الوحدة 3 · التنقّل والـ State
النماذج ولوحة المفاتيح
20 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
ابنِ نموذج إضافة رحلة في Trailhead بحيث يعمل مع لوحة مفاتيح الهاتف، فينتقل بين الحقول، ويبقى ظاهرًا فوق لوحة المفاتيح، ويحفظ من النقرة الأولى، ويتحقّق من المدخلات برسائل خطأ واضحة لكل حقل.
- 1مسوّدة من النصوص الخام
- 2التنقّل بين الحقول
- 3إبقاء الحقول فوق لوحة المفاتيح
- 4كلمة عن التواريخ
- 5تحقّق لا يُزعج
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
النماذج هي المكان الذي تختلف فيه تطبيقات الجوّال عن الويب أكثر من أي مكان آخر. لوحة المفاتيح تغطّي نصف الشاشة، ولا يوجد مفتاح Tab، والنقرة الأولى على زرّ أحيانًا لا تفعل إلا إغلاق لوحة المفاتيح، والكتابة على الزجاج بطيئة لدرجة أن كل رسالة خطأ غير ضرورية تبدو شخصية. لنموذج “Log a hike” في Trailhead خمسة حقول: الاسم، والتاريخ، والمسافة، والمدّة، والملاحظات. وهكذا تجعله مريحًا.
مسوّدة من النصوص الخام
احفظ كل ما كتبه المستخدم في كائن واحد من النصوص، كما كُتب بالضبط. وحلّله حين تحتاج إلى أرقام، كما فعلت مع parseDistanceKm في القسم 1:
import { useRef, useState, type ComponentRef } from 'react';
import { TextInput } from 'react-native';
type Draft = { name: string; date: string; distance: string; duration: string; notes: string };
// Today's date in the phone's time zone. toISOString() would give the UTC date,
// which is already tomorrow (or still yesterday) for part of the day in most places.
function localToday() {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
const emptyDraft = (): Draft => ({ name: '', date: localToday(), distance: '', duration: '', notes: '' });
export default function NewHike() {
const [draft, setDraft] = useState<Draft>(emptyDraft);
const [submitted, setSubmitted] = useState(false);
const [touched, setTouched] = useState<Partial<Record<keyof Draft, boolean>>>({});
const distanceRef = useRef<ComponentRef<typeof TextInput>>(null);
const durationRef = useRef<ComponentRef<typeof TextInput>>(null);
const set = (field: keyof Draft) => (text: string) => setDraft((d) => ({ ...d, [field]: text }));
// …errors, save and JSX below
}تعطي الدالة المساعدة set('distance') كل حقل onChangeText دون خمس دوال شبه متطابقة. وتخزين النصوص يعني أن “8,” تبقى “8,” أثناء الكتابة، بدلًا من أن تنهار إلى 8 أو NaN.
التنقّل بين الحقول
لا يوجد مفتاح Tab، فيقوم مفتاح الإدخال بالمهمّة. سمِّه باستخدام returnKeyType="next"، وحين يُضغط، ركّز على الحقل التالي عبر ref:
<TextInput
value={draft.name}
onChangeText={set('name')}
placeholder="Ridge Loop"
autoCapitalize="words"
returnKeyType="next"
submitBehavior="submit"
onSubmitEditing={() => distanceRef.current?.focus()}
onBlur={() => setTouched((t) => ({ ...t, name: true }))}
/>
<TextInput
ref={distanceRef}
value={draft.distance}
onChangeText={set('distance')}
keyboardType="decimal-pad"
returnKeyType="next"
onSubmitEditing={() => durationRef.current?.focus()}
onBlur={() => setTouched((t) => ({ ...t, distance: true }))}
/>يُبقي submitBehavior="submit" لوحة المفاتيح مفتوحة بينما ينتقل التركيز، فلا تومض مغلقةً ثم مفتوحةً من جديد. وفي الحقل الأخير استخدم returnKeyType="done" ودعه يرسل النموذج. وتنبيه صريح: لوحات المفاتيح الرقمية على iOS ليس فيها مفتاح إدخال أصلًا، فينقر المستخدمون على الحقل التالي أو على زرّ في شريط أدوات. هذا طبيعي، وسبب آخر لعدم الاعتماد على لوحة المفاتيح وحدها.
إبقاء الحقول فوق لوحة المفاتيح
حين تنفتح لوحة المفاتيح، تستقرّ فوق تطبيقك. والحقل القريب من الأسفل (الملاحظات) يختفي خلفها. الإصلاح المعتاد يغلّف النموذج بـ KeyboardAvoidingView ويضع ScrollView بداخله:
import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native';
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView
contentContainerStyle={{ padding: 16, gap: 16 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{/* fields and the Save button */}
</ScrollView>
</KeyboardAvoidingView>على iOS، تضيف padding حشوًا سفليًا يساوي ارتفاع لوحة المفاتيح، فينكمش الـ ScrollView ويمكن تمرير الحقل المركّز عليه إلى منطقة الرؤية. أما على Android فعادةً ما تتغيّر أبعاد النافذة لاستيعاب لوحة المفاتيح أصلًا، لذا فإن undefined هي القيمة الموصى بها. ويغلق keyboardDismissMode="on-drag" لوحة المفاتيح حين يمرّر المستخدم، وهذا يبدو طبيعيًا.
وللنماذج الطويلة ذات الحقول الكثيرة، توصي وثائق Expo بمكتبة react-native-keyboard-controller، التي يمرّر الـ KeyboardAwareScrollView الخاص بها الحقل المركّز عليه إلى منطقة الرؤية تلقائيًا؛ ويضيف KeyboardToolbar من المكتبة نفسها فوق لوحة المفاتيح أزرارًا للسابق والتالي والإنهاء. تحتاج إلى development build، وهو ما سيحصل عليه Trailhead في القسم 4.
كلمة عن التواريخ
تحتفظ مسوّدة Trailhead بالتاريخ نصًّا بصيغة YYYY-MM-DD، قيمته الافتراضية تاريخ اليوم، لأن معظم الرحلات تُسجَّل في اليوم نفسه. لكن كتابة التواريخ على لوحة مفاتيح الهاتف مزعجة، لذا تعرض تطبيقات الإنتاج منتقي التاريخ الخاص بالمنصّة: عجلة أو تقويم على iOS، ونافذة حوار على Android. تغلّف حزمة المجتمع @react-native-community/datetimepicker الاثنين معًا، وهي مثال جيد على component يختلف سلوكه بين المنصّتين بما يكفي لتريد معه الملفات الخاصة بكل منصّة من القسم 2.
تحقّق لا يُزعج
احفظ القواعد في دالة نقية: المسوّدة تدخل، والأخطاء تخرج. والكائن الفارغ يعني أن المدخلات صالحة.
export function validateHike(d: Draft) {
const errors: Partial<Record<keyof Draft, string>> = {};
if (!d.name.trim()) errors.name = 'Give the hike a name.';
if (parseDistanceKm(d.distance) === null) errors.distance = 'Enter a distance in km, like 8.4.';
return errors;
}ثم قرّر متى تعرض كل رسالة. عرض الأخطاء من أول ضغطة مفتاح يعاقب الناس لأنهم لم ينتهوا من الكتابة. وانتظار نافذة تنبيه بعد Save يُخفي النموذج خلف قائمة يجب أن يحفظوها. النمط الذي ينجح: اعرض خطأ الحقل بعد أن يغادره المستخدم (touched) أو يضغط Save (submitted)، بجوار الحقل، وأبقِه حيًّا من تلك اللحظة فيختفي لحظة إصلاحه.
const errors = validateHike(draft);
const show = (field: keyof Draft) => (submitted || touched[field]) && errors[field];
function save() {
setSubmitted(true);
if (Object.keys(errors).length > 0) return;
dispatch({ type: 'added', hike: toHike(draft) });
router.back();
}
// under the distance input:
{show('distance') ? <Text style={{ color: '#b42318' }}>{errors.distance}</Text> : null}لمستان لإمكانية الوصول تكلّف كلٌّ منهما سطرًا واحدًا: أعطِ كل حقل accessibilityLabel يطابق تسميته المرئية، وأبقِ نص الخطأ تحت حقله مباشرة ليصل إليه قارئ الشاشة بعده.
تحوّل toHike المسوّدة الصالحة إلى Hike حقيقي (تحلّل الأرقام وتولّد معرّفًا). وبعد الحفظ، يعيد router.back() المستخدم إلى السجلّ، حيث تكون الرحلة الجديدة في الأعلى أصلًا لأن الـ reducer وضعها هناك.
يفتح النموذج حاليًا شاشةً عادية مُضافة إلى الـ stack. في الدرس التالي ستعرضه بوصفه modal، وتحمي الشاشات خلف تسجيل الدخول، وتسمح للروابط بفتح Trailhead على رحلة محدّدة.
أهم النقاط
- احفظ مسوّدة النموذج نصوصًا خامًا في كائن state واحد، ولا تحلّلها وتتحقّق منها إلا حين تحتاج إلى النتيجة.
- اربط الحقول ببعضها باستخدام
returnKeyType="next"والـ refs، فينقلك مفتاح الإدخال إلى الحقل التالي بدلًا من إغلاق لوحة المفاتيح. - غلّف النموذج بـ
KeyboardAvoidingViewمعbehavior="padding"على iOS، وأعطِ الـ ScrollView الداخليkeyboardShouldPersistTaps="handled". - تحقّق من المدخلات في دالة نقية تُرجع كائن أخطاء، واعرض كل خطأ بجوار حقله بعد أول محاولة حفظ أو بعد مغادرة الحقل.