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

MediaQuery وحجم الشاشة

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

ما هو MediaQuery؟

يوفر MediaQuery معلومات حول حجم شاشة الجهاز الحالي واتجاهه وكثافة البكسل وإعدادات إمكانية الوصول والمزيد. إنه أحد أهم الأدوات الأساسية لبناء تطبيقات Flutter متجاوبة تتكيف مع الأجهزة المختلفة.

مفهوم أساسي: MediaQuery هو InheritedWidget يقع بالقرب من أعلى شجرة العناصر (يوضع بواسطة MaterialApp / WidgetsApp). يمكن لأي عنصر في الشجرة الوصول إليه لقراءة مقاييس الجهاز والاستجابة وفقًا لذلك.

MediaQuery.of(context)

الطريقة التقليدية للوصول إلى بيانات استعلام الوسائط هي من خلال MediaQuery.of(context)، والتي تُرجع كائن MediaQueryData يحتوي على جميع المعلومات المتاحة حول الجهاز والعرض.

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

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

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);

    return Column(
      children: [
        Text('عرض الشاشة: \${mediaQuery.size.width}'),
        Text('ارتفاع الشاشة: \${mediaQuery.size.height}'),
        Text('الاتجاه: \${mediaQuery.orientation}'),
        Text('نسبة البكسل: \${mediaQuery.devicePixelRatio}'),
        Text('مقياس النص: \${mediaQuery.textScaler}'),
        Text('السطوع: \${mediaQuery.platformBrightness}'),
      ],
    );
  }
}

خصائص MediaQueryData

يوفر كائن MediaQueryData ثروة من المعلومات. إليك الخصائص الأكثر استخدامًا:

الحجم

تمنحك خاصية size الأبعاد المنطقية للشاشة بوحدات البكسل المستقلة عن الجهاز (dp). هذا ليس عدد البكسلات الفعلية—بل يأخذ في الاعتبار نسبة بكسل الجهاز.

العمل مع حجم الشاشة

Widget build(BuildContext context) {
  final size = MediaQuery.sizeOf(context);
  final width = size.width;
  final height = size.height;

  // عدد أعمدة متجاوب
  int columns;
  if (width < 600) {
    columns = 2;  // هاتف
  } else if (width < 900) {
    columns = 3;  // جهاز لوحي
  } else {
    columns = 4;  // سطح مكتب
  }

  return GridView.builder(
    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: columns,
      crossAxisSpacing: 16,
      mainAxisSpacing: 16,
    ),
    itemCount: 20,
    itemBuilder: (context, index) {
      return Card(
        child: Center(child: Text('عنصر \$index')),
      );
    },
  );
}

الحشو وإطارات العرض

فهم الفرق بين padding وviewPadding وviewInsets ضروري للتعامل مع المناطق الآمنة ولوحة المفاتيح البرمجية:

  • padding — المناطق المحجوبة بواجهة النظام (شريط الحالة، النتوء، الشريط السفلي). تتقلص عند فتح لوحة المفاتيح.
  • viewPadding — نفس المناطق، لكنها لا تتقلص عند فتح لوحة المفاتيح. تمثل العوائق الفعلية.
  • viewInsets — المساحة التي تشغلها لوحة المفاتيح البرمجية وواجهات النظام الأخرى التي تتراكب على التطبيق.

التعامل مع المنطقة الآمنة

Widget build(BuildContext context) {
  final mediaQuery = MediaQuery.of(context);

  return Padding(
    padding: EdgeInsets.only(
      top: mediaQuery.padding.top,       // ارتفاع شريط الحالة
      bottom: mediaQuery.padding.bottom,  // المنطقة الآمنة السفلية
      left: mediaQuery.padding.left,      // منطقة النتوء اليسرى
      right: mediaQuery.padding.right,    // منطقة النتوء اليمنى
    ),
    child: const Scaffold(
      body: Center(
        child: Text('المحتوى آمن داخل كل واجهة النظام'),
      ),
    ),
  );
}

// أو ببساطة استخدم SafeArea التي تفعل نفس الشيء:
SafeArea(
  child: Scaffold(
    body: const Center(
      child: Text('SafeArea تتعامل مع الحشو تلقائيًا'),
    ),
  ),
)

إطارات لوحة المفاتيح

عند فتح لوحة المفاتيح البرمجية، يعكس viewInsets.bottom ارتفاع لوحة المفاتيح. هذا ضروري للنماذج وشاشات الإدخال.

تخطيط واعٍ بلوحة المفاتيح

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

  @override
  Widget build(BuildContext context) {
    final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
    final isKeyboardVisible = bottomInset > 0;

    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            if (!isKeyboardVisible) ...[
              const SizedBox(height: 40),
              const Icon(Icons.lock, size: 80, color: Colors.blue),
              const SizedBox(height: 20),
              const Text(
                'مرحبًا بعودتك',
                style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
              ),
              const SizedBox(height: 40),
            ],
            const Padding(
              padding: EdgeInsets.symmetric(horizontal: 24),
              child: TextField(
                decoration: InputDecoration(
                  labelText: 'البريد الإلكتروني',
                  border: OutlineInputBorder(),
                ),
              ),
            ),
            const SizedBox(height: 16),
            const Padding(
              padding: EdgeInsets.symmetric(horizontal: 24),
              child: TextField(
                obscureText: true,
                decoration: InputDecoration(
                  labelText: 'كلمة المرور',
                  border: OutlineInputBorder(),
                ),
              ),
            ),
            const Spacer(),
            Padding(
              padding: EdgeInsets.only(
                left: 24,
                right: 24,
                bottom: bottomInset + 24,
              ),
              child: SizedBox(
                width: double.infinity,
                child: ElevatedButton(
                  onPressed: () {},
                  child: const Text('تسجيل الدخول'),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

الاتجاه

تُرجع خاصية orientation إما Orientation.portrait أو Orientation.landscape. يمكنك أيضًا استخدام OrientationBuilder لنهج أكثر محلية.

تخطيط قائم على الاتجاه

Widget build(BuildContext context) {
  final orientation = MediaQuery.orientationOf(context);

  return orientation == Orientation.portrait
      ? Column(
          children: [
            _buildHeader(),
            Expanded(child: _buildContent()),
          ],
        )
      : Row(
          children: [
            SizedBox(
              width: 300,
              child: _buildHeader(),
            ),
            Expanded(child: _buildContent()),
          ],
        );
}

نسبة بكسل الجهاز وسطوع المنصة

تخبرك devicePixelRatio بعدد البكسلات الفعلية التي تقابل بكسلًا منطقيًا واحدًا. platformBrightness تشير إلى ما إذا كان النظام في الوضع الفاتح أو الداكن.

نسبة البكسل والسطوع

Widget build(BuildContext context) {
  final mediaQuery = MediaQuery.of(context);
  final pixelRatio = mediaQuery.devicePixelRatio;
  final isDarkMode =
      mediaQuery.platformBrightness == Brightness.dark;

  return Container(
    color: isDarkMode ? Colors.grey[900] : Colors.white,
    child: Column(
      children: [
        Text(
          'نسبة البكسل: \$pixelRatio',
          style: TextStyle(
            color: isDarkMode ? Colors.white : Colors.black,
          ),
        ),
        // استخدام صور بدقة أعلى على شاشات عالية الكثافة
        Image.asset(
          pixelRatio > 2
              ? 'assets/images/logo@3x.png'
              : 'assets/images/logo@2x.png',
        ),
      ],
    ),
  );
}

MediaQuery.sizeOf ومحددات الوصول المُحسّنة الأخرى

قدم Flutter 3.10+ طرقًا أكثر كفاءة لقراءة خصائص MediaQuery محددة. استخدام MediaQuery.sizeOf(context) بدلاً من MediaQuery.of(context).size يعني أن عنصرك يُعاد بناؤه فقط عندما يتغير الحجم، وليس عندما تتغير أي خاصية استعلام وسائط.

محددات وصول MediaQuery المُحسّنة

// بدلاً من هذا (يُعاد البناء عند أي تغيير في استعلام الوسائط):
final size = MediaQuery.of(context).size;

// استخدم هذا (يُعاد البناء فقط عند تغيير الحجم):
final size = MediaQuery.sizeOf(context);

// محددات وصول مُحسّنة أخرى:
final orientation = MediaQuery.orientationOf(context);
final padding = MediaQuery.paddingOf(context);
final viewInsets = MediaQuery.viewInsetsOf(context);
final viewPadding = MediaQuery.viewPaddingOf(context);
final brightness = MediaQuery.platformBrightnessOf(context);
final textScaler = MediaQuery.textScalerOf(context);
final highContrast = MediaQuery.highContrastOf(context);
نصيحة أداء: فضّل دائمًا محددات الوصول المحددة (sizeOf، orientationOf، إلخ) على MediaQuery.of(context) العام. المحدد العام يتسبب في إعادة بناء عنصرك كلما تغيرت أي خاصية استعلام وسائط، مما قد يؤدي إلى إعادة بناء غير ضرورية عند فتح لوحة المفاتيح أو تغيير مقياس النص.

مثال عملي: حشو تكيفي

حشو تكيفي بناءً على عرض الشاشة

class AdaptivePadding extends StatelessWidget {
  final Widget child;

  const AdaptivePadding({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.sizeOf(context).width;

    // تدريج الحشو بناءً على عرض الشاشة
    final horizontalPadding = width < 600
        ? 16.0   // هاتف
        : width < 1200
            ? 32.0  // جهاز لوحي
            : (width - 1200) / 2 + 32; // سطح مكتب: توسيط المحتوى

    return Padding(
      padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
      child: child,
    );
  }
}

مثال عملي: واجهة قائمة على الاتجاه

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

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

  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.sizeOf(context);
    final orientation = MediaQuery.orientationOf(context);
    final isLandscape = orientation == Orientation.landscape;

    return Scaffold(
      appBar: AppBar(title: const Text('الملف الشخصي')),
      body: SafeArea(
        child: isLandscape
            ? Row(
                children: [
                  SizedBox(
                    width: size.width * 0.35,
                    child: _buildAvatar(size: 100),
                  ),
                  Expanded(child: _buildDetails()),
                ],
              )
            : SingleChildScrollView(
                child: Column(
                  children: [
                    const SizedBox(height: 24),
                    _buildAvatar(size: 120),
                    const SizedBox(height: 24),
                    _buildDetails(),
                  ],
                ),
              ),
      ),
    );
  }

  Widget _buildAvatar({required double size}) {
    return Center(
      child: CircleAvatar(
        radius: size / 2,
        backgroundImage:
            const NetworkImage('https://example.com/avatar.jpg'),
      ),
    );
  }

  Widget _buildDetails() {
    return const Padding(
      padding: EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('محمد أحمد',
              style: TextStyle(
                  fontSize: 24, fontWeight: FontWeight.bold)),
          SizedBox(height: 8),
          Text('مطور Flutter',
              style: TextStyle(fontSize: 16, color: Colors.grey)),
          SizedBox(height: 16),
          Text('السيرة: شغوف ببناء تطبيقات جميلة.'),
        ],
      ),
    );
  }
}
تحذير: تجنب استخدام MediaQuery.of(context) داخل دالة build للعناصر العميقة في الشجرة ما لم تكن بحاجة فعلاً لجميع الخصائص. كل استدعاء ينشئ اعتمادية على كائن MediaQueryData بأكمله، مما يسبب إعادة بناء غير ضرورية. استخدم محددات الوصول المحددة أو فكّر في استخدام LayoutBuilder للتحجيم النسبي للأب بدلاً من التحجيم النسبي للشاشة.
ملخص: MediaQuery هو بوابتك لمعلومات الجهاز في Flutter. استخدم sizeOf للتخطيطات المتجاوبة، وviewInsetsOf للتعامل مع لوحة المفاتيح، وpaddingOf للمناطق الآمنة، وorientationOf للتصاميم الواعية بالاتجاه. فضّل دائمًا محددات الوصول المحددة على of(context) العام لأداء أفضل.