Flutter: القوائم والتمرير
التمرير هو أكثر تفاعل طبيعي على الهاتف — إذا لم تكن قائمتك سلسة، سيتجه المستخدمون نحو زر إلغاء التثبيت.
📋 المتطلبات السابقة: يجب أن تتقن ما يلي أولًا
- الدرس 6: Material Design والمكوّنات الشائعة
1. ما ستتعلمه
- ListView.builder / ListView.separated التحميل الكسول وتحسين أداء itemExtent
- GridView.builder / SliverGrid عرض شبكي لبيانات ضخمة
- ScrollController: مراقبة التمرير، العودة للأعلى، السحب لتحميل المزيد
- عائلة Sliver: SliverAppBar، SliverList، SliverPersistentHeader
- قائمة منتجات ShopApp بمستوى المليون + سحب للتحديث + تمرير لا نهائي
2. قصة حقيقية عن قائمة متعثرة
(1) المشكلة: كابوس 30 إطارًا لقائمة منتجات بالمليون
تطبيق ShopApp لبوب فيه ملايين سجلات المنتجات. يستخدم ListView(children: allProducts.map(...).toList()) لإنشاء جميع الويدجتات دفعة واحدة — 100 عنصر يُنزل الأداء إلى 30 إطارًا، و1000 عنصر يُسبب خطأ OOM. الأسوأ، يجب على المستخدمين النقر يدويًا على "تحميل المزيد" في الأسفل، مما يُعطي معدل مغادرة 60%.
(2) حل التحميل الكسول + التمرير اللانهائي
ListView.builder يبني فقط العناصر المرئية — حتى مع 10 ملايين سجل، يُصيّر فقط 10-20 عنصرًا مرئيًا على الشاشة. مُدمجًا مع ScrollController لمراقبة موضع التمرير، يُشغّل تلقائيًا تحميل المزيد من البيانات.
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final String name;
final double price;
const Product({required this.name, required this.price});
}
// Lazy loading: only builds visible items
ListView.builder(
itemCount: products.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == products.length) return const LoadingIndicator();
return ProductTile(product: products[index]);
},
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(3) الفائدة: 60 إطارًا + تحميل سلس
بعد التحول للتحميل الكسول بـ builder + التمرير اللانهائي، تعمل قائمة المنتجات بالمليون لبوب بسرعة 60 إطارًا مستقرة، وينخفض معدل المغادرة من 60% إلى 15%.
3. نظام ListView
graph TD
SV[ScrollView] --> LV[ListView]
SV --> GV2[GridView]
SV --> CS[CustomScrollView]
CS --> SA[SliverAppBar]
CS --> SL[SliverList]
CS --> SG[SliverGrid]
CS --> SPH[SliverPersistentHeader]
LV --> |builder| Lazy[Lazy Loading]
LV --> |controller| LoadMore[Load More]
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(1) طرق بناء ListView
| المُنشئ | itemCount | توقيت البناء | حالة الاستخدام |
|---|---|---|---|
ListView(children:) |
ثابت | بناء الكل دفعة واحدة | عناصر قليلة (<20) |
ListView.builder() |
متغير | تحميل كسول للعناصر المرئية | قوائم بيانات كبيرة |
ListView.separated() |
متغير | تحميل كسول + فواصل | قوائم بخطوط تقسيم |
ListView.custom() |
متغير | SliverChildDelegate مخصص | متطلبات خاصة |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: قائمة المنتجات (ListView.builder)
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final String name;
final double price;
final int id;
const Product({required this.name, required this.price, required this.id});
}
// Simplified API class
class ProductApi {
Future<List<Product>> fetchProducts({int page = 1}) async {
await Future.delayed(const Duration(milliseconds: 500));
return List.generate(20, (i) => Product(
name: 'Product ${(page - 1) * 20 + i + 1}',
price: 9.99 + i * 5,
id: (page - 1) * 20 + i + 1,
));
}
}
final api = ProductApi();
class ProductListPage extends StatefulWidget {
const ProductListPage({super.key});
@override
State<ProductListPage> createState() => _ProductListPageState();
}
class _ProductListPageState extends State<ProductListPage> {
final ScrollController _controller = ScrollController();
List<Product> _products = [];
bool _isLoading = false;
bool _hasMore = true;
int _page = 1;
@override
void initState() {
super.initState();
_loadProducts();
_controller.addListener(_onScroll);
}
void _onScroll() {
if (_controller.position.pixels >= _controller.position.maxScrollExtent - 200) {
_loadProducts(); // Load more when near bottom
}
}
Future<void> _loadProducts() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
final newProducts = await api.fetchProducts(page: _page++);
setState(() {
_products.addAll(newProducts);
_isLoading = false;
_hasMore = newProducts.length >= 20;
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: () async {
_page = 1;
_products.clear();
_hasMore = true;
await _loadProducts();
},
child: ListView.builder(
controller: _controller,
itemCount: _products.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == _products.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return ProductListTile(product: _products[index]);
},
),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: قائمة بخطوط تقسيم (ListView.separated)
import 'package:flutter/material.dart';
// Simplified class definition
class Order {
final int id;
final double total;
final List<String> items;
final String status;
const Order({required this.id, required this.total, required this.items, required this.status});
}
Widget _buildStatusChip(String status) => Chip(label: Text(status));
ListView.separated(
itemCount: orders.length,
separatorBuilder: (context, index) => const Divider(height: 1, indent: 72),
itemBuilder: (context, index) {
final order = orders[index];
return ListTile(
leading: CircleAvatar(child: Text('#${order.id}')),
title: Text('Order #${order.id}'),
subtitle: Text('\$${order.total.toStringAsFixed(2)} • ${order.items.length} items'),
trailing: _buildStatusChip(order.status),
);
},
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
4. تحسين الأداء
(1) itemExtent وprototypeItem
| التحسين | التأثير | التحسن |
|---|---|---|
itemExtent |
ارتفاع ثابت للعنصر، تخطي القياس | أداء التمرير +30% |
prototypeItem |
قياس عنصر مرجعي مرة واحدة | عندما يكون لجميع العناصر ارتفاع موحد |
مُنشآت const |
إعادة استخدام نسخ الويدجت | يقلل عبء إعادة البناء |
addAutomaticKeepAlives |
التحكم في سياسة التخزين المؤقت | تحسين الذاكرة |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تحسين itemExtent
import 'package:flutter/material.dart';
// Fixed-height items: use itemExtent for maximum performance
ListView.builder(
itemExtent: 72, // Each item is exactly 72 pixels tall
itemCount: 10000,
itemBuilder: (context, index) => ListTile(
title: Text('Product ${index + 1}'),
trailing: Text('\$${(9.99 + index * 5).toStringAsFixed(2)}'),
),
)
// Variable-height items: use prototypeItem
ListView.builder(
prototypeItem: const ListTile(title: Text('Prototype'), trailing: Text('\$0.00')),
itemCount: products.length,
itemBuilder: (context, index) => ProductListTile(product: products[index]),
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
5. التحكم المتقدم بـ ScrollController
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: زر العودة للأعلى
import 'package:flutter/material.dart';
class ScrollToTopList extends StatefulWidget {
const ScrollToTopList({super.key});
@override
State<ScrollToTopList> createState() => _ScrollToTopListState();
}
class _ScrollToTopListState extends State<ScrollToTopList> {
final _controller = ScrollController();
bool _showTopButton = false;
@override
void initState() {
super.initState();
_controller.addListener(() {
final show = _controller.offset > 500;
if (show != _showTopButton) setState(() => _showTopButton = show);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
controller: _controller,
itemCount: 5000,
itemBuilder: (_, i) => ListTile(title: Text('Item \$i')),
),
floatingActionButton: _showTopButton
? FloatingActionButton.mini(
onPressed: () => _controller.animateTo(0,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut),
child: const Icon(Icons.arrow_upward),
)
: null,
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
6. التمرير المتقدم بـ Sliver
Slivers هي كتل بناء CustomScrollView يمكن دمجها لإنشاء تأثيرات تمرير معقدة.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: CustomScrollView + SliverAppBar
import 'package:flutter/material.dart';
// Simplified class definitions
class Product {
final String name;
const Product({required this.name});
}
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({super.key, required this.product});
@override Widget build(BuildContext context) => Card(child: Text(product.name));
}
final categories = ['All', 'Electronics', 'Clothing', 'Home'];
final products = List.generate(20, (i) => Product(name: 'Product ${i + 1}'));
const _hasMore = true;
class LoadingIndicator extends StatelessWidget {
const LoadingIndicator({super.key});
@override Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator()));
}
CustomScrollView(
slivers: [
// Collapsible header
SliverAppBar(
expandedHeight: 200,
floating: true,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Flash Sale'),
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [Colors.blue, Colors.purple]),
),
child: const Center(child: Text('UP TO 50% OFF',
style: TextStyle(fontSize: 32, color: Colors.white, fontWeight: FontWeight.bold))),
),
),
),
// Category chips (fixed height)
SliverToBoxAdapter(
child: SizedBox(
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: categories.map((c) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Chip(label: Text(c)),
)).toList(),
),
),
),
// Product grid
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid.extent(
maxCrossAxisExtent: 180,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
children: products.map((p) => ProductCard(product: p)).toList(),
),
),
// Loading indicator at bottom
SliverToBoxAdapter(
child: _hasMore ? const LoadingIndicator() : const SizedBox(height: 40),
),
],
)
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: SliverPersistentHeader رأس فئة لاصق
import 'package:flutter/material.dart';
class StickyCategoryHeader extends SliverPersistentHeaderDelegate {
final String category;
StickyCategoryHeader(this.category);
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
alignment: Alignment.centerLeft,
child: Text(category, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
);
}
@override
double get maxExtent => 40;
@override
double get minExtent => 40;
@override
bool shouldRebuild(covariant StickyCategoryHeader oldDelegate) =>
oldDelegate.category != category;
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
7. مثال كامل: قائمة منتجات ShopApp بتمرير لا نهائي
import 'package:flutter/material.dart';
// Simplified class definitions
class Product {
final int id;
final String name;
final double price;
const Product({required this.id, required this.name, required this.price});
}
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({super.key, required this.product});
@override Widget build(BuildContext context) => Card(child: Padding(
padding: const EdgeInsets.all(8), child: Text('${product.name} \$${product.price}')));
}
class MockProducts {
static List<Product> generate({int count = 20, int offset = 0, String category = 'All'}) {
return List.generate(count, (i) => Product(
id: offset + i + 1,
name: 'Product ${offset + i + 1}',
price: (9.99 + (offset + i) * 5).toDouble(),
));
}
}
class InfiniteProductList extends StatefulWidget {
const InfiniteProductList({super.key});
@override
State<InfiniteProductList> createState() => _InfiniteProductListState();
}
class _InfiniteProductListState extends State<InfiniteProductList> {
final _controller = ScrollController();
final List<Product> _products = [];
bool _isLoading = false;
bool _hasMore = true;
int _page = 0;
String _category = 'All';
@override
void initState() {
super.initState();
_loadMore();
_controller.addListener(() {
if (_controller.position.pixels >= _controller.position.maxScrollExtent - 300 && !_isLoading && _hasMore) {
_loadMore();
}
});
}
Future<void> _loadMore() async {
if (_isLoading) return;
setState(() => _isLoading = true);
await Future.delayed(const Duration(seconds: 1)); // Simulate API
final newItems = MockProducts.generate(count: 20, offset: _page * 20, category: _category);
setState(() {
_products.addAll(newItems);
_page++;
_isLoading = false;
_hasMore = _page < 500; // Max 10,000 items for demo
});
}
Future<void> _refresh() async {
setState(() { _page = 0; _products.clear(); _hasMore = true; });
await _loadMore();
}
@override
void dispose() { _controller.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: RefreshIndicator(
onRefresh: _refresh,
child: CustomScrollView(
controller: _controller,
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: 48, child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: ['All', 'Electronics', 'Clothing', 'Home'].map((c) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(label: Text(c), selected: c == _category,
onSelected: (_) => setState(() { _category = c; _refresh(); })),
)).toList(),
)),
),
SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverGrid.extent(
maxCrossAxisExtent: 180,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
children: _products.map((p) => ProductCard(
key: ValueKey(p.id), product: p,
)).toList(),
),
),
if (_isLoading) const SliverToBoxAdapter(
child: Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator())),
),
if (!_hasMore) const SliverToBoxAdapter(
child: Padding(padding: EdgeInsets.all(16), child: Center(child: Text('No more products'))),
),
],
),
),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
❓ أسئلة شائعة
shrinkWrap: true وphysics: NeverScrollableScrollPhysics(). لكن هذا يُضر الأداء — فكر في استخدام Slivers بدلًا من ذلك.flutter_staggered_grid_view مع MasonryGridView.builder. Flutter القياسي لا يتضمن شبكة متدرجة.📖 ملخص
- ListView.builder يحمّل كسولًا، يبني فقط العناصر المرئية، يدعم بيانات بمستوى المليون
- itemExtent يثبت ارتفاع العنصر، يتخطى القياس، يحسن أداء التمرير بأكثر من 30%
- ScrollController يراقب التمرير، يتحكم بالعودة للأعلى، ويُشغّل تحميل المزيد
- RefreshIndicator يُنفذ السحب للتحديث
- تجميعات CustomScrollView + Sliver تُنشئ تأثيرات تمرير معقدة
📝 تمارين
- أساسي (⭐): أنشئ ListView.builder من 100 عنصر، كل عنصر يعرض اسم المنتج والسعر، مع itemExtent ثابت.
- متوسط (⭐⭐): نفّذ سحب للتحديث + تحميل تلقائي عند التمرير للأسفل، مع عرض CircularProgressIndicator أثناء التحميل.
- متقدم (⭐⭐⭐): استخدم CustomScrollView + SliverAppBar + SliverGrid لبناء صفحة رئيسية: رأس قابل للطي + وسوم فئات + شبكة منتجات + تحميل عند التمرير.