الثيمات في Flutter: ThemeData والألوان والوضع الليلي
كتبت حتى الآن Colors.indigo في عشرات الأماكن. ماذا لو قرر العميل غداً أن لون التطبيق أخضر؟ ستبحث في كل الملفات وتغيّرها واحداً واحداً، وستنسى بعضها حتماً. الحل الاحترافي أن تعرّف مظهر التطبيق مرة واحدة في مكان واحد اسمه الثيم (Theme)، وكل الشاشات تقرأ منه. ومن نفس المكان ستضيف الوضع الليلي بسطرين.
ما هو الثيم؟
Section titled “ما هو الثيم؟”الثيم مجموعة قرارات المظهر: الألوان، والخطوط، وشكل الأزرار والحقول والبطاقات. تخيّله مثل دليل الهوية البصرية لشركة: يحدد الألوان والخطوط مرة واحدة، وكل مطبوعات الشركة تلتزم به، فتبدو متناسقة.
تعرّفه في MaterialApp:
MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), ), home: const Scaffold(),)ColorScheme.fromSeed: لوحة ألوان من لون واحد
Section titled “ColorScheme.fromSeed: لوحة ألوان من لون واحد”fromSeed تأخذ لوناً واحداً (البذرة)، وتولّد منه لوحة كاملة من الألوان المتناسقة: لون أساسي، ولون للنص فوقه، وألوان للخلفيات والحدود والأخطاء. وكل الـ Widgets الجاهزة تستخدم هذه اللوحة تلقائياً: الأزرار، والشريط السفلي، والمفاتيح، وأشرطة التمرير.
غيّر Colors.teal إلى Colors.deepOrange وأعد التشغيل: يتغير التطبيق كله.
أهم ألوان اللوحة:
| اللون | استخدامه |
|---|---|
primary |
اللون الأساسي: الأزرار المهمة والعناصر المحددة |
onPrimary |
لون النص والأيقونات فوق primary |
primaryContainer |
نسخة هادئة من الأساسي لخلفيات البطاقات المميزة |
secondary |
لون مساعد للعناصر الأقل أهمية |
surface |
خلفية الشاشات والبطاقات |
onSurface |
لون النص العادي فوق surface |
error |
لون الأخطاء |
القاعدة سهلة: كل لون له رفيق يبدأ بـ on، وهو لون المحتوى الذي يوضع فوقه بتباين جيد. فوق primary اكتب بلون onPrimary، وفوق surface اكتب بلون onSurface.
قراءة الثيم: Theme.of(context)
Section titled “قراءة الثيم: Theme.of(context)”بدلاً من كتابة لون ثابت، اقرأه من الثيم:
Container( padding: const EdgeInsets.all(16), color: Theme.of(context).colorScheme.primaryContainer, child: Text( 'بطاقة مميزة', style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, ), ),)الآن إذا تغيّر لون البذرة، أو انتقل التطبيق إلى الوضع الليلي، تتغير هذه البطاقة تلقائياً دون أن تلمس كودها. ولتقليل التكرار احفظ اللوحة في متغير أول دالة build:
final colors = Theme.of(context).colorScheme;تخصيص شكل العناصر
Section titled “تخصيص شكل العناصر”الثيم لا يحدد الألوان فقط. تستطيع أن تحدد شكل نوع كامل من العناصر في كل التطبيق:
ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), fontFamily: 'Cairo', appBarTheme: const AppBarTheme(centerTitle: true), inputDecorationTheme: const InputDecorationTheme( border: OutlineInputBorder(), filled: true, ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ), cardTheme: const CardThemeData(elevation: 0),)appBarTheme: كل الأشرطة العلوية بعنوان في المنتصف.inputDecorationTheme: كل حقول الإدخال بإطار وخلفية، بدون تكرارborder: OutlineInputBorder()في كل حقل.filledButtonTheme: كل الأزرار الممتلئة بارتفاع 48 وعرض كامل وزوايا 12.cardTheme: كل البطاقات بدون ظل.
تكتب القرار مرة واحدة، فتلتزم به كل الشاشات، الحالية والتي ستكتبها مستقبلاً.
الوضع الليلي
Section titled “الوضع الليلي”MaterialApp يقبل ثيمين: theme للوضع الفاتح و darkTheme للوضع الليلي، وخاصية themeMode تختار بينهما:
MaterialApp( theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal), ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.teal, brightness: Brightness.dark, ), ), themeMode: ThemeMode.system, home: const Scaffold(),)الفرق الوحيد بين الثيمين هو brightness: Brightness.dark، فتولّد fromSeed لوحة داكنة من نفس البذرة.
قيمة themeMode |
النتيجة |
|---|---|
ThemeMode.system |
يتبع إعداد الهاتف (الافتراضي) |
ThemeMode.light |
فاتح دائماً |
ThemeMode.dark |
ليلي دائماً |
مع ThemeMode.system يتحول تطبيقك تلقائياً عندما يفعّل المستخدم الوضع الليلي في هاتفه، وأنت لم تكتب سوى خمسة أسطر.
المشروع: تطبيق بمفتاح للوضع الليلي
Section titled “المشروع: تطبيق بمفتاح للوضع الليلي”لنسمح للمستخدم بتبديل الوضع من داخل التطبيق. المتغير _dark يجب أن يكون في أعلى الشجرة، بجوار MaterialApp، لأن الثيم يؤثر في كل شيء تحته.
import 'package:flutter/material.dart';
void main() { runApp(const ThemedApp());}
class ThemedApp extends StatefulWidget { const ThemedApp({super.key});
@override State<ThemedApp> createState() => _ThemedAppState();}
class _ThemedAppState extends State<ThemedApp> { bool _dark = false;
ThemeData _buildTheme(Brightness brightness) { return ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: Colors.teal, brightness: brightness, ), appBarTheme: const AppBarTheme(centerTitle: true), ); }
@override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, theme: _buildTheme(Brightness.light), darkTheme: _buildTheme(Brightness.dark), themeMode: _dark ? ThemeMode.dark : ThemeMode.light, home: Directionality( textDirection: TextDirection.rtl, child: ThemeDemoScreen( dark: _dark, onChanged: (value) => setState(() => _dark = value), ), ), ); }}
class ThemeDemoScreen extends StatelessWidget { const ThemeDemoScreen({ super.key, required this.dark, required this.onChanged, });
final bool dark; final ValueChanged<bool> onChanged;
@override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme;
return Scaffold( appBar: AppBar(title: const Text('الثيمات')), body: ListView( padding: const EdgeInsets.all(16), children: [ SwitchListTile( title: const Text('الوضع الليلي'), secondary: Icon(dark ? Icons.dark_mode : Icons.light_mode), value: dark, onChanged: onChanged, ), const SizedBox(height: 16), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: colors.primaryContainer, borderRadius: BorderRadius.circular(16), ), child: Text( 'هذه البطاقة تقرأ ألوانها من الثيم، فتتغير مع الوضع.', style: TextStyle(color: colors.onPrimaryContainer), ), ), const SizedBox(height: 16), FilledButton(onPressed: () {}, child: const Text('زر أساسي')), const SizedBox(height: 8), OutlinedButton(onPressed: () {}, child: const Text('زر ثانوي')), ], ), ); }}ثلاث أفكار مهمة في هذا الكود:
- الدالة
_buildThemeتبني الثيمين من نفس الإعدادات، والفرق بينهماbrightnessفقط. بهذا لا تكرر إعدادات الثيم مرتين. - الشاشة
ThemeDemoScreenلا تملك الحالة. تستقبل القيمة الحاليةdark، ودالةonChangedتستدعيها لتطلب التغيير من الأب. هذا النمط اسمه رفع الحالة للأعلى، وستتعلمه بالتفصيل في درس إدارة الحالة. - لا يوجد لون ثابت واحد في الشاشة. كل شيء يُقرأ من
colors، ولذلك يعمل الوضعان من أول مرة.
جرّب بنفسك
Section titled “جرّب بنفسك”- شغّل التطبيق وبدّل المفتاح، وراقب كل العناصر وهي تتحول.
- غيّر لون البذرة إلى
Colors.deepOrangeوأعد التشغيل. - أضف
inputDecorationThemeإلى_buildTheme، ثم ضعTextFieldفي الشاشة وتأكد أنه أخذ الإطار تلقائياً. - تحدٍّ: استبدل المفتاح بثلاثة اختيارات: «فاتح» و «ليلي» و «حسب النظام». (تلميح: اجعل الحالة من نوع
ThemeModeبدلاً منbool.)
أخطاء شائعة
Section titled “أخطاء شائعة”- ألوان ثابتة في الشاشات: تعمل في الوضع الفاتح ثم تظهر نصوص سوداء على خلفية سوداء في الليلي.
- نسيان brightness في darkTheme: تحصل على ثيم «ليلي» ألوانه فاتحة، فلا يتغير شيء.
- وضع متغير الثيم في شاشة داخلية:
setStateهناك لا تعيد بناءMaterialApp. الحالة يجب أن تكون فوقه.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
كيف تقرأ اللون الأساسي للتطبيق من داخل أي Widget؟
Theme.of(context) تُرجع ثيم التطبيق الحالي، و colorScheme لوحة ألوانه، و primary اللون الأساسي فيها.
سؤال
ما قيمة themeMode التي تجعل التطبيق يتبع إعداد الوضع الليلي في الهاتف؟
ThemeMode.system تختار بين theme و darkTheme بحسب إعداد النظام، وهي القيمة الافتراضية.
الخلاصة
Section titled “الخلاصة”- الثيم يعرّف مظهر التطبيق في مكان واحد داخل
MaterialApp. ColorScheme.fromSeedتولّد لوحة ألوان متناسقة من لون واحد.- اقرأ الألوان بـ
Theme.of(context).colorSchemeبدلاً من الألوان الثابتة. - ثيمات العناصر مثل
appBarThemeوinputDecorationThemeتوحّد الشكل في كل الشاشات. darkThemeمعthemeModeيضيفان الوضع الليلي بأسطر قليلة.