EduFlow

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

الـ tabs والمسارات الديناميكية والـ params

19 دقراءة 3 دبيئة تجريب الكود

الدرس في لمحة

أضف إلى Trailhead أشرطة tabs سفلية باستخدام مجموعة مسارات، وشاشة لتفاصيل الرحلة عبر مسار ديناميكي [id]، واقرأ الـ params الخاصة بها بأمان، متذكّرًا أن الـ params تصل دائمًا نصوصًا.

  1. 1مجموعة مسارات للـ tabs
  2. 2المسارات الديناميكية والـ params
  3. 3المعرّفات في الـ params، والبيانات من الـ state

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

صار في Trailhead الآن أكثر من مكان يمكن أن تكون فيه: سجلّ الرحلات، وصفحة الإحصاءات، والإعدادات، إضافةً إلى شاشة تفاصيل لكل رحلة. الثلاثة الأولى نظائر يتنقّل المستخدمون بينها طوال الوقت؛ أما شاشة التفاصيل فشيء تتعمّق فيه ثم تعود منه. هذا هو الشكل الكلاسيكي لتطبيقات الجوّال: tabs للنظائر، وstack للتعمّق.

مجموعة مسارات للـ tabs

هذه بنية الملفات التي يستخدمها Trailhead:

Text
src/app/
  _layout.tsx          root Stack
  (tabs)/
    _layout.tsx        Tabs navigator
    index.tsx          /          (Log)
    stats.tsx          /stats
    settings.tsx       /settings
  hikes/
    [id].tsx           /hikes/42  (pushed over the tabs)

المجلّد بين قوسين مجموعة مسارات (route group). تتيح لشاشات الـ tabs الثلاث أن تتشارك layout دون أن تضيف شيئًا إلى عناوينها: (tabs)/stats.tsx هو /stats، لا /tabs/stats.

يُرجع layout الـ tabs أداة التنقّل Tabs، مع Tabs.Screen واحد لكل tab، بالترتيب الذي يجب أن تظهر به:

src/app/(tabs)/_layout.tsx
import Ionicons from '@expo/vector-icons/Ionicons';
import { Tabs } from 'expo-router';

export default function TabLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#2f6f4e' }}>
      <Tabs.Screen
        name="index"
        options={{
          title: 'Log',
          tabBarIcon: ({ color, size }) => <Ionicons name="list" color={color} size={size} />,
        }}
      />
      <Tabs.Screen
        name="stats"
        options={{
          title: 'Stats',
          tabBarIcon: ({ color, size }) => <Ionicons name="stats-chart" color={color} size={size} />,
        }}
      />
      <Tabs.Screen
        name="settings"
        options={{
          title: 'Settings',
          tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" color={color} size={size} />,
        }}
      />
    </Tabs>
  );
}

ثم يضع الـ root layout مجموعة الـ tabs كلها داخل الـ Stack، مع إخفاء ترويسة المجموعة نفسها (فلكل tab ترويسته)، ويليها مسار التفاصيل:

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

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="hikes/[id]" options={{ title: 'Hike' }} />
    </Stack>
  );
}

ولأن hikes/[id] ينتمي إلى الـ Stack الجذري لا إلى الـ tabs، فإن فتح رحلة يُدخل شاشة كاملة منزلقة فوق شريط الـ tabs، وتعيدك إيماءة الرجوع إلى الـ tab الذي جئت منه. هذا ما تفعله معظم تطبيقات iOS وAndroid، ويعطي شاشة التفاصيل ارتفاع الهاتف كله.

عادتان تُبقيان الـ tabs مريحة. اجعلها بين ثلاث وخمس وجهات رئيسية يزورها المستخدمون كثيرًا؛ فالـ tab السادس يعني عادةً أن بعضها مكانه داخل الإعدادات. وتذكّر أن الـ tabs تبقى مركّبة بعد زيارتها: انتقل من Log إلى Stats ثم عُد، وسيحتفظ السجلّ بموضع تمريره. هذا هو السلوك الذي يتوقّعه المستخدمون، فلا تُعِد ضبطه. وإن احتاج tab واحد إلى شاشات تعمّق خاصة به تُبقي شريط الـ tabs ظاهرًا، فأعطِ ذلك الـ tab مجلّدًا بملف _layout.tsx خاص به يُرجع Stack؛ فتداخل أدوات التنقّل ليس إلا تداخلًا للمجلّدات.

المسارات الديناميكية والـ params

الملف المسمّى بأقواس مربّعة، [id].tsx، يطابق أي جزء مفرد من الـ URL ويكشفه بوصفه param اسمه id. اقرأه باستخدام useLocalSearchParams:

src/app/hikes/[id].tsx
import { Stack, useLocalSearchParams } from 'expo-router';
import { Text, View } from 'react-native';
import { useHike } from '../../state/hikes';

export default function HikeDetail() {
  const { id } = useLocalSearchParams<{ id: string }>();
  const hike = useHike(id);

  if (!hike) {
    return (
      <View style={{ flex: 1, padding: 24 }}>
        <Text>That hike doesn't exist anymore.</Text>
      </View>
    );
  }

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Stack.Screen options={{ title: hike.name }} />
      <Text style={{ fontSize: 16 }}>{hike.distanceKm} km</Text>
    </View>
  );
}

وللوصول إليها، اربط إلى الـ URL. إما أن تبني النص بنفسك، أو تدع Expo Router يملأ النمط:

TSX
<Link href={`/hikes/${hike.id}`}>{hike.name}</Link>

<Link href={{ pathname: '/hikes/[id]', params: { id: hike.id } }}>{hike.name}</Link>

أي شيء في params ليس جزءًا من المسار يصبح query string: فـ params: { id: hike.id, units: 'mi' } ينتج /hikes/42?units=mi، وتقرأ شاشة التفاصيل units بالـ hook نفسه.

المعرّفات في الـ params، والبيانات من الـ state

مرّر أصغر شيء يعرّف البيانات، وهو معرّف في العادة، وابحث عن البيانات في الشاشة الوجهة. يبدو تمرير كائن رحلة كامل مريحًا، لكن الـ params تُحوَّل إلى نصّ داخل الـ URL: يصبح الكائن نصًّا، ولا يستطيع deep link إعادة إنشائه أبدًا، وإن عدّل المستخدم الرحلة، تعرض شاشة التفاصيل النسخة القديمة التي سُلّمت لها. الدرس التالي يبني الـ state المشتركة التي يقرأ منها useHike(id).

يُرجع useLocalSearchParams الـ params الخاصة بالشاشة التي يُستدعى فيها، وهذا ما تريده في الغالبية العظمى من الحالات. أما شقيقه useGlobalSearchParams فيتحدّث كلما تغيّر أي مسار، حتى المسارات في الخلفية، وهذا يسبّب عمليات render إضافية؛ احتفظ به لأشياء مثل التحليلات (analytics) التي تهتم فعلًا بكل عملية تنقّل.

التالي: الـ state التي تقف وراء عمليات البحث هذه، أي مصدر واحد للحقيقة عن الرحلات، تتشاركه كل الـ tabs والشاشات.

أهم النقاط

  • المجلّد بين قوسين، مثل (tabs)، يجمّع المسارات تحت layout دون أن يضيف جزءًا إلى الـ URL.
  • ضع أداة تنقّل الـ tabs في (tabs)/_layout.tsx، وأضف شاشات التفاصيل من الـ Stack الجذري لتغطّي شريط الـ tabs.
  • الملف المسمّى [id].tsx يطابق أي جزء مفرد من الـ URL؛ اقرأه باستخدام useLocalSearchParams().
  • الـ params في المسارات نصوص (أو مصفوفات من النصوص)، فحوّلها وتحقّق منها قبل الاستخدام.
  • مرّر المعرّفات في الـ params، لا الكائنات كاملة أبدًا، وابحث عن البيانات في الشاشة الوجهة.

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

تحقّق من فهمك

3 أسئلة

السؤال 1 من 3

ما الـ URL الذي يخدمه src/app/(tabs)/stats.tsx؟

السؤال 2 من 3

شاشة التفاصيل تفعل هذا، والرحلة ذات المعرّف 7 لا تُطابَق أبدًا. لماذا؟

TSX
const { id } = useLocalSearchParams<{ id: string }>();
const hike = hikes.find((h) => h.id === id); // h.id is a number
السؤال 3 من 3

يمرّر زميلك كائن رحلة كاملًا بوصفه param في المسار حتى لا تحتاج شاشة التفاصيل إلى البحث عنه. ما المشكلة الرئيسية؟

تدريب

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

اقرأ الـ params في المسار بأمان

يمكن فتح شاشة التفاصيل في Trailhead على /hikes/[id] من داخل التطبيق، أو من إشعار، أو من رابط قديم يحوي أي شيء. اكتب parseHikeParams(params) التي تأخذ الكائن القادم من useLocalSearchParams() وتُرجع { id, units, photo }:

  • id: عدد صحيح موجب، أو null إن كان مفقودًا أو غير صالح ('abc'، '0'، '4.5'، '-3').
  • units: 'mi' إن كان الـ param يساوي mi بأي حالة أحرف؛ وإلا 'km'.
  • photo: عدد صحيح يساوي 0 أو أكثر (أيّ صورة تُعرض أولًا)؛ و0 إن كان مفقودًا أو غير صالح.

قد يصل أي param أيضًا مصفوفةً من النصوص (مفتاح query مكرّر). استخدم القيمة الأولى.

// params is whatever useLocalSearchParams() returned for /hikes/[id]
// Return { id, units, photo } with real types and safe defaults.
function parseHikeParams(params) {
  return { id: params.id, units: params.units, photo: params.photo };
}

console.log(parseHikeParams({ id: '42', units: 'MI', photo: '2' }));
console.log(parseHikeParams({ id: 'abc' }));
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • مجموعة params عادية تُحوَّل إلى أنواع حقيقية
  • المعرّفات غير الصالحة أو المفقودة تصبح null
  • الوحدات لا تتأثّر بحالة الأحرف وقيمتها الافتراضية km
  • فهرس الصورة يساوي 0 افتراضيًا للقيم المفقودة أو غير الصالحة
  • الـ params التي تصل مصفوفات تستخدم قيمتها الأولى