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

LayoutBuilder والتخطيطات التكيفية

50 دقيقة الدرس 11 من 16

ما هو LayoutBuilder؟

بينما يمنحك MediaQuery حجم الشاشة، يمنحك LayoutBuilder المساحة المتاحة من العنصر الأب. هذا فرق جوهري—العنصر داخل شريط جانبي لا يملك كامل عرض الشاشة المتاح. يتيح لك LayoutBuilder بناء عناصر متجاوبة تتكيف مع حجم حاويتها الفعلي، وليس حجم الجهاز.

الفرق الأساسي: MediaQuery.sizeOf(context) يُرجع أبعاد الشاشة. LayoutBuilder يوفر قيود العنصر الأب—المساحة الفعلية التي يجب أن يعمل عنصرك ضمنها. فضّل دائمًا LayoutBuilder عندما قد يُوضع عنصرك في حاويات بأحجام مختلفة.

عنصر LayoutBuilder

يوفر LayoutBuilder دالة بناء مع معاملين: BuildContext وكائن BoxConstraints يمثل القيود من العنصر الأب. تستخدم هذه القيود لتقرر أي تخطيط تعرضه.

استخدام LayoutBuilder الأساسي

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    // constraints.maxWidth  - أقصى عرض متاح
    // constraints.maxHeight - أقصى ارتفاع متاح
    // constraints.minWidth  - أدنى عرض (عادةً 0)
    // constraints.minHeight - أدنى ارتفاع (عادةً 0)

    if (constraints.maxWidth > 600) {
      return _buildWideLayout();
    } else {
      return _buildNarrowLayout();
    }
  },
)

BoxConstraints في LayoutBuilder

كائن BoxConstraints يخبرك بالضبط كم المساحة المتاحة. فهم خصائصه أمر ضروري:

فهم BoxConstraints

LayoutBuilder(
  builder: (context, constraints) {
    debugPrint('أدنى عرض: \${constraints.minWidth}');
    debugPrint('أقصى عرض: \${constraints.maxWidth}');
    debugPrint('أدنى ارتفاع: \${constraints.minHeight}');
    debugPrint('أقصى ارتفاع: \${constraints.maxHeight}');

    // فحوصات قيود مفيدة:
    final isTight = constraints.isTight;       // min == max
    final isUnbounded = constraints.maxHeight == double.infinity;
    final hasLooseWidth = constraints.minWidth == 0;

    return Container(
      width: constraints.maxWidth,
      height: constraints.hasBoundedHeight
          ? constraints.maxHeight
          : 400,
      color: Colors.blue[50],
      child: Center(
        child: Text(
          'المتاح: \${constraints.maxWidth.toStringAsFixed(0)} x '
          '\${constraints.hasBoundedHeight ? constraints.maxHeight.toStringAsFixed(0) : "غير محدود"}',
        ),
      ),
    );
  },
)

أنماط نقاط التوقف

نمط شائع هو تعريف نقاط توقف تحدد أي تخطيط يُعرض. هذا النهج يحافظ على تنظيم الكود واتساقه عبر تطبيقك.

ثوابت نقاط التوقف

abstract class Breakpoints {
  static const double mobile = 600;
  static const double tablet = 900;
  static const double desktop = 1200;
  static const double wideDesktop = 1800;
}

// الاستخدام في LayoutBuilder
LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth < Breakpoints.mobile) {
      return const MobileLayout();
    } else if (constraints.maxWidth < Breakpoints.tablet) {
      return const TabletLayout();
    } else {
      return const DesktopLayout();
    }
  },
)

فئة مساعد متجاوب

بناء مساعد متجاوب قابل لإعادة الاستخدام يُبسّط إنشاء التخطيطات التكيفية في أنحاء تطبيقك:

عنصر ResponsiveBuilder

class ResponsiveBuilder extends StatelessWidget {
  final Widget mobile;
  final Widget? tablet;
  final Widget? desktop;

  const ResponsiveBuilder({
    super.key,
    required this.mobile,
    this.tablet,
    this.desktop,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth >= 1200 && desktop != null) {
          return desktop!;
        }
        if (constraints.maxWidth >= 600 && tablet != null) {
          return tablet!;
        }
        return mobile;
      },
    );
  }

  /// مساعد ثابت للحصول على نوع الجهاز الحالي
  static ScreenType getScreenType(BoxConstraints constraints) {
    if (constraints.maxWidth >= 1200) return ScreenType.desktop;
    if (constraints.maxWidth >= 600) return ScreenType.tablet;
    return ScreenType.mobile;
  }
}

enum ScreenType { mobile, tablet, desktop }

// الاستخدام:
ResponsiveBuilder(
  mobile: const MobileProductList(),
  tablet: const TabletProductGrid(columns: 3),
  desktop: const DesktopProductGrid(columns: 4),
)
نصيحة: باستخدام LayoutBuilder بدلاً من MediaQuery في ResponsiveBuilder، يتكيف العنصر بشكل صحيح حتى عند وضعه داخل شريط جانبي أو حوار أو عرض مقسم—أماكن يكون فيها العرض المتاح أصغر بكثير من عرض الشاشة.

التنقل التكيفي: Rail مقابل Bottom

نمط تكيفي كلاسيكي هو التبديل بين BottomNavigationBar على الهاتف وNavigationRail على الشاشات الأكبر. LayoutBuilder يجعل هذا سهلاً:

التنقل التكيفي

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

  @override
  State<AdaptiveNavigation> createState() => _AdaptiveNavigationState();
}

class _AdaptiveNavigationState extends State<AdaptiveNavigation> {
  int _selectedIndex = 0;

  static const List<NavigationDestination> _destinations = [
    NavigationDestination(icon: Icon(Icons.home), label: 'الرئيسية'),
    NavigationDestination(icon: Icon(Icons.search), label: 'البحث'),
    NavigationDestination(icon: Icon(Icons.person), label: 'الملف الشخصي'),
  ];

  static const List<NavigationRailDestination> _railDestinations = [
    NavigationRailDestination(icon: Icon(Icons.home), label: Text('الرئيسية')),
    NavigationRailDestination(icon: Icon(Icons.search), label: Text('البحث')),
    NavigationRailDestination(icon: Icon(Icons.person), label: Text('الملف الشخصي')),
  ];

  final List<Widget> _pages = const [
    Center(child: Text('الصفحة الرئيسية')),
    Center(child: Text('صفحة البحث')),
    Center(child: Text('صفحة الملف الشخصي')),
  ];

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final useRail = constraints.maxWidth >= 600;

        if (useRail) {
          return Scaffold(
            body: Row(
              children: [
                NavigationRail(
                  selectedIndex: _selectedIndex,
                  onDestinationSelected: (index) {
                    setState(() => _selectedIndex = index);
                  },
                  labelType: NavigationRailLabelType.all,
                  destinations: _railDestinations,
                ),
                const VerticalDivider(thickness: 1, width: 1),
                Expanded(child: _pages[_selectedIndex]),
              ],
            ),
          );
        }

        return Scaffold(
          body: _pages[_selectedIndex],
          bottomNavigationBar: NavigationBar(
            selectedIndex: _selectedIndex,
            onDestinationSelected: (index) {
              setState(() => _selectedIndex = index);
            },
            destinations: _destinations,
          ),
        );
      },
    );
  }
}

مثال عملي: تخطيط Master-Detail

على الشاشات العريضة، اعرض القائمة والتفاصيل جنبًا إلى جنب. على الشاشات الضيقة، تنقل بينهما:

نمط Master-Detail

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

  @override
  State<MasterDetailScreen> createState() => _MasterDetailScreenState();
}

class _MasterDetailScreenState extends State<MasterDetailScreen> {
  int? _selectedItemId;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final isWide = constraints.maxWidth >= 800;

        if (isWide) {
          // تخطيط جنبًا إلى جنب
          return Row(
            children: [
              SizedBox(
                width: 350,
                child: _buildMasterList(),
              ),
              const VerticalDivider(width: 1),
              Expanded(
                child: _selectedItemId != null
                    ? _buildDetail(_selectedItemId!)
                    : const Center(
                        child: Text('اختر عنصرًا'),
                      ),
              ),
            ],
          );
        }

        // تخطيط متراص - استخدم Navigator للتفاصيل
        return _buildMasterList();
      },
    );
  }

  Widget _buildMasterList() {
    return ListView.builder(
      itemCount: 20,
      itemBuilder: (context, index) {
        return ListTile(
          title: Text('عنصر \$index'),
          subtitle: Text('وصف العنصر \$index'),
          selected: _selectedItemId == index,
          onTap: () {
            setState(() => _selectedItemId = index);

            // على الشاشات الضيقة، انتقل لصفحة التفاصيل
            final isWide = MediaQuery.sizeOf(context).width >= 800;
            if (!isWide) {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (_) => Scaffold(
                    appBar: AppBar(title: Text('عنصر \$index')),
                    body: _buildDetail(index),
                  ),
                ),
              );
            }
          },
        );
      },
    );
  }

  Widget _buildDetail(int id) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.article, size: 80, color: Colors.blue[300]),
            const SizedBox(height: 16),
            Text('تفاصيل العنصر \$id',
                style: const TextStyle(fontSize: 24)),
            const SizedBox(height: 8),
            Text('المحتوى الكامل للعنصر \$id يظهر هنا.'),
          ],
        ),
      ),
    );
  }
}

مثال عملي: شبكة متجاوبة

شبكة متجاوبة مع LayoutBuilder

class ResponsiveGrid extends StatelessWidget {
  final List<Widget> children;
  final double spacing;

  const ResponsiveGrid({
    super.key,
    required this.children,
    this.spacing = 16,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final columns = _getColumnCount(constraints.maxWidth);
        final itemWidth =
            (constraints.maxWidth - spacing * (columns - 1)) / columns;

        return Wrap(
          spacing: spacing,
          runSpacing: spacing,
          children: children.map((child) {
            return SizedBox(
              width: itemWidth,
              child: child,
            );
          }).toList(),
        );
      },
    );
  }

  int _getColumnCount(double width) {
    if (width >= 1200) return 4;
    if (width >= 800) return 3;
    if (width >= 500) return 2;
    return 1;
  }
}

مثال عملي: شريط جانبي تكيفي

تخطيط شريط جانبي تكيفي

class AdaptiveSidebar extends StatelessWidget {
  final Widget sidebar;
  final Widget body;

  const AdaptiveSidebar({
    super.key,
    required this.sidebar,
    required this.body,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth >= 1200) {
          // عريض: شريط جانبي موسّع
          return Row(
            children: [
              SizedBox(width: 280, child: sidebar),
              const VerticalDivider(width: 1),
              Expanded(child: body),
            ],
          );
        } else if (constraints.maxWidth >= 800) {
          // متوسط: شريط جانبي مطوي (أيقونات فقط)
          return Row(
            children: [
              SizedBox(width: 72, child: sidebar),
              const VerticalDivider(width: 1),
              Expanded(child: body),
            ],
          );
        } else {
          // ضيق: استخدم Drawer
          return Scaffold(
            drawer: Drawer(child: sidebar),
            body: body,
          );
        }
      },
    );
  }
}
تحذير: لا يمكن لـ LayoutBuilder توفير قيود لا نهائية للبناء. إذا وضعت LayoutBuilder داخل عنصر قابل للتمرير بدون قيود محدودة، سيكون maxHeight هو double.infinity. تحقق دائمًا من constraints.hasBoundedHeight قبل استخدام maxHeight في الحسابات.
ملخص: استخدم LayoutBuilder كلما احتجت لعناصر تتكيف مع مساحتها المتاحة بدلاً من حجم الشاشة. عرّف نقاط التوقف كثوابت للاتساق. ابنِ مساعدات متجاوبة قابلة لإعادة الاستخدام مثل ResponsiveBuilder للحفاظ على عدم تكرار الكود. ادمج LayoutBuilder مع أنماط التنقل التكيفي لإنشاء تطبيقات متجاوبة حقًا.