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

ListView وفيزياء التمرير

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

مقدمة عن ListView

عنصر ListView هو أكثر عناصر التمرير استخداماً في Flutter. يعرض عناصره الفرعية واحداً تلو الآخر في اتجاه التمرير (عمودياً بشكل افتراضي). فهم المنشئات المختلفة وتأثيراتها على الأداء أمر بالغ الأهمية لبناء واجهات تمرير سلسة وفعالة.

مهم: يأخذ ListView كل المساحة المتاحة في المحور العرضي (عرض كامل عند التمرير عمودياً). يمكنه التمرير في اتجاه واحد فقط. للتمرير ثنائي الأبعاد، ستحتاج نهجاً مختلفاً.

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 الافتراضي للقوائم الكبيرة أو المولّدة ديناميكياً. بما أنه يبني جميع العناصر مسبقاً، فإنه يسبب أداءً ضعيفاً واستخداماً عالياً للذاكرة. استخدم 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,
    );
  }
}
تحذير: دائماً تخلص من ScrollController في طريقة 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 يجعله يبدأ من الأسفل، وهو السلوك الطبيعي لتطبيقات المحادثة. تظهر الرسائل الجديدة في الأسفل ويتمرر المستخدم لأعلى لرؤية الرسائل القديمة.