تخطَّ إلى المحتوى

التمرير في Flutter: SingleChildScrollView والقوائم الأفقية

الدرس 15 من 33 · Flutter

كتبت شاشة جميلة فيها نموذج من خمسة حقول، وعملت جيداً. ثم فتح المستخدم لوحة المفاتيح، فظهر شريط أصفر وأسود مكتوب عليه «BOTTOM OVERFLOWED BY 120 PIXELS». هذا أشهر خطأ يقابله مبتدئو Flutter، وحله في كلمة واحدة: التمرير. في هذا الدرس ستتعلم كيف تجعل أي محتوى قابلاً للتمرير، رأسياً وأفقياً.

Column لا يعرف التمرير. إذا كان مجموع ارتفاع أبنائه أكبر من الشاشة، فلا يوجد مكان للباقي، فيرسم Flutter شريط التحذير الأصفر والأسود في وضع التطوير.

تخيّل Column مثل رف كتب ارتفاعه ثابت: إذا وضعت كتباً أكثر مما يتحمل سقطت. أما القائمة القابلة للتمرير فهي مثل لفافة ورق طويلة تنظر إليها من نافذة صغيرة، وتحرّكها لترى الباقي.

أبسط حل: لف المحتوى كله بـ 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.

لصف يتحرك يميناً ويساراً، مثل قسم «الأكثر مبيعاً» في المتاجر، غيّر اتجاه التمرير:

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 لتمرير صف من الأزرار أو الوسوم أفقياً.

خطأ مشهور آخر: وضع 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:

Scrollbar(
child: ListView.builder(
itemCount: 50,
itemBuilder: (context, index) => ListTile(title: Text('سطر $index')),
),
)

المشروع: صفحة رئيسية لمتجر

Section titled “المشروع: صفحة رئيسية لمتجر”
lib/store_home.dart
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) لإنشاء ستة عناصر، وهذا مقبول لأن عددها قليل ومعروف.

  1. شغّل صفحة المتجر وجرّب التمرير في الاتجاهين.
  2. احذف SingleChildScrollView مؤقتاً وشاهد خطأ Overflow بعينك، ثم أعده.
  3. زد عدد المنتجات الجديدة إلى 30. هل ما زال for اختياراً جيداً؟ ولماذا؟
  4. تحدٍّ: أضف صفاً أفقياً من Chip للتصنيفات تحت اللافتة، يتمرر بـ SingleChildScrollView أفقي.
  • ListView داخل Column بدون Expanded: خطأ «unbounded height». لف القائمة بـ Expanded.
  • قائمة أفقية بدون ارتفاع محدد: نفس نوع الخطأ. ضعها في SizedBox له height.
  • مئات العناصر داخل SingleChildScrollView: تُبنى كلها مرة واحدة فيتأخر فتح الشاشة. استخدم ListView.builder.

سؤال

شاشة فيها نموذج من ستة حقول، وعند فتح لوحة المفاتيح يظهر خطأ Overflow. ما الحل الأنسب؟

سؤال

ما الذي تحتاجه قائمة ListView أفقية حتى تعمل داخل Column؟

  • خطأ Overflow معناه أن المحتوى أكبر من المساحة، والحل غالباً هو التمرير.
  • SingleChildScrollView لشاشة محدودة المحتوى، وخاصة النماذج مع لوحة المفاتيح.
  • ListView.builder للقوائم الطويلة، و scrollDirection: Axis.horizontal للتمرير الأفقي.
  • ListView داخل Column تحتاج Expanded، والقائمة الأفقية تحتاج ارتفاعاً محدداً.
  • RefreshIndicator للسحب للتحديث، و Scrollbar لإظهار شريط التمرير.