تخطَّ إلى المحتوى

الثيمات في Flutter: ThemeData والألوان والوضع الليلي

الدرس 22 من 33 · Flutter

كتبت حتى الآن Colors.indigo في عشرات الأماكن. ماذا لو قرر العميل غداً أن لون التطبيق أخضر؟ ستبحث في كل الملفات وتغيّرها واحداً واحداً، وستنسى بعضها حتماً. الحل الاحترافي أن تعرّف مظهر التطبيق مرة واحدة في مكان واحد اسمه الثيم (Theme)، وكل الشاشات تقرأ منه. ومن نفس المكان ستضيف الوضع الليلي بسطرين.

الثيم مجموعة قرارات المظهر: الألوان، والخطوط، وشكل الأزرار والحقول والبطاقات. تخيّله مثل دليل الهوية البصرية لشركة: يحدد الألوان والخطوط مرة واحدة، وكل مطبوعات الشركة تلتزم به، فتبدو متناسقة.

تعرّفه في 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.

بدلاً من كتابة لون ثابت، اقرأه من الثيم:

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;

الثيم لا يحدد الألوان فقط. تستطيع أن تحدد شكل نوع كامل من العناصر في كل التطبيق:

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: كل البطاقات بدون ظل.

تكتب القرار مرة واحدة، فتلتزم به كل الشاشات، الحالية والتي ستكتبها مستقبلاً.

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، لأن الثيم يؤثر في كل شيء تحته.

lib/main.dart
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('زر ثانوي')),
],
),
);
}
}

ثلاث أفكار مهمة في هذا الكود:

  1. الدالة _buildTheme تبني الثيمين من نفس الإعدادات، والفرق بينهما brightness فقط. بهذا لا تكرر إعدادات الثيم مرتين.
  2. الشاشة ThemeDemoScreen لا تملك الحالة. تستقبل القيمة الحالية dark، ودالة onChanged تستدعيها لتطلب التغيير من الأب. هذا النمط اسمه رفع الحالة للأعلى، وستتعلمه بالتفصيل في درس إدارة الحالة.
  3. لا يوجد لون ثابت واحد في الشاشة. كل شيء يُقرأ من colors، ولذلك يعمل الوضعان من أول مرة.
  1. شغّل التطبيق وبدّل المفتاح، وراقب كل العناصر وهي تتحول.
  2. غيّر لون البذرة إلى Colors.deepOrange وأعد التشغيل.
  3. أضف inputDecorationTheme إلى _buildTheme، ثم ضع TextField في الشاشة وتأكد أنه أخذ الإطار تلقائياً.
  4. تحدٍّ: استبدل المفتاح بثلاثة اختيارات: «فاتح» و «ليلي» و «حسب النظام». (تلميح: اجعل الحالة من نوع ThemeMode بدلاً من bool.)
  • ألوان ثابتة في الشاشات: تعمل في الوضع الفاتح ثم تظهر نصوص سوداء على خلفية سوداء في الليلي.
  • نسيان brightness في darkTheme: تحصل على ثيم «ليلي» ألوانه فاتحة، فلا يتغير شيء.
  • وضع متغير الثيم في شاشة داخلية: setState هناك لا تعيد بناء MaterialApp. الحالة يجب أن تكون فوقه.

سؤال

كيف تقرأ اللون الأساسي للتطبيق من داخل أي Widget؟

سؤال

ما قيمة themeMode التي تجعل التطبيق يتبع إعداد الوضع الليلي في الهاتف؟

  • الثيم يعرّف مظهر التطبيق في مكان واحد داخل MaterialApp.
  • ColorScheme.fromSeed تولّد لوحة ألوان متناسقة من لون واحد.
  • اقرأ الألوان بـ Theme.of(context).colorScheme بدلاً من الألوان الثابتة.
  • ثيمات العناصر مثل appBarTheme و inputDecorationTheme توحّد الشكل في كل الشاشات.
  • darkTheme مع themeMode يضيفان الوضع الليلي بأسطر قليلة.