التمرير في Flutter: SingleChildScrollView والقوائم الأفقية
كتبت شاشة جميلة فيها نموذج من خمسة حقول، وعملت جيداً. ثم فتح المستخدم لوحة المفاتيح، فظهر شريط أصفر وأسود مكتوب عليه «BOTTOM OVERFLOWED BY 120 PIXELS». هذا أشهر خطأ يقابله مبتدئو Flutter، وحله في كلمة واحدة: التمرير. في هذا الدرس ستتعلم كيف تجعل أي محتوى قابلاً للتمرير، رأسياً وأفقياً.
لماذا يحدث Overflow؟
Section titled “لماذا يحدث Overflow؟”Column لا يعرف التمرير. إذا كان مجموع ارتفاع أبنائه أكبر من الشاشة، فلا يوجد مكان للباقي، فيرسم Flutter شريط التحذير الأصفر والأسود في وضع التطوير.
تخيّل Column مثل رف كتب ارتفاعه ثابت: إذا وضعت كتباً أكثر مما يتحمل سقطت. أما القائمة القابلة للتمرير فهي مثل لفافة ورق طويلة تنظر إليها من نافذة صغيرة، وتحرّكها لترى الباقي.
SingleChildScrollView
Section titled “SingleChildScrollView”أبسط حل: لف المحتوى كله بـ SingleChildScrollView.
SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ Container(height: 300, color: Colors.indigo.shade100), const SizedBox(height: 16), Container(height: 300, color: Colors.amber.shade100), const SizedBox(height: 16), Container(height: 300, color: Colors.teal.shade100), ], ),)مجموع الارتفاعات هنا أكثر من 900، وهو أطول من أغلب الشاشات. لكن لا يحدث خطأ، لأن المستخدم يستطيع السحب لرؤية الباقي.
- اسمه «Single Child» لأنه يأخذ ابناً واحداً فقط، وغالباً يكون
Column. paddingهنا مسافة داخل منطقة التمرير، فتتحرك مع المحتوى.
النماذج ولوحة المفاتيح
Section titled “النماذج ولوحة المفاتيح”عندما تظهر لوحة المفاتيح تأكل نصف الشاشة تقريباً. لذلك اجعلها قاعدة: أي شاشة فيها حقول إدخال، لف محتواها بـ SingleChildScrollView. عندها يستطيع المستخدم تمرير النموذج ليصل إلى الحقول السفلية والزر.
متى أستخدم SingleChildScrollView ومتى ListView؟
Section titled “متى أستخدم SingleChildScrollView ومتى ListView؟”الاثنان يمرران المحتوى، لكن بينهما فرق مهم:
SingleChildScrollView |
ListView.builder |
|
|---|---|---|
| يبني العناصر | كلها مرة واحدة | الظاهر منها فقط |
| مناسب لـ | شاشة واحدة طويلة قليلاً: نموذج، صفحة تفاصيل | قوائم طويلة أو غير معروفة العدد |
| نوع المحتوى | عناصر مختلفة الشكل | عناصر متكررة بنفس الشكل |
القاعدة: محتوى محدود ومتنوع يناسبه SingleChildScrollView، وعناصر كثيرة متشابهة يناسبها ListView.builder.
التمرير الأفقي
Section titled “التمرير الأفقي”لصف يتحرك يميناً ويساراً، مثل قسم «الأكثر مبيعاً» في المتاجر، غيّر اتجاه التمرير:
SizedBox( height: 120, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: 10, itemBuilder: (context, index) { return Container( width: 120, margin: const EdgeInsets.only(left: 12), decoration: BoxDecoration( color: Colors.indigo.shade100, borderRadius: BorderRadius.circular(12), ), child: Center(child: Text('منتج ${index + 1}')), ); }, ),)لاحظ شيئين:
scrollDirection: Axis.horizontalتجعل القائمة أفقية.- القائمة الأفقية يجب أن يكون لها ارتفاع محدد، لذلك وضعناها داخل
SizedBox. بدونه تحاول أخذ ارتفاع غير محدود ويظهر خطأ.
ونفس الخاصية موجودة في SingleChildScrollView لتمرير صف من الأزرار أو الوسوم أفقياً.
قائمة داخل Column
Section titled “قائمة داخل Column”خطأ مشهور آخر: وضع ListView مباشرة داخل Column. القائمة تطلب ارتفاعاً غير محدود، والعمود لا يقيّدها، فتحصل على الخطأ «Vertical viewport was given unbounded height». الحل أن تعطي القائمة حدوداً:
Column( children: [ const Padding( padding: EdgeInsets.all(16), child: Text('كل المهام', style: TextStyle(fontSize: 20)), ), Expanded( child: ListView.builder( itemCount: 30, itemBuilder: (context, index) { return ListTile(title: Text('مهمة رقم ${index + 1}')); }, ), ), ],)Expanded يقول للقائمة: «خذي ما بقي من ارتفاع الشاشة بعد العنوان، ومرّري بداخله».
السحب للتحديث: RefreshIndicator
Section titled “السحب للتحديث: RefreshIndicator”الحركة المعروفة: تسحب القائمة للأسفل فتظهر دائرة تدور ثم يتحدث المحتوى.
RefreshIndicator( onRefresh: () async { await Future.delayed(const Duration(seconds: 1)); }, child: ListView( children: const [ ListTile(title: Text('خبر أول')), ListTile(title: Text('خبر ثانٍ')), ], ),)onRefresh دالة async، وتبقى الدائرة تدور حتى تنتهي. في تطبيق حقيقي تضع فيها طلب البيانات الجديدة من الإنترنت.
شريط التمرير: Scrollbar
Section titled “شريط التمرير: Scrollbar”على الهاتف يظهر شريط تمرير رفيع تلقائياً أثناء السحب. وإذا أردته ظاهراً بوضوح، وهذا مفيد على الويب وسطح المكتب، لف المحتوى بـ Scrollbar:
Scrollbar( child: ListView.builder( itemCount: 50, itemBuilder: (context, index) => ListTile(title: Text('سطر $index')), ),)المشروع: صفحة رئيسية لمتجر
Section titled “المشروع: صفحة رئيسية لمتجر”class StoreHome extends StatelessWidget { const StoreHome({super.key});
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('المتجر')), body: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( height: 150, margin: const EdgeInsets.symmetric(horizontal: 16), alignment: Alignment.center, decoration: BoxDecoration( color: Colors.indigo, borderRadius: BorderRadius.circular(16), ), child: const Text( 'خصومات نهاية الموسم', style: TextStyle(color: Colors.white, fontSize: 22), ), ), const Padding( padding: EdgeInsets.all(16), child: Text('الأكثر مبيعاً', style: TextStyle(fontSize: 20)), ), SizedBox( height: 110, child: ListView.builder( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: 8, itemBuilder: (context, index) { return Container( width: 110, margin: const EdgeInsets.only(left: 12), decoration: BoxDecoration( color: Colors.amber.shade100, borderRadius: BorderRadius.circular(12), ), child: Center(child: Text('منتج ${index + 1}')), ); }, ), ), const Padding( padding: EdgeInsets.all(16), child: Text('وصل حديثاً', style: TextStyle(fontSize: 20)), ), for (var i = 1; i <= 6; i++) ListTile( leading: const Icon(Icons.shopping_bag_outlined), title: Text('منتج جديد رقم $i'), subtitle: const Text('متوفر الآن'), ), ], ), ), ); }}الصفحة كلها تتمرر رأسياً بفضل SingleChildScrollView، وبداخلها صف «الأكثر مبيعاً» يتمرر أفقياً وحده. والقسم الأخير استخدم for داخل قائمة الأبناء (collection for) لإنشاء ستة عناصر، وهذا مقبول لأن عددها قليل ومعروف.
جرّب بنفسك
Section titled “جرّب بنفسك”- شغّل صفحة المتجر وجرّب التمرير في الاتجاهين.
- احذف
SingleChildScrollViewمؤقتاً وشاهد خطأ Overflow بعينك، ثم أعده. - زد عدد المنتجات الجديدة إلى 30. هل ما زال
forاختياراً جيداً؟ ولماذا؟ - تحدٍّ: أضف صفاً أفقياً من
Chipللتصنيفات تحت اللافتة، يتمرر بـSingleChildScrollViewأفقي.
أخطاء شائعة
Section titled “أخطاء شائعة”- ListView داخل Column بدون Expanded: خطأ «unbounded height». لف القائمة بـ
Expanded. - قائمة أفقية بدون ارتفاع محدد: نفس نوع الخطأ. ضعها في
SizedBoxلهheight. - مئات العناصر داخل SingleChildScrollView: تُبنى كلها مرة واحدة فيتأخر فتح الشاشة. استخدم
ListView.builder.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
شاشة فيها نموذج من ستة حقول، وعند فتح لوحة المفاتيح يظهر خطأ Overflow. ما الحل الأنسب؟
SingleChildScrollView يجعل المحتوى قابلاً للتمرير، فلا يحدث خطأ عندما تصغر المساحة بسبب لوحة المفاتيح.
سؤال
ما الذي تحتاجه قائمة ListView أفقية حتى تعمل داخل Column؟
القائمة الأفقية تتمدد رأسياً بلا حدود إن لم تقيّدها، لذلك نعطيها ارتفاعاً محدداً.
الخلاصة
Section titled “الخلاصة”- خطأ Overflow معناه أن المحتوى أكبر من المساحة، والحل غالباً هو التمرير.
SingleChildScrollViewلشاشة محدودة المحتوى، وخاصة النماذج مع لوحة المفاتيح.ListView.builderللقوائم الطويلة، وscrollDirection: Axis.horizontalللتمرير الأفقي.ListViewداخلColumnتحتاجExpanded، والقائمة الأفقية تحتاج ارتفاعاً محدداً.RefreshIndicatorللسحب للتحديث، وScrollbarلإظهار شريط التمرير.