ListView وفيزياء التمرير
مقدمة عن ListView
عنصر ListView هو أكثر عناصر التمرير استخداماً في Flutter. يعرض عناصره الفرعية واحداً تلو الآخر في اتجاه التمرير (عمودياً بشكل افتراضي). فهم المنشئات المختلفة وتأثيراتها على الأداء أمر بالغ الأهمية لبناء واجهات تمرير سلسة وفعالة.
ListView مع عناصر فرعية (المنشئ الافتراضي)
المنشئ الافتراضي لـ ListView يأخذ قائمة من العناصر الفرعية. يتم بناء جميع العناصر فوراً، حتى غير المرئية على الشاشة. هذا مناسب للقوائم الصغيرة ذات العدد المعروف من العناصر (أقل من 20-30 عنصراً تقريباً).
ListView أساسي
ListView(
padding: const EdgeInsets.all(16.0),
children: const [
ListTile(
leading: Icon(Icons.person),
title: Text('أحمد الفارسي'),
subtitle: Text('مهندس برمجيات'),
),
Divider(),
ListTile(
leading: Icon(Icons.person),
title: Text('سارة جونسون'),
subtitle: Text('مصممة منتجات'),
),
Divider(),
ListTile(
leading: Icon(Icons.person),
title: Text('عمر خالد'),
subtitle: Text('عالم بيانات'),
),
],
)
ListView.builder بدلاً من ذلك.
ListView.builder (التحميل الكسول)
منشئ ListView.builder ينشئ العناصر بشكل كسول — يبني فقط العناصر المرئية حالياً على الشاشة (بالإضافة إلى منطقة عازلة صغيرة). هذا هو النهج الموصى به لمعظم القوائم، خاصة تلك التي تحتوي على عناصر كثيرة أو محملة من قاعدة بيانات أو API.
مثال ListView.builder
// قائمة جهات اتصال بـ 1000 عنصر - فقط العناصر المرئية تُبنى
class ContactListScreen extends StatelessWidget {
final List<String> contacts = List.generate(
1000,
(index) => 'جهة اتصال \${index + 1}',
);
ContactListScreen({super.key});
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: contacts.length,
itemBuilder: (context, index) {
return ListTile(
leading: CircleAvatar(
child: Text(contacts[index][0]),
),
title: Text(contacts[index]),
subtitle: Text('+966 5\${index.toString().padLeft(8, '0')}'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// معالجة النقر
},
);
},
);
}
}
ListView.separated
منشئ ListView.separated مشابه لـ ListView.builder لكنه يضيف عنصر فاصل بين كل عنصر. هذا مثالي للقوائم التي تحتاج فواصل أو تباعد أو خلفيات متناوبة.
مثال ListView.separated
ListView.separated(
itemCount: messages.length,
separatorBuilder: (context, index) => const Divider(
height: 1.0,
indent: 72.0, // محاذاة مع النص بعد الأفاتار
),
itemBuilder: (context, index) {
final message = messages[index];
return ListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage(message.avatarUrl),
),
title: Text(message.sender),
subtitle: Text(
message.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
message.timeAgo,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
);
},
)
ListView.custom
منشئ ListView.custom يوفر أقصى تحكم بقبول SliverChildDelegate. يمكنك استخدام SliverChildBuilderDelegate للبناء الكسول أو SliverChildListDelegate للقوائم الثابتة مع خيارات متقدمة مثل findChildIndexCallback لإعادة الترتيب الفعالة.
مثال ListView.custom
ListView.custom(
childrenDelegate: SliverChildBuilderDelegate(
(context, index) {
return Card(
key: ValueKey(items[index].id),
child: ListTile(
title: Text(items[index].name),
),
);
},
childCount: items.length,
findChildIndexCallback: (Key key) {
// يساعد Flutter في العثور على العناصر بكفاءة أثناء إعادة الترتيب
final valueKey = key as ValueKey<int>;
final index = items.indexWhere((item) => item.id == valueKey.value);
return index != -1 ? index : null;
},
),
)
ScrollController
يتيح لك ScrollController التحكم ومراقبة موضع التمرير برمجياً. يمكنك التمرير إلى مواضع محددة والاستماع لأحداث التمرير وتحديد إزاحة التمرير الحالية.
استخدام ScrollController
class ScrollableListScreen extends StatefulWidget {
const ScrollableListScreen({super.key});
@override
State<ScrollableListScreen> createState() => _ScrollableListScreenState();
}
class _ScrollableListScreenState extends State<ScrollableListScreen> {
final ScrollController _scrollController = ScrollController();
bool _showScrollToTop = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
final showButton = _scrollController.offset > 200;
if (showButton != _showScrollToTop) {
setState(() => _showScrollToTop = showButton);
}
}
void _scrollToTop() {
_scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
controller: _scrollController,
itemCount: 100,
itemBuilder: (context, index) => ListTile(
title: Text('عنصر \${index + 1}'),
),
),
floatingActionButton: _showScrollToTop
? FloatingActionButton(
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward),
)
: null,
);
}
}
dispose() لمنع تسريب الذاكرة. أيضاً أزل المستمعين إذا أضفتهم يدوياً.
فيزياء التمرير
يوفر Flutter تطبيقات ScrollPhysics مختلفة تتحكم في كيفية استجابة عنصر قابل للتمرير لإدخال المستخدم — كيف يرتد أو يُقيّد أو يتصرف عند الوصول إلى الحافة.
أنواع فيزياء التمرير
// BouncingScrollPhysics - ارتداد بنمط iOS عند الحواف
ListView.builder(
physics: const BouncingScrollPhysics(),
itemCount: 50,
itemBuilder: (context, index) => ListTile(title: Text('عنصر \$index')),
)
// ClampingScrollPhysics - توهج بنمط Android عند الحواف (افتراضي على Android)
ListView.builder(
physics: const ClampingScrollPhysics(),
itemCount: 50,
itemBuilder: (context, index) => ListTile(title: Text('عنصر \$index')),
)
// NeverScrollableScrollPhysics - تعطيل التمرير بالكامل
// مفيد لـ ListView متداخلة حيث يتعامل الأب مع التمرير
ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: 5,
itemBuilder: (context, index) => ListTile(title: Text('عنصر \$index')),
)
// AlwaysScrollableScrollPhysics - يسمح بالتمرير حتى لو كان المحتوى يتسع
ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [Text('محتوى قصير لا يزال يتمرر')],
)
BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()) للحصول على تأثير ارتداد iOS مع ضمان أن القائمة قابلة للتمرير دائماً — هذا مفيد لوظيفة السحب للتحديث.
اتجاه التمرير والقوائم الأفقية
بشكل افتراضي، يتمرر ListView عمودياً. عيّن scrollDirection: Axis.horizontal لإنشاء قوائم تمرير أفقية، تُستخدم عادة للعروض الدوارة ومحددات الفئات ومعارض الصور.
عرض دوار أفقي
// عرض صور دوار أفقي
SizedBox(
height: 200.0, // يجب تقييد الارتفاع لـ ListView أفقي
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: imageUrls.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Image.network(
imageUrls[index],
width: 300.0,
fit: BoxFit.cover,
),
),
);
},
),
)
shrinkWrap و itemExtent
خاصيتان مهمتان تؤثران على أداء وسلوك ListView:
shrinkWrap و itemExtent
// shrinkWrap: true - ListView يأخذ المساحة التي يحتاجها فقط
// تحذير: هذا يعطل التحميل الكسول! استخدمه بحذر.
Column(
children: [
const Text('ترويسة'),
ListView.builder(
shrinkWrap: true, // يتقلص لارتفاع المحتوى
physics: const NeverScrollableScrollPhysics(), // تعطيل تمريره الخاص
itemCount: 5,
itemBuilder: (context, index) => ListTile(
title: Text('عنصر \$index'),
),
),
const Text('تذييل'),
],
)
// itemExtent: يفرض ارتفاعاً دقيقاً لكل عنصر (يحسن الأداء)
ListView.builder(
itemExtent: 72.0, // كل عنصر بارتفاع 72 بكسل بالضبط
itemCount: 1000,
itemBuilder: (context, index) => ListTile(
leading: CircleAvatar(child: Text('\${index + 1}')),
title: Text('عنصر بارتفاع ثابت'),
),
)
itemExtent يحسن أداء التمرير بشكل كبير لأن Flutter لا يحتاج لقياس كل عنصر فرعي — فهو يعرف التخطيط الدقيق مسبقاً. استخدمه عندما يكون لجميع العناصر نفس الارتفاع.
مثال عملي: رسائل المحادثة
قائمة رسائل المحادثة
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final ScrollController _scrollController = ScrollController();
final List<ChatMessage> _messages = [];
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(
controller: _scrollController,
reverse: true, // يبدأ من الأسفل مثل تطبيقات المحادثة
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(16.0),
itemCount: _messages.length,
itemBuilder: (context, index) {
final message = _messages[index];
return Align(
alignment: message.isMine
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8.0),
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: message.isMine
? Colors.blue.shade100
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16.0),
),
child: Text(message.text),
),
);
},
),
),
// منطقة إدخال الرسالة ستكون هنا
],
);
}
}
reverse: true على ListView يجعله يبدأ من الأسفل، وهو السلوك الطبيعي لتطبيقات المحادثة. تظهر الرسائل الجديدة في الأسفل ويتمرر المستخدم لأعلى لرؤية الرسائل القديمة.