{"id":"l-rn-2-4","courseId":"react-native-apps","lang":"ar","kind":"lesson","summary":"اعرض سجلّ الرحلات المتنامي في Trailhead باستخدام FlatList وSectionList، وافهم الـ virtualization، وطبّق التحسينات القليلة التي تُبقي القوائم الطويلة سلسة على الهواتف متوسطة الفئة.","html":"<p>يبدأ سجلّ Trailhead بنحو اثنتي عشرة رحلة. المتنزّه المتحمّس يسجّل ثلاث رحلات أسبوعيًا؛ وبعد أربع سنوات يتجاوز السجلّ 600 صف، لكلٍّ منها صورة مصغّرة وعنوان وبعض الإحصاءات. اعرض ذلك باستخدام <code dir=\"ltr\">ScrollView</code> و<code dir=\"ltr\">.map()</code>، وسيبني التطبيق الـ native views لكل صف قبل أن يعرض أول إطار. على هاتف رائد قد لا تلاحظ. أما على هواتف Android متوسطة الفئة التي يستخدمها معظم العالم، فستحصل على شاشة فارغة وتقطّع.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">FlatList: ارسم ما هو مرئي</h2>\n<p>يأخذ <code dir=\"ltr\">FlatList</code> بياناتك ودالة ترسم عنصرًا واحدًا، ولا يركّب إلا الصفوف القريبة من منطقة العرض:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"tsx\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">src/app/index.tsx</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { FlatList, Text, View } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'react-native'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { HikeRow } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '../components/HikeRow'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { useHikes } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '../state/hikes'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> default</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> function</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> HikeLog</span><span style=\"color:#953800;--shiki-dark:#FFA657\">() </span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">{</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> hikes</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> useHikes</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">FlatList</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      data</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">hikes</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      keyExtractor</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">hike</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> hike.id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      renderItem</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ </span><span style=\"color:#953800;--shiki-dark:#FFA657\">item</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">HikeRow</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> hike</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">item</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      ItemSeparatorComponent</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">View</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> style</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">{ height: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, backgroundColor: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#e2e8e4'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, marginLeft: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">80</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      ListEmptyComponent</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">Text</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> style</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">{ padding: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">24</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, textAlign: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'center'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>No hikes yet. Log your first one!&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">Text</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">      contentContainerStyle</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">{ paddingBottom: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">24</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    /></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  );</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>مثل ScrollView، يحتاج FlatList إلى ارتفاع محدود، لذا يجب أن يعطيه أبوه <code dir=\"ltr\">flex: 1</code> (والشاشة في Expo Router تفعل ذلك أصلًا). تستقبل <code dir=\"ltr\">renderItem</code> الكائن <code dir=\"ltr\">{ item, index }</code>. ويتيح لك <code dir=\"ltr\">ListHeaderComponent</code> و<code dir=\"ltr\">ListFooterComponent</code> أن تتمرّر بطاقة ملخّص أو مؤشّر «تحميل المزيد» مع القائمة، بدلًا من تغليف الـ FlatList داخل ScrollView، وهذا يُبطل الـ virtualization كليًا ويُطلق تحذيرًا.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">كيف يعمل الـ virtualization</h2>\n<p>يحتفظ FlatList بـ <strong>نافذة رسم</strong> (render window): الصفوف الظاهرة على الشاشة، زائد مخزون احتياطي فوقها وتحتها. وأثناء التمرير، تُركَّب الصفوف التي تدخل النافذة وتُزال التي تخرج منها، ويحلّ محلّها فراغ بالارتفاع الصحيح.</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 640 300\" role=\"img\" aria-labelledby=\"l-rn-2-4-t1\"><defs><marker id=\"arrow-l-rn-2-4-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-rn-2-4-t1\">من بين 600 رحلة، لا يُركَّب إلا الصفوف الظاهرة على الشاشة ومخزون احتياطي فوقها وتحتها؛ أما الصفوف خارج النافذة فيحلّ محلّها فراغ بالارتفاع نفسه.</title>\n  <rect class=\"d-box\" x=\"40\" y=\"20\" width=\"220\" height=\"260\" rx=\"10\"/>\n  <text class=\"d-label-muted\" x=\"150\" y=\"44\" text-anchor=\"middle\">الصفوف 1–90: فراغ</text>\n  <rect class=\"d-box-accent\" x=\"56\" y=\"64\" width=\"188\" height=\"40\" rx=\"6\"/>\n  <text class=\"d-label\" x=\"150\" y=\"89\" text-anchor=\"middle\">احتياطي (مركَّب)</text>\n  <rect class=\"d-box-primary\" x=\"56\" y=\"112\" width=\"188\" height=\"76\" rx=\"6\"/>\n  <text class=\"d-label-strong\" x=\"150\" y=\"155\" text-anchor=\"middle\">الصفوف المرئية</text>\n  <rect class=\"d-box-accent\" x=\"56\" y=\"196\" width=\"188\" height=\"40\" rx=\"6\"/>\n  <text class=\"d-label\" x=\"150\" y=\"221\" text-anchor=\"middle\">احتياطي (مركَّب)</text>\n  <text class=\"d-label-muted\" x=\"150\" y=\"264\" text-anchor=\"middle\">الصفوف 320–600: فراغ</text>\n  <rect class=\"d-box-success\" x=\"320\" y=\"100\" width=\"150\" height=\"100\" rx=\"18\"/>\n  <text class=\"d-label-strong\" x=\"395\" y=\"146\" text-anchor=\"middle\">شاشة الهاتف</text>\n  <text class=\"d-label-muted\" x=\"395\" y=\"170\" text-anchor=\"middle\">منطقة العرض</text>\n  <path class=\"d-arrow\" d=\"M320 150 L252 150\" marker-end=\"url(#arrow-l-rn-2-4-1)\"/>\n  <text class=\"d-label\" x=\"500\" y=\"60\" text-anchor=\"start\">windowSize</text>\n  <text class=\"d-label-muted\" x=\"500\" y=\"80\" text-anchor=\"start\">الافتراضي 21 شاشة</text>\n  <text class=\"d-label\" x=\"500\" y=\"240\" text-anchor=\"start\">initialNumToRender</text>\n  <text class=\"d-label-muted\" x=\"500\" y=\"260\" text-anchor=\"start\">الافتراضي 10 صفوف</text>\n</svg><figcaption>يُبقي FlatList نافذة واحدة فقط من الصفوف مركّبة</figcaption></figure>\n<p>ينتج عن ذلك أمران. أولًا، <strong>يجب أن تكون المفاتيح (keys) ثابتة</strong>: يستخدمها FlatList وReact لمعرفة أي صفّ هو أي صفّ بينما تأتي الصفوف وتذهب، فاستخدم معرّفًا من بياناتك. فهارس المصفوفة تنكسر لحظة إدراج رحلة جديدة في الأعلى. ثانيًا، <strong>الصفوف تنسى الـ state المحلية</strong> حين تُزال. علامة «موسَّع» المحفوظة في <code dir=\"ltr\">useState</code> داخل الصف تعود إلى قيمتها الأولى بعد تمرير طويل. ارفع هذه الـ state إلى الأب (<code dir=\"ltr\">expandedId</code>) أو إلى بياناتك.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">خطأ شائع</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>استخدام الفهرس مفتاحًا للصفوف</span></p>\n<p>دون <code dir=\"ltr\">keyExtractor</code>، يستخدم FlatList القيمة <code dir=\"ltr\">item.key</code>، ثم <code dir=\"ltr\">item.id</code>، وأخيرًا فهرس المصفوفة. لنفترض أن الـ API الخاص بالمزامنة في Trailhead أرسل <code dir=\"ltr\">uuid</code> بدلًا من <code dir=\"ltr\">id</code>: سيصبح مفتاح كل صف موضعه بصمت، وتمرير <code dir=\"ltr\">(item, index) =&gt; String(index)</code> بنفسك يفعل الشيء ذاته. أدرج رحلة جديدة في الأعلى، وسيحمل كل صف تحتها مفتاحًا مختلفًا: تُعاد تركيب الصفوف، وتومض الصور، وتقفز الـ state الخاصة بكل صف إلى الرحلة الخطأ. أرجع دائمًا المعرّف الحقيقي، أيًّا كان اسمه.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">الحفاظ على السلاسة</h2>\n<p>معظم التقطّع في القوائم يأتي من إعادة الـ render لصفوف لم تتغيّر، أو من عمل ثقيل داخلها. بالترتيب بحسب العائد:</p>\n<ol>\n<li><strong>طبّق memo على الصف.</strong> صدّر <code dir=\"ltr\">HikeRow</code> مغلّفًا بـ <code dir=\"ltr\">memo</code>، فلا يُعاد الـ render للصف إلا حين يتغيّر الـ prop المسمّى <code dir=\"ltr\">hike</code>. (إن كان مشروعك يفعّل React Compiler، فهو يتولّى الـ memoization نيابةً عنك.)</li>\n<li><strong>أبقِ <code dir=\"ltr\">renderItem</code> ثابتة.</strong> الدالة السهمية المباشرة لا بأس بها مع صفّ مغلّف بـ memo، لكن لا تنشئ كائنات جديدة للـ props في كل render، مثل <code dir=\"ltr\">style={{…}}</code> أو <code dir=\"ltr\">hike={{ ...item }}</code>، فهي تُبطل <code dir=\"ltr\">memo</code>.</li>\n<li><strong>لا عمل ثقيل داخل الصفوف.</strong> نسّق التواريخ واحسب الإحصاءات مرة واحدة، حين تتغيّر البيانات، لا في الـ render الخاص بكل صف.</li>\n<li><strong>صور بالحجم المناسب.</strong> صورة بعرض 4000 بكسل مصغّرة داخل صورة مصغّرة بعرض 56 نقطة تكلّف ذاكرة لكل صفّ مرئي. اطلب صورًا مصغّرة من الـ API، أو استخدم <code dir=\"ltr\">expo-image</code> مع التخزين المؤقت.</li>\n<li><strong>ارتفاعات ثابتة؟ استخدم <code dir=\"ltr\">getItemLayout</code>.</strong> إن كان كل صف 72 نقطة زائد فاصل بنقطة واحدة، يستطيع FlatList تخطّي القياس:</li>\n</ol>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"tsx\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">TSX</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> ROW</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 73</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">FlatList</span></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">  // …</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  getItemLayout</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">_</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">index</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ({ length: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">ROW</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, offset: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">ROW</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> *</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> index, index })</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">/></span></span></code></pre></div>\n<p>وللسجلّات المحفوظة على خادم، لا تجلب الرحلات الـ 600 كلها دفعة واحدة. يُطلَق <code dir=\"ltr\">onEndReached</code> حين يقترب المستخدم من الأسفل بالتمرير (ومدى الاقتراب يحدّده <code dir=\"ltr\">onEndReachedThreshold</code>، ويُقاس بارتفاعات الشاشة)، وهذه هي اللحظة لطلب الصفحة التالية وإلحاقها بـ <code dir=\"ltr\">data</code>. اعرض مؤشّر تحميل في <code dir=\"ltr\">ListFooterComponent</code> أثناء التحميل، واحمِ الكود من إطلاق الطلب مرتين.</p>\n<p>ضبط <code dir=\"ltr\">windowSize</code> و<code dir=\"ltr\">initialNumToRender</code> يأتي في النهاية، وبعد أن تقيس فقط. وللقوائم الضخمة جدًا أو المعقّدة، تنتقل فرق كثيرة إلى FlashList من Shopify، الذي يعيد تدوير views الصفوف بدلًا من تركيب صفوف جديدة، ويحافظ على واجهة برمجية شبيهة بـ FlatList.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">SectionList: بيانات مجمّعة</h2>\n<p>يجمّع Trailhead الرحلات بحسب الشهر، مع ترويسة لاصقة لكل شهر. يفعل <code dir=\"ltr\">SectionList</code> ذلك انطلاقًا من بيانات بهذا الشكل:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"ts\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">TypeScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> sections</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> [</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  { title: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'October 2026'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, data: [ridgeLoop, lakeTrail] },</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  { title: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'September 2026'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, data: [oldMill] },</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">];</span></span></code></pre></div>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"tsx\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">TSX</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"نسخ الكود\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>نسخ</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">SectionList</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  sections</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">sections</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  keyExtractor</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">hike</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> hike.id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  renderItem</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ </span><span style=\"color:#953800;--shiki-dark:#FFA657\">item</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">HikeRow</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> hike</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">item</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> /></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  renderSectionHeader</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({ </span><span style=\"color:#953800;--shiki-dark:#FFA657\">section</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> }) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">Text</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> style</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">={</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">styles.header</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">{</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">section.title</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">Text</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  stickySectionHeadersEnabled</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">/></span></span></code></pre></div>\n<p>الجزء المثير للاهتمام ليس الـ component؛ بل تحويل مصفوفة مسطّحة من الرحلات إلى شكل <code dir=\"ltr\">sections</code> هذا، مرتّبة بشكل صحيح. وهذا هو تمرينك.</p>\n<p>يبدأ القسم 3 بعد ذلك: مع وجود شاشات حقيقية للتنقّل بينها، يحتاج Trailhead إلى نظام تنقّل (navigation).</p>\n","toc":[{"id":"sec-1","text":"FlatList: ارسم ما هو مرئي","level":2},{"id":"sec-2","text":"كيف يعمل الـ virtualization","level":2},{"id":"sec-3","text":"الحفاظ على السلاسة","level":2},{"id":"sec-4","text":"SectionList: بيانات مجمّعة","level":2}],"takeaways":["<code dir=\"ltr\">ScrollView</code> مع <code dir=\"ltr\">.map()</code> يركّب (mount) كل الصفوف دفعة واحدة؛ أما <code dir=\"ltr\">FlatList</code> فيركّب فقط نافذة من الصفوف حول الجزء المرئي.","أعطِ <code dir=\"ltr\">FlatList</code> دالة <code dir=\"ltr\">keyExtractor</code> تُرجع معرّفًا ثابتًا من بياناتك، لا فهرس المصفوفة أبدًا.","الصفوف في القائمة الافتراضية (virtualized) تُزال حين تبتعد بالتمرير، لذا احفظ state الصف في بياناتك، لا داخل component الصف.","طبّق memo على component الصف، وأبقِ <code dir=\"ltr\">renderItem</code> ثابتة، وأضف <code dir=\"ltr\">getItemLayout</code> حين يكون لكل الصفوف الارتفاع نفسه.","يأخذ <code dir=\"ltr\">SectionList</code> الخاصية <code dir=\"ltr\">sections</code> بشكل <code dir=\"ltr\">{ title, data }</code> ويعرض قوائم مجمّعة بترويسات لاصقة."],"further":[{"title":"FlatList","url":"https://reactnative.dev/docs/flatlist"},{"title":"SectionList","url":"https://reactnative.dev/docs/sectionlist"},{"title":"Optimizing FlatList Configuration","url":"https://reactnative.dev/docs/optimizing-flatlist-configuration"}],"quiz":[{"q":"<p>في سجلّ Trailhead ستمئة رحلة تُعرض باستخدام <code dir=\"ltr\">ScrollView</code> و<code dir=\"ltr\">.map()</code>. تستغرق الشاشة ثلاث ثوانٍ لتظهر على هاتف Android قديم. لماذا؟</p>","options":[{"text":"يُنزّل ScrollView كل الصور المصغّرة قبل أن يعرض أي شيء.","why":"الصور تُحمَّل بشكل غير متزامن في الحالتين. الكلفة في إنشاء 600 صف من الـ native views مقدّمًا."},{"text":"ScrollView مُهمَل ويعمل في وضع توافق.","why":"ScrollView ليس مُهمَلًا؛ إنه الخيار الصحيح لشاشة محتوى. لكنه الخيار الخاطئ لقائمة بيانات طويلة."},{"text":"يرسم ScrollView كل الصفوف الـ 600، بكل الـ native views الخاصة بها، قبل أول إطار.","why":"صحيح. كان FlatList سيرسم ما يملأ الشاشة الأولى أو نحو ذلك، ثم يملأ المزيد كلما مرّرت."}],"answer":2},{"q":"<p>لكل صفّ رحلة <code dir=\"ltr\">useState</code> محلية للحالة «موسَّع». يوسّع المستخدمون صفًّا، ثم يمرّرون بعيدًا إلى الأسفل، ثم يعودون، فيجدونه مطويًّا من جديد. لماذا؟</p>","options":[{"text":"أزال FlatList الصف حين خرج من نافذة الرسم، وذهبت الـ state المحلية معه.","why":"صحيح. الـ virtualization يزيل الصفوف البعيدة. احفظ <code dir=\"ltr\">expandedId</code> في الأب أو في بياناتك ومرّرها إلى الأسفل."},{"text":"تُرجع keyExtractor مفاتيح مكرّرة، فأعادت React استخدام الصف الخطأ.","why":"المفاتيح المكرّرة تجعل صفوفًا خاطئة تتحدّث، لا تعيد ضبط الـ state عند التمرير. وستحذّرك React أيضًا."},{"text":"يعيد FlatList ترتيب البيانات كلما مرّرت.","why":"لا يغيّر FlatList ترتيب بياناتك أبدًا. هو يقرّر فقط أي الصفوف يركّب."}],"answer":0},{"q":"<p>ارتفاع كل صفّ في Trailhead 72 نقطة بالضبط. أيّ prop يتيح لـ FlatList تخطّي قياس الصفوف والقفز مباشرة إلى الصف 400؟</p>","options":[{"text":"<code dir=\"ltr\">initialNumToRender</code>","why":"يتحكّم في عدد الصفوف التي تُرسم في الدفعة الأولى؛ ولا يخبر FlatList بأي شيء عن أحجامها."},{"text":"<code dir=\"ltr\">getItemLayout</code>","why":"صحيح. حين تكون <code dir=\"ltr\">length</code> و<code dir=\"ltr\">offset</code> معروفتين مسبقًا، لا يقيس FlatList الصفوف، وتعمل <code dir=\"ltr\">scrollToIndex</code> حتى مع الصفوف التي لم تُرسم بعد."},{"text":"<code dir=\"ltr\">removeClippedSubviews</code>","why":"يفصل الـ views الواقعة خارج الشاشة عن شجرة العناصر الأصلية. قد يوفّر ذاكرة، لكنه لا يقول شيئًا عن أحجام الصفوف."},{"text":"<code dir=\"ltr\">windowSize</code>","why":"يتحكّم في عدد الشاشات من الصفوف التي تبقى مركّبة حول منطقة العرض، لا في طريقة قياسها."}],"answer":1}],"exercise":{"kind":"web","mode":"js","starter":{"js":"const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\nconst hikes = [\n  { id: 'h1', name: 'Ridge Loop', date: '2026-10-04', distanceKm: 8.4 },\n  { id: 'h2', name: 'Old Mill Creek', date: '2026-09-12', distanceKm: 5.1 },\n  { id: 'h3', name: 'Lake Trail', date: '2026-10-19', distanceKm: 11.2 },\n  { id: 'h4', name: 'Eagle Point', date: '2025-10-02', distanceKm: 14.0 },\n  { id: 'h5', name: 'Fern Gully', date: '2026-09-28', distanceKm: 3.6 },\n];\n\n// Return SectionList sections: [{ title: 'October 2026', data: [...] }, ...]\nfunction groupHikesByMonth(hikes) {\n  const sections = {};\n  for (const hike of hikes) {\n    const month = MONTHS[Number(hike.date.slice(5, 7))];\n    if (!sections[month]) sections[month] = [];\n    sections[month].push(hike);\n  }\n  return Object.keys(sections).map((title) => ({ title, data: sections[title] }));\n}\n\nconsole.log(JSON.stringify(groupHikesByMonth(hikes), null, 2));\n"},"solution":{"js":"const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];\n\nconst hikes = [\n  { id: 'h1', name: 'Ridge Loop', date: '2026-10-04', distanceKm: 8.4 },\n  { id: 'h2', name: 'Old Mill Creek', date: '2026-09-12', distanceKm: 5.1 },\n  { id: 'h3', name: 'Lake Trail', date: '2026-10-19', distanceKm: 11.2 },\n  { id: 'h4', name: 'Eagle Point', date: '2025-10-02', distanceKm: 14.0 },\n  { id: 'h5', name: 'Fern Gully', date: '2026-09-28', distanceKm: 3.6 },\n];\n\nfunction groupHikesByMonth(hikes) {\n  const sorted = [...hikes].sort((a, b) => b.date.localeCompare(a.date));\n  const sections = [];\n  for (const hike of sorted) {\n    const year = hike.date.slice(0, 4);\n    const month = MONTHS[Number(hike.date.slice(5, 7)) - 1];\n    const title = `${month} ${year}`;\n    const last = sections[sections.length - 1];\n    if (last && last.title === title) last.data.push(hike);\n    else sections.push({ title, data: [hike] });\n  }\n  return sections;\n}\n\nconsole.log(JSON.stringify(groupHikesByMonth(hikes), null, 2));\n"},"checks":[{"id":"titles","test":"const sample = [{ id: 'a', date: '2026-10-04' }, { id: 'b', date: '2026-09-12' }, { id: 'c', date: '2026-10-19' }, { id: 'd', date: '2025-10-02' }, { id: 'e', date: '2026-09-28' }]; const s = groupHikesByMonth(sample); return Array.isArray(s) && s.map((x) => x.title).join('|') === 'October 2026|September 2026|October 2025';"},{"id":"items-newest-first","test":"const sample = [{ id: 'a', date: '2026-10-04' }, { id: 'b', date: '2026-09-12' }, { id: 'c', date: '2026-10-19' }, { id: 'd', date: '2025-10-02' }, { id: 'e', date: '2026-09-28' }]; const s = groupHikesByMonth(sample); return s[0].data.map((h) => h.id).join() === 'c,a' && s[1].data.map((h) => h.id).join() === 'e,b' && s[2].data.map((h) => h.id).join() === 'd';"},{"id":"january-december","test":"const s = groupHikesByMonth([{ id: 'x', date: '2026-01-31' }, { id: 'y', date: '2025-12-01' }]); return s.length === 2 && s[0].title === 'January 2026' && s[1].title === 'December 2025';"},{"id":"no-mutation","test":"const sample = [{ id: 'a', date: '2026-10-04' }, { id: 'b', date: '2026-09-12' }, { id: 'c', date: '2026-10-19' }, { id: 'd', date: '2025-10-02' }, { id: 'e', date: '2026-09-28' }]; const ids = sample.map((h) => h.id).join(); groupHikesByMonth(sample); return sample.map((h) => h.id).join() === ids;"},{"id":"empty","test":"const s = groupHikesByMonth([]); return Array.isArray(s) && s.length === 0;"}],"title":"جمّع الرحلات في أقسام SectionList","prompt":"<p>اكتب <code dir=\"ltr\">groupHikesByMonth(hikes)</code> ليتمكّن الـ <code dir=\"ltr\">SectionList</code> في Trailhead من عرض السجلّ مجمّعًا بحسب الشهر. أرجع مصفوفة من الأقسام بالشكل <code dir=\"ltr\">{ title, data }</code> بحيث:</p>\n<ul>\n<li><code dir=\"ltr\">title</code> هو اسم الشهر والسنة، مثل <code dir=\"ltr\">&quot;October 2026&quot;</code> (استخدم المصفوفة <code dir=\"ltr\">MONTHS</code>)،</li>\n<li>الأقسام مرتّبة <strong>من الشهر الأحدث أولًا</strong>، وOctober 2026 وOctober 2025 قسمان مختلفان،</li>\n<li>الرحلات داخل كل قسم مرتّبة <strong>من الأحدث أولًا</strong>،</li>\n<li>لا تُعدَّل مصفوفة المدخلات.</li>\n</ul>\n<p>التواريخ نصوص بصيغة <code dir=\"ltr\">YYYY-MM-DD</code>. في الكود المبدئي عدّة أخطاء؛ شغّله وقارن مخرجاته بما تحتاج إليه القائمة.</p>\n","hints":["<code dir=\"ltr\">MONTHS</code> تبدأ فهارسها من الصفر، لكن الشهر في <code dir=\"ltr\">'2026-10-04'</code> هو 10. والعنوان يحتاج إلى السنة أيضًا.","إن رتّبت نسخة من الرحلات من الأحدث أولًا (<code dir=\"ltr\">[...hikes].sort((a, b) =&gt; b.date.localeCompare(a.date))</code>)، فإن نصوص التاريخ بصيغة ISO تُقارَن بشكل صحيح كنصوص.","بعد الترتيب، مرّ على الرحلات مرة واحدة: إن طابق عنوان الرحلة عنوان آخر قسم، أضفها إليه؛ وإلا فابدأ قسمًا جديدًا. وعندها ينتج ترتيب الأقسام من الترتيب نفسه."],"explanation":"<p>استخدم الكود المبدئي فهرس شهر خاطئًا (بفارق واحد)، وأسقط السنة فاندمج October 2025 في October 2026، واعتمد على ترتيب مفاتيح الكائن، وهو يتبع ترتيب الإدراج لا التاريخ. ترتيب نسخة أولًا ثم تجميع الرحلات المتتالية يحلّ ترتيب الأقسام والعناصر معًا في مرور واحد، ويترك مصفوفة المستدعي دون مساس. ونصوص ISO بصيغة <code dir=\"ltr\">YYYY-MM-DD</code> تُرتَّب زمنيًا كنصّ عادي، وهذا يتجنّب مفاجآت المناطق الزمنية التي تأتي من <code dir=\"ltr\">new Date()</code>. احسب هذا حين تتغيّر البيانات (مثلًا داخل <code dir=\"ltr\">useMemo</code>)، لا داخل <code dir=\"ltr\">renderItem</code>.</p>\n","checkLabels":{"titles":"عناوين الأقسام تتضمّن السنة ومرتّبة من الشهر الأحدث أولًا","items-newest-first":"الرحلات داخل كل قسم مرتّبة من الأحدث أولًا","january-december":"يحصل يناير وديسمبر على اسمَي الشهرين الصحيحين","no-mutation":"تحتفظ مصفوفة الرحلات الأصلية بترتيبها","empty":"السجلّ الفارغ يعطي مصفوفة أقسام فارغة"}},"words":645,"readMinutes":4}