Flutter: القوائم والتمرير

التمرير هو أكثر تفاعل طبيعي على الهاتف — إذا لم تكن قائمتك سلسة، سيتجه المستخدمون نحو زر إلغاء التثبيت.

📋 المتطلبات السابقة: يجب أن تتقن ما يلي أولًا

1. ما ستتعلمه


2. قصة حقيقية عن قائمة متعثرة

(1) المشكلة: كابوس 30 إطارًا لقائمة منتجات بالمليون

تطبيق ShopApp لبوب فيه ملايين سجلات المنتجات. يستخدم ListView(children: allProducts.map(...).toList()) لإنشاء جميع الويدجتات دفعة واحدة — 100 عنصر يُنزل الأداء إلى 30 إطارًا، و1000 عنصر يُسبب خطأ OOM. الأسوأ، يجب على المستخدمين النقر يدويًا على "تحميل المزيد" في الأسفل، مما يُعطي معدل مغادرة 60%.

(2) حل التحميل الكسول + التمرير اللانهائي

ListView.builder يبني فقط العناصر المرئية — حتى مع 10 ملايين سجل، يُصيّر فقط 10-20 عنصرًا مرئيًا على الشاشة. مُدمجًا مع ScrollController لمراقبة موضع التمرير، يُشغّل تلقائيًا تحميل المزيد من البيانات.

DART
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]);
  },
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(3) الفائدة: 60 إطارًا + تحميل سلس

بعد التحول للتحميل الكسول بـ builder + التمرير اللانهائي، تعمل قائمة المنتجات بالمليون لبوب بسرعة 60 إطارًا مستقرة، وينخفض معدل المغادرة من 60% إلى 15%.


3. نظام ListView

100%
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]
TEXT
> الإخراج: شغّل محليًا باستخدام 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 مخصص متطلبات خاصة

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: قائمة المنتجات (ListView.builder)

DART
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]);
        },
      ),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: قائمة بخطوط تقسيم (ListView.separated)

DART
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),
    );
  },
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

4. تحسين الأداء

(1) itemExtent وprototypeItem

التحسين التأثير التحسن
itemExtent ارتفاع ثابت للعنصر، تخطي القياس أداء التمرير +30%
prototypeItem قياس عنصر مرجعي مرة واحدة عندما يكون لجميع العناصر ارتفاع موحد
مُنشآت const إعادة استخدام نسخ الويدجت يقلل عبء إعادة البناء
addAutomaticKeepAlives التحكم في سياسة التخزين المؤقت تحسين الذاكرة

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: تحسين itemExtent

DART
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]),
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. التحكم المتقدم بـ ScrollController

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: زر العودة للأعلى

DART
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,
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

6. التمرير المتقدم بـ Sliver

Slivers هي كتل بناء CustomScrollView يمكن دمجها لإنشاء تأثيرات تمرير معقدة.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: CustomScrollView + SliverAppBar

DART
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),
    ),
  ],
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: SliverPersistentHeader رأس فئة لاصق

DART
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;
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

7. مثال كامل: قائمة منتجات ShopApp بتمرير لا نهائي

DART
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'))),
            ),
          ],
        ),
      ),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

❓ أسئلة شائعة

س ما الفرق بين ListView وSingleChildScrollView؟
ج ListView يحمّل كسولًا للقوائم؛ SingleChildScrollView يبني جميع الأبناء دفعة واحدة، مناسب للصفحات الطويلة قليلة المحتوى.
س ListView متداخل داخل ListView يُلقي خطأ؟
ج اضبط ListView الداخلي على shrinkWrap: true وphysics: NeverScrollableScrollPhysics(). لكن هذا يُضر الأداء — فكر في استخدام Slivers بدلًا من ذلك.
س RefreshIndicator لا يعمل؟
ج يجب أن يكون الابن قابلًا للتمرير (ListView/GridView/CustomScrollView)، ويجب أن يتجاوز المحتوى ارتفاع منطقة العرض.
س maxScrollExtent في ScrollController يساوي 0؟
ج maxScrollExtent يكون 0 عندما لا يتجاوز المحتوى منطقة العرض. تأكد من أن القائمة طويلة بما يكفي، أو اقرأها في WidgetsBinding.instance.addPostFrameCallback.
س ما الفرق بين floating وsnap في SliverAppBar؟
ج floating=true يُظهره فوريًا عند التمرير للأسفل؛ snap=true يُوسعه بالكامل تلقائيًا عند الإفلات (يتطلب floating).
س كيف أنفذ شبكة متدرجة (MasonryGrid)؟
ج استخدم حزمة flutter_staggered_grid_view مع MasonryGridView.builder. Flutter القياسي لا يتضمن شبكة متدرجة.

📖 ملخص


📝 تمارين

  1. أساسي (⭐): أنشئ ListView.builder من 100 عنصر، كل عنصر يعرض اسم المنتج والسعر، مع itemExtent ثابت.
  2. متوسط (⭐⭐): نفّذ سحب للتحديث + تحميل تلقائي عند التمرير للأسفل، مع عرض CircularProgressIndicator أثناء التحميل.
  3. متقدم (⭐⭐⭐): استخدم CustomScrollView + SliverAppBar + SliverGrid لبناء صفحة رئيسية: رأس قابل للطي + وسوم فئات + شبكة منتجات + تحميل عند التمرير.

← الدرس السابق | الدرس التالي →

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%