تخطيطات Flutter والتصميم المتجاوب

الشرائح: CustomScrollView

55 دقيقة الدرس 12 من 16

ما هي الشرائح (Slivers)؟

الشرائح هي اللبنات الأساسية منخفضة المستوى للمناطق القابلة للتمرير في Flutter. بينما عناصر الراحة مثل ListView وGridView مبنية فوق الشرائح، فإن العمل مع الشرائح مباشرة عبر CustomScrollView يمنحك القوة لدمج القوائم والشبكات والعناوين والعناصر القابلة للتمرير الأخرى في تجربة تمرير موحدة واحدة.

لماذا الشرائح؟ مع ListView، تحصل على قائمة قابلة للتمرير. مع GridView، تحصل على شبكة قابلة للتمرير. لكن ماذا لو أردت صفحة قابلة للتمرير بها عنوان، ثم قائمة، ثم شبكة، ثم محتوى إضافي؟ هذا بالضبط ما يتيحه CustomScrollView مع الشرائح—مزج تخطيطات قابلة للتمرير مختلفة تحت متحكم تمرير واحد.

CustomScrollView

CustomScrollView هو عرض تمرير ينشئ تأثيرات تمرير مخصصة باستخدام الشرائح. بدلاً من قبول قائمة واحدة من العناصر الفرعية، يقبل قائمة من عناصر الشرائح. كل شريحة تحدد جزءًا من المنطقة القابلة للتمرير.

CustomScrollView الأساسي

CustomScrollView(
  slivers: [
    // شريط تطبيق شريحي
    const SliverAppBar(
      title: Text('صفحتي'),
      floating: true,
    ),

    // قائمة شريحية
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => ListTile(
          title: Text('عنصر \$index'),
        ),
        childCount: 10,
      ),
    ),

    // شبكة شريحية
    SliverGrid(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      delegate: SliverChildBuilderDelegate(
        (context, index) => Card(
          child: Center(child: Text('شبكة \$index')),
        ),
        childCount: 6,
      ),
    ),
  ],
)

SliverList

ينشئ SliverList مصفوفة خطية من العناصر على طول محور التمرير. يستخدم نمط المفوض لبناء العناصر الفرعية بشكل كسول، تمامًا مثل ListView.builder.

SliverList مع المفوض

// مفوض البناء - كسول، فعال للقوائم الكبيرة
SliverList(
  delegate: SliverChildBuilderDelegate(
    (context, index) {
      return Card(
        margin: const EdgeInsets.symmetric(
          horizontal: 16, vertical: 4,
        ),
        child: ListTile(
          leading: CircleAvatar(child: Text('\$index')),
          title: Text('عنصر \$index'),
          subtitle: Text('وصف العنصر \$index'),
        ),
      );
    },
    childCount: 100,
  ),
)

// مفوض قائمة ثابتة - للقوائم الصغيرة المعروفة
SliverList.list(
  children: [
    const ListTile(title: Text('العنصر الأول')),
    const ListTile(title: Text('العنصر الثاني')),
    const ListTile(title: Text('العنصر الثالث')),
  ],
)

SliverGrid

ينشئ SliverGrid ترتيبًا ثنائي الأبعاد للعناصر الفرعية في منطقة قابلة للتمرير. مثل SliverList، يستخدم المفوضين للبناء الكسول.

أمثلة SliverGrid

// عدد أعمدة ثابت
SliverGrid(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,
    mainAxisSpacing: 10,
    crossAxisSpacing: 10,
    childAspectRatio: 1.0,
  ),
  delegate: SliverChildBuilderDelegate(
    (context, index) => Container(
      color: Colors.primaries[index % Colors.primaries.length],
      child: Center(
        child: Text(
          '\$index',
          style: const TextStyle(
            color: Colors.white, fontSize: 20,
          ),
        ),
      ),
    ),
    childCount: 30,
  ),
)

// أقصى امتداد لكل بلاطة
SliverGrid(
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 200,
    mainAxisSpacing: 10,
    crossAxisSpacing: 10,
  ),
  delegate: SliverChildBuilderDelegate(
    (context, index) => Card(
      child: Center(child: Text('بلاطة \$index')),
    ),
    childCount: 20,
  ),
)

SliverToBoxAdapter

يلف SliverToBoxAdapter عنصرًا عاديًا (غير شريحي) حتى يمكن استخدامه داخل CustomScrollView. هذه هي الطريقة لإدراج العناوين واللافتات أو أي عنصر صندوقي بين الشرائح.

استخدام SliverToBoxAdapter

CustomScrollView(
  slivers: [
    const SliverAppBar(
      title: Text('المتجر'),
      expandedHeight: 200,
      flexibleSpace: FlexibleSpaceBar(
        background: Image.network(
          'https://example.com/banner.jpg',
          fit: BoxFit.cover,
        ),
      ),
    ),

    // عنوان القسم
    SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(
          'المنتجات المميزة',
          style: Theme.of(context).textTheme.headlineSmall,
        ),
      ),
    ),

    // شبكة المنتجات
    SliverGrid(
      gridDelegate:
          const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      delegate: SliverChildBuilderDelegate(
        (context, index) => const ProductCard(),
        childCount: 6,
      ),
    ),

    // عنوان قسم آخر
    SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(
          'جميع المنتجات',
          style: Theme.of(context).textTheme.headlineSmall,
        ),
      ),
    ),

    // قائمة المنتجات
    SliverList(
      delegate: SliverChildBuilderDelegate(
        (context, index) => const ProductListTile(),
        childCount: 20,
      ),
    ),
  ],
)
نصيحة: استخدم SliverToBoxAdapter باعتدال للعناصر غير المتكررة مثل العناوين واللافتات. لقوائم العناصر، فضّل دائمًا SliverList أو SliverGrid مع مفوضي البناء للحصول على أداء مثالي.

SliverPadding

يضيف SliverPadding حشوًا حول شريحة، بشكل مشابه لكيفية عمل Padding لعناصر الصندوق. لا يمكنك لف شريحة بعنصر Padding عادي—يجب استخدام SliverPadding.

مثال SliverPadding

CustomScrollView(
  slivers: [
    SliverPadding(
      padding: const EdgeInsets.all(16),
      sliver: SliverGrid(
        gridDelegate:
            const SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: 2,
          crossAxisSpacing: 12,
          mainAxisSpacing: 12,
        ),
        delegate: SliverChildBuilderDelegate(
          (context, index) => Card(
            child: Center(child: Text('عنصر \$index')),
          ),
          childCount: 10,
        ),
      ),
    ),
  ],
)

SliverFillRemaining

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

SliverFillRemaining للتذييل

CustomScrollView(
  slivers: [
    SliverList.list(
      children: [
        const SizedBox(height: 200, child: Text('العنوان')),
        const SizedBox(height: 100, child: Text('المحتوى')),
      ],
    ),

    // هذا يملأ أي مساحة متبقية
    SliverFillRemaining(
      hasScrollBody: false,
      child: Container(
        color: Colors.grey[200],
        child: const Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.end,
            children: [
              Text('محتوى التذييل'),
              SizedBox(height: 24),
              Text('حقوق النشر 2024'),
              SizedBox(height: 16),
            ],
          ),
        ),
      ),
    ),
  ],
)
تحذير: اضبط hasScrollBody: false على SliverFillRemaining عندما لا يكون عنصره الفرعي عنصرًا قابلاً للتمرير. إذا كان hasScrollBody هو true (الافتراضي)، يُتوقع أن يكون الفرعي قابلاً للتمرير مثل ListView. ضبطه بشكل خاطئ يمكن أن يسبب مشاكل في التخطيط.

دمج شرائح متعددة

القوة الحقيقية لـ CustomScrollView تأتي من دمج شرائح متعددة في صفحة واحدة قابلة للتمرير. إليك مثال شامل:

صفحة قابلة للتمرير معقدة

class ShopPage extends StatelessWidget {
  const ShopPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          // شريط تطبيق قابل للطي مع صورة
          SliverAppBar(
            expandedHeight: 250,
            pinned: true,
            flexibleSpace: FlexibleSpaceBar(
              title: const Text('المتجر الإلكتروني'),
              background: Image.network(
                'https://example.com/hero.jpg',
                fit: BoxFit.cover,
              ),
            ),
          ),

          // شريط البحث
          SliverToBoxAdapter(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: SearchBar(
                hintText: 'ابحث عن المنتجات...',
                leading: const Icon(Icons.search),
              ),
            ),
          ),

          // شرائح الفئات
          SliverToBoxAdapter(
            child: SizedBox(
              height: 50,
              child: ListView.builder(
                scrollDirection: Axis.horizontal,
                padding: const EdgeInsets.symmetric(horizontal: 16),
                itemCount: 8,
                itemBuilder: (context, index) => Padding(
                  padding: const EdgeInsets.only(right: 8),
                  child: Chip(label: Text('فئة \$index')),
                ),
              ),
            ),
          ),

          // عنوان قسم المميز
          _buildSectionHeader(context, 'المميزة'),

          // شبكة المميز
          SliverPadding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            sliver: SliverGrid(
              gridDelegate:
                  const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 2,
                crossAxisSpacing: 12,
                mainAxisSpacing: 12,
                childAspectRatio: 0.75,
              ),
              delegate: SliverChildBuilderDelegate(
                (context, index) => _buildProductCard(index),
                childCount: 4,
              ),
            ),
          ),

          // عنوان قسم الأحدث
          _buildSectionHeader(context, 'المُضافة حديثًا'),

          // قائمة المنتجات الأحدث
          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) => _buildProductTile(index),
              childCount: 15,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildSectionHeader(BuildContext context, String title) {
    return SliverToBoxAdapter(
      child: Padding(
        padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
        child: Text(title,
            style: Theme.of(context).textTheme.titleLarge),
      ),
    );
  }

  Widget _buildProductCard(int index) {
    return Card(
      clipBehavior: Clip.antiAlias,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(
            child: Container(
              color: Colors.primaries[index % Colors.primaries.length][100],
              child: const Center(child: Icon(Icons.image, size: 48)),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text('منتج \$index',
                    style: const TextStyle(fontWeight: FontWeight.bold)),
                Text('\\$\${(index + 1) * 19.99}',
                    style: const TextStyle(color: Colors.green)),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildProductTile(int index) {
    return ListTile(
      leading: Container(
        width: 56,
        height: 56,
        color: Colors.grey[200],
        child: const Icon(Icons.shopping_bag),
      ),
      title: Text('منتج \$index'),
      subtitle: Text('\\$\${(index + 1) * 9.99}'),
      trailing: const Icon(Icons.chevron_right),
    );
  }
}

فوائد الأداء

توفر الشرائح مزايا أداء كبيرة مقارنة بتداخل عناصر قابلة للتمرير متعددة:

  • العرض الكسول: فقط العناصر الفرعية المرئية يتم بناؤها وتخطيطها. العناصر خارج الشاشة لا تستهلك أي موارد.
  • متحكم تمرير واحد: جميع الشرائح تتشارك نفس فيزياء التمرير والمتحكم، مما ينشئ تجربة تمرير سلسة وموحدة.
  • لا مشاكل تمرير متداخلة: بدلاً من لف ListView في Column مع shrinkWrap: true (الذي يلغي التحميل الكسول)، تتركب الشرائح بشكل طبيعي.
  • ذاكرة فعالة: مفوضو البناء يتخلصون من العناصر خارج الشاشة، مما يحافظ على استخدام ذاكرة ثابت بغض النظر عن حجم القائمة.
نمط مضاد: لا تستخدم أبدًا shrinkWrap: true على ListView داخل Column للقوائم الكبيرة. هذا يجبر Flutter على قياس جميع العناصر مسبقًا، مما يدمر التحميل الكسول. استخدم CustomScrollView مع الشرائح بدلاً من ذلك.

مثال عملي: تمرير لانهائي كسول

تمرير لانهائي مع الشرائح

class InfiniteScrollPage extends StatefulWidget {
  const InfiniteScrollPage({super.key});

  @override
  State<InfiniteScrollPage> createState() => _InfiniteScrollPageState();
}

class _InfiniteScrollPageState extends State<InfiniteScrollPage> {
  final List<String> _items = List.generate(20, (i) => 'عنصر \$i');
  bool _isLoading = false;

  Future<void> _loadMore() async {
    if (_isLoading) return;
    setState(() => _isLoading = true);

    // محاكاة تأخير الشبكة
    await Future.delayed(const Duration(seconds: 1));

    setState(() {
      final start = _items.length;
      _items.addAll(
        List.generate(20, (i) => 'عنصر \${start + i}'),
      );
      _isLoading = false;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          const SliverAppBar(
            title: Text('تمرير لانهائي'),
            floating: true,
          ),

          SliverList(
            delegate: SliverChildBuilderDelegate(
              (context, index) {
                if (index == _items.length) {
                  // تفعيل تحميل المزيد
                  _loadMore();
                  return const Padding(
                    padding: EdgeInsets.all(16),
                    child: Center(
                      child: CircularProgressIndicator(),
                    ),
                  );
                }
                return ListTile(
                  title: Text(_items[index]),
                  leading: CircleAvatar(
                    child: Text('\$index'),
                  ),
                );
              },
              childCount: _items.length + 1,
            ),
          ),
        ],
      ),
    );
  }
}
ملخص: الشرائح هي أساس كل المحتوى القابل للتمرير في Flutter. استخدم CustomScrollView عندما تحتاج لدمج القوائم والشبكات ومحتوى آخر في تمرير واحد. SliverList وSliverGrid يوفران عرضًا كسولًا فعالًا، وSliverToBoxAdapter يدمج العناصر غير الشريحية، وSliverPadding يضيف تباعدًا، وSliverFillRemaining يتعامل مع المساحة المتبقية في منطقة العرض. فضّل الشرائح على العناصر القابلة للتمرير المتداخلة مع shrinkWrap لأداء أفضل.