EduFlow

الوحدة 3 · التنقّل والـ State

النماذج ولوحة المفاتيح

20 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

ابنِ نموذج إضافة رحلة في Trailhead بحيث يعمل مع لوحة مفاتيح الهاتف، فينتقل بين الحقول، ويبقى ظاهرًا فوق لوحة المفاتيح، ويحفظ من النقرة الأولى، ويتحقّق من المدخلات برسائل خطأ واضحة لكل حقل.

  1. 1مسوّدة من النصوص الخام
  2. 2التنقّل بين الحقول
  3. 3إبقاء الحقول فوق لوحة المفاتيح
  4. 4كلمة عن التواريخ
  5. 5تحقّق لا يُزعج

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

النماذج هي المكان الذي تختلف فيه تطبيقات الجوّال عن الويب أكثر من أي مكان آخر. لوحة المفاتيح تغطّي نصف الشاشة، ولا يوجد مفتاح Tab، والنقرة الأولى على زرّ أحيانًا لا تفعل إلا إغلاق لوحة المفاتيح، والكتابة على الزجاج بطيئة لدرجة أن كل رسالة خطأ غير ضرورية تبدو شخصية. لنموذج “Log a hike” في Trailhead خمسة حقول: الاسم، والتاريخ، والمسافة، والمدّة، والملاحظات. وهكذا تجعله مريحًا.

مسوّدة من النصوص الخام

احفظ كل ما كتبه المستخدم في كائن واحد من النصوص، كما كُتب بالضبط. وحلّله حين تحتاج إلى أرقام، كما فعلت مع parseDistanceKm في القسم 1:

src/app/new-hike.tsx
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:

TSX
<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 بداخله:

TSX
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.

تحقّق لا يُزعج

احفظ القواعد في دالة نقية: المسوّدة تدخل، والأخطاء تخرج. والكائن الفارغ يعني أن المدخلات صالحة.

src/lib/validateHike.ts
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)، بجوار الحقل، وأبقِه حيًّا من تلك اللحظة فيختفي لحظة إصلاحه.

TSX
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".
  • تحقّق من المدخلات في دالة نقية تُرجع كائن أخطاء، واعرض كل خطأ بجوار حقله بعد أول محاولة حفظ أو بعد مغادرة الحقل.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

يقول المستخدمون إنهم مضطرون للنقر على Save مرتين في نموذج Trailhead حين تكون لوحة المفاتيح مفتوحة. النقرة الأولى تغلق لوحة المفاتيح فقط. ما الإصلاح؟

السؤال 2 من 4

على iOS، يختفي حقل الملاحظات في أسفل النموذج خلف لوحة المفاتيح حين يُركَّز عليه. ما الإعداد المعتاد لإصلاح ذلك؟

السؤال 3 من 4

متى يجب أن يعرض Trailhead الخطأ “Distance must be a number” تحت حقل المسافة؟

السؤال 4 من 4

كيف تجعل مفتاح الإدخال في حقل الاسم ينقل التركيز إلى حقل المسافة؟

تدريب

بيئة تجريب الكود

تحقّق من نموذج إضافة رحلة

اكتب validateHikeForm(draft, today) لنموذج Trailhead. يحمل draft النصوص الخام التي كتبها المستخدم: name وdate وdistance وduration وnotes. وtoday هو تاريخ اليوم بصيغة YYYY-MM-DD. أرجع كائنًا فيه رسالة لـ كل حقل غير صالح (أي نصّ غير فارغ)؛ وأرجع {} حين يكون كل شيء صالحًا.

  • name: مطلوب بعد حذف المسافات الطرفية؛ و60 حرفًا على الأكثر.
  • date: بصيغة YYYY-MM-DD، وتاريخ موجود فعلًا (2026-02-30 غير موجود)، وليس بعد today.
  • distance: رقم أكبر من 0 و200 على الأكثر؛ والفاصلة العشرية مثل 8,4 مقبولة.
  • duration: اختياري؛ وإن مُلئ فدقائق كاملة من 1 إلى 1440.
  • notes: اختياري؛ و500 حرف على الأكثر.
// Return an object with one message per invalid field. {} means the draft is valid.
// `today` is the current date as 'YYYY-MM-DD', passed in so the function stays pure.
function validateHikeForm(draft, today) {
  const errors = {};
  if (draft.name === '') errors.name = 'Give the hike a name.';
  return errors;
}

const draft = { name: 'Ridge Loop', date: '2026-10-04', distance: '8,4', duration: '', notes: '' };
console.log(validateHikeForm(draft, '2026-10-05'));
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

الـ console

(لا مخرجات)

الفحوص

  • المسوّدة الكاملة المعقولة ليس فيها أخطاء
  • تُرفض الأسماء الفارغة والطويلة جدًا
  • تُرفض التواريخ سيئة الصيغة والمستحيلة والمستقبلية
  • تُرفض المسافات المفقودة وغير الرقمية والصفرية
  • المدّة المملوءة يجب أن تكون دقائق كاملة من 1 إلى 1440
  • يُبلَّغ عن عدّة حقول غير صالحة دفعة واحدة