EduFlow

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

الـ modals والمسارات المحميّة والـ deep links

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

الدرس في لمحة

اعرض نموذج إضافة رحلة في Trailhead بوصفه modal، وأبقِ المستخدمين غير المسجّلين على شاشة تسجيل الدخول باستخدام Stack.Protected، وافتح رحلات محدّدة من الـ deep links، مع فهم كيف تُطابَق الـ URLs مع ملفات المسارات.

  1. 1نموذج إضافة رحلة بوصفه modal
  2. 2المسارات المحميّة
  3. 3الـ deep links
  4. 4كيف يختار الـ URL مساره

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

ثلاثة مواقف لا تناسب نمط «أضف شاشة» الذي استخدمته حتى الآن. تسجيل رحلة مهمّة قصيرة يبدؤها المستخدم وينهيها، فيجب أن تبدو منفصلة عن التصفّح. وبعض الشاشات يجب ألا توجد إلا للمستخدمين المسجّلين. ويجب أن ينفتح Trailhead على الرحلة الصحيحة حين ينقر أحدهم على رابط في تذكير أو رسالة. يتعامل Expo Router مع الثلاثة في ملف الـ layout.

نموذج إضافة رحلة بوصفه modal

الـ modal شاشة تُعرض فوق السياق الحالي، وتنزلق عادةً من الأسفل. على iOS تظهر بطاقةً تبقى الشاشة السابقة مرئية خلفها، ويستطيع المستخدمون سحبها إلى الأسفل للإلغاء. اجعل النموذج modal بضبط طريقة عرضه في الـ Stack الجذري:

src/app/_layout.tsx
<Stack>
  <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
  <Stack.Screen name="hikes/[id]" options={{ title: 'Hike' }} />
  <Stack.Screen name="new-hike" options={{ presentation: 'modal', title: 'Log a hike' }} />
</Stack>

يبقى فتحه بـ router.push('/new-hike') (أو Link)؛ وإغلاقه بعد الحفظ بـ router.back()، الذي يُزيل الـ modal كأي شاشة. لا يملك Android ورقة على شكل بطاقة افتراضيًا، فيظهر الـ modal صفحةً بملء الشاشة بحركة مختلفة؛ لذا أعطِ النموذج زرّ Cancel ظاهرًا في headerLeft ليكون للمنصّتين مخرج واضح.

حين يحوي النموذج مدخلات غير محفوظة، فإن سحبه بعيدًا يُضيّع ما كتبه المستخدم. في النماذج الطويلة، اطلب التأكيد قبل التجاهل باستخدام Alert عند Cancel؛ أما في نموذج قصير مثل نموذج Trailhead، فمسوّدة تُعاد كتابتها بسرعة مقايضة مقبولة.

المسارات المحميّة

يزامن Trailhead الرحلات مع حساب، لذا تتطلّب معظم الشاشات تسجيل الدخول. يغلّف Stack.Protected المسارات بشرط:

src/app/_layout.tsx
import { Stack } from 'expo-router';
import { useSession } from '../state/session';

export default function RootLayout() {
  const { isSignedIn, isLoading } = useSession();
  if (isLoading) return null; // the splash screen is still showing

  return (
    <Stack>
      <Stack.Protected guard={!isSignedIn}>
        <Stack.Screen name="sign-in" options={{ headerShown: false }} />
      </Stack.Protected>

      <Stack.Protected guard={isSignedIn}>
        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
        <Stack.Screen name="hikes/[id]" options={{ title: 'Hike' }} />
        <Stack.Screen name="new-hike" options={{ presentation: 'modal', title: 'Log a hike' }} />
      </Stack.Protected>
    </Stack>
  );
}

حين يكون guard قيمته false، تصبح تلك المسارات غير متاحة: التنقّل إليها، بما في ذلك من deep link، يرسل المستخدم إلى أول شاشة متاحة بدلًا منها، وتُحذف أي مدخلات لها في السجلّ. تسجيل الدخول يقلب قيمة isSignedIn، فيُعاد الـ render للـ layout، وتظهر الشاشات المحميّة دون أن تستدعي router على الإطلاق. وتسجيل الخروج يفعل العكس، فيمسح أيضًا الـ back stack، فلا تستطيع إيماءة الرجوع كشف شاشة خاصة بالمستخدم المسجّل.

useSession هو context مثل الذي بنيته قبل درسين. تغطّي isLoading اللحظة عند بدء التشغيل التي يكون فيها Trailhead ما زال يقرأ الـ token المحفوظ من التخزين الآمن (القسم 4)؛ ورسم لا شيء بينما شاشة البداية ظاهرة يتجنّب وميض صفحة تسجيل الدخول للمستخدمين المسجّلين أصلًا.

الـ deep links

الـ deep link (الرابط العميق) يفتح التطبيق على شاشة محدّدة. أعطِ Trailhead مخطّط URL (scheme) في app.json:

app.json
{
  "expo": {
    "name": "Trailhead",
    "scheme": "trailhead"
  }
}

الآن يفتح trailhead://hikes/42 التطبيق ويرسم /hikes/42 بالـ Stack نفسه الذي بنيته، بما في ذلك زرّ رجوع إلى السجلّ. تحصل على هذا مجانًا من التوجيه المبني على الملفات: كل مسار له URL أصلًا. المخطّطات جزء من الإعدادات الأصلية للتطبيق، لذا اختبرها في development build؛ فلـ Expo Go مخطّطه الخاص. ومن الطرفية:

Terminal
npx uri-scheme open trailhead://hikes/42 --ios
npx uri-scheme open trailhead://hikes/42 --android

للمخطّطات المخصّصة نقطة ضعف: أي تطبيق يستطيع ادّعاء trailhead://. وللروابط التي تشاركها على الويب، استخدم الـ universal links (على iOS) والـ App Links (على Android)، التي تستخدم نطاق https:// الخاص بك وتتطلّب استضافة ملف تحقّق صغير عليه. يشرح دليل الروابط في Expo الاثنين خطوة بخطوة.

كيف يختار الـ URL مساره

حين يصل رابط، يقارن Expo Router مساره بكل ملف مسار. ومعرفة القواعد تُزيل التخمين:

القاعدة مثال
المجموعات مثل (tabs) لا تضيف جزءًا (tabs)/stats.tsx يطابق /stats
index لا يضيف جزءًا settings/index.tsx يطابق /settings
[name] يطابق جزءًا واحدًا بالضبط hikes/[id].tsx يطابق /hikes/42
[...name] يطابق جزءًا متبقّيًا أو أكثر، بوصفها مصفوفة trails/[...path].tsx يطابق /trails/alps/mont-blanc
الثابت يتغلّب على الديناميكي، والديناميكي على الشامل /hikes/new يختار hikes/new.tsx بدلًا من hikes/[id].tsx
الـ query string تصبح params إضافية /hikes/42?units=mi يضيف units

هذا الترتيب هو سبب قدرتك على إضافة hikes/new.tsx بجوار hikes/[id].tsx بأمان. وفي التمرين ستكتب أداة المطابقة هذه بنفسك، وهي أسرع طريقة لكي لا تفاجئك مرة أخرى أبدًا.

بهذا يكتمل التنقّل. أما القسم 4 فيصل Trailhead بالعالم الخارجي: خادم، وتخزين الجهاز، ومستشعراته.

أهم النقاط

  • شاشة Stack مع presentation: 'modal' تنزلق إلى الأعلى كورقة على iOS؛ وأغلقها باستخدام router.back() بعد الحفظ.
  • يُخفي Stack.Protected مع guard المسارات حين يكون الشرط false، فلا يستطيع المستخدمون غير المسجّلين الوصول إليها، حتى من deep link.
  • الـ scheme في app.json يجعل trailhead://hikes/42 يفتح المسار /hikes/[id]؛ والمخطّطات المخصّصة تحتاج إلى development build، لا إلى Expo Go.
  • عند مطابقة URL، لا تضيف المجموعات ولا index أي جزء، والأجزاء الثابتة تتغلّب على الديناميكية، و[...rest] يلتقط كل ما تبقّى.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

في Trailhead الملفان src/app/hikes/new.tsx وsrc/app/hikes/[id].tsx معًا. أيّهما يُرسم للعنوان /hikes/new؟

السؤال 2 من 3

ينقر مستخدمون غير مسجّلين على رابط في بريد قديم إلى trailhead://hikes/42. يقع مسار الرحلة داخل <Stack.Protected guard={isSignedIn}>. ماذا يحدث؟

السؤال 3 من 3

أضفت scheme بقيمة trailhead إلى app.json وجرّبت trailhead://hikes/42 في Expo Go. لا شيء ينفتح. لماذا؟

تدريب

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

طابق deep link مع ملف مسار

اكتب matchRoute(url, routes) بالطريقة التي يختار بها Expo Router شاشة لـ deep link. الـ routes مسارات ملفات دون .tsx (انظر ROUTES). أرجع { route, params } لأفضل مطابقة، أو null.

  • المجموعات مثل (tabs) وindex الأخير لا تضيف جزءًا إلى الـ URL.
  • [id] يطابق جزءًا واحدًا بالضبط ويضيفه إلى params نصًّا.
  • [...path] يطابق جزءًا متبقّيًا أو أكثر ويضيفها مصفوفةً.
  • حين تطابق عدّة مسارات، فضّل الأجزاء الثابتة على أجزاء [dynamic]، و[dynamic] على [...catchAll]، مقارنًا من الجزء الأول.
  • تجاهل الشرطة المائلة في النهاية؛ وأضف قيم الـ query string (?units=mi) إلى params.
const ROUTES = [
  '(tabs)/index',
  '(tabs)/stats',
  'settings/index',
  'settings/units',
  'hikes/new',
  'hikes/[id]',
  'hikes/[id]/photos',
  'trails/[...path]',
];

// Return { route, params } for the best matching route file, or null.
function matchRoute(url, routes) {
  const path = url.replace(/^\//, '');
  const route = routes.find((r) => r === path);
  return route ? { route, params: {} } : null;
}

console.log(JSON.stringify(matchRoute('/hikes/42?units=mi', ROUTES)));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • المجموعات وملفات index لا تضيف جزءًا إلى الـ URL
  • الأجزاء الديناميكية تُطابَق وتصبح params نصية
  • المسار الثابت يتغلّب على الديناميكي
  • المسارات الشاملة تجمع الأجزاء المتبقّية في مصفوفة
  • الـ query strings تصبح params والشرطات المائلة الأخيرة تُتجاهَل
  • المسارات غير المعروفة أو الطويلة جدًا تُرجع null