أساسيات ودجات Flutter

ودجات الإدخال: TextField و Checkbox

50 دقيقة الدرس 9 من 18

ودجة TextField

TextField هي الودجة الأساسية في Flutter لإدخال النص. تسمح للمستخدمين بكتابة النصوص والأرقام وكلمات المرور وبيانات أخرى. TextField قابلة للتخصيص بشكل كبير من خلال خاصية InputDecoration التي تتحكم في المظهر البصري لحقل الإدخال بما في ذلك التسميات والتلميحات والأيقونات والحدود وحالات الخطأ.

TextField أساسي

// حقل نص بسيط
const TextField(
  decoration: InputDecoration(
    labelText: 'اسم المستخدم',
    hintText: 'أدخل اسم المستخدم',
  ),
)

// حقل نص بحدود
const TextField(
  decoration: InputDecoration(
    labelText: 'البريد الإلكتروني',
    hintText: 'you@example.com',
    border: OutlineInputBorder(),
  ),
)

TextEditingController

لقراءة أو تعيين أو الاستماع إلى التغييرات في قيمة TextField، تستخدم TextEditingController. هذا أحد أهم المفاهيم عند العمل مع إدخال النص في Flutter.

استخدام TextEditingController

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

  @override
  State<MyForm> createState() => _MyFormState();
}

class _MyFormState extends State<MyForm> {
  // إنشاء المتحكم
  final _nameController = TextEditingController();

  @override
  void dispose() {
    // تخلص دائماً من المتحكمات لمنع تسرب الذاكرة
    _nameController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          controller: _nameController,
          decoration: const InputDecoration(
            labelText: 'الاسم الكامل',
            border: OutlineInputBorder(),
          ),
        ),
        const SizedBox(height: 16),
        ElevatedButton(
          onPressed: () {
            // قراءة القيمة الحالية
            final name = _nameController.text;
            debugPrint('الاسم: \$name');

            // تعيين قيمة برمجياً
            // _nameController.text = 'قيمة جديدة';

            // مسح الحقل
            // _nameController.clear();
          },
          child: const Text('إرسال'),
        ),
      ],
    );
  }
}
تحذير: تخلص دائماً من TextEditingController في دالة dispose() في فئة State. عدم القيام بذلك يسبب تسرب ذاكرة، لأن المتحكم يستمر في الاستماع للتغييرات حتى بعد إزالة الودجة من الشجرة.

التعمق في InputDecoration

InputDecoration يوفر تخصيصاً واسعاً لمظهر TextField:

خصائص InputDecoration

TextField(
  decoration: InputDecoration(
    // التسميات والتلميحات
    labelText: 'عنوان البريد الإلكتروني',
    hintText: 'you@example.com',
    helperText: 'لن نشارك بريدك الإلكتروني أبداً',

    // البادئة واللاحقة
    prefixIcon: const Icon(Icons.email),
    suffixIcon: IconButton(
      icon: const Icon(Icons.clear),
      onPressed: () {
        // مسح الحقل
      },
    ),

    // أنماط الحدود
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    focusedBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(
        color: Colors.blue,
        width: 2,
      ),
    ),
    enabledBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: BorderSide(
        color: Colors.grey[300]!,
      ),
    ),

    // التعبئة
    filled: true,
    fillColor: Colors.grey[50],

    // حشو المحتوى
    contentPadding: const EdgeInsets.symmetric(
      horizontal: 16,
      vertical: 12,
    ),
  ),
)

حالة الخطأ

عرض أخطاء التحقق باستخدام errorText:

TextField مع معالجة الأخطاء

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

  @override
  State<EmailField> createState() => _EmailFieldState();
}

class _EmailFieldState extends State<EmailField> {
  final _controller = TextEditingController();
  String? _errorText;

  void _validate() {
    setState(() {
      final text = _controller.text;
      if (text.isEmpty) {
        _errorText = 'البريد الإلكتروني مطلوب';
      } else if (!text.contains('@')) {
        _errorText = 'يرجى إدخال بريد إلكتروني صالح';
      } else {
        _errorText = null;
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      decoration: InputDecoration(
        labelText: 'البريد الإلكتروني',
        prefixIcon: const Icon(Icons.email),
        border: const OutlineInputBorder(),
        errorText: _errorText,
        errorBorder: const OutlineInputBorder(
          borderSide: BorderSide(color: Colors.red),
        ),
      ),
      onChanged: (value) => _validate(),
      keyboardType: TextInputType.emailAddress,
    );
  }
}

دوال رد الاتصال في TextField

يوفر TextField عدة خيارات لدوال رد الاتصال للاستجابة لإدخال المستخدم:

دوال رد الاتصال في TextField

TextField(
  // تُستدعى كلما تغير النص
  onChanged: (String value) {
    debugPrint('القيمة الحالية: \$value');
  },

  // تُستدعى عندما يضغط المستخدم زر الإجراء (Enter/Done)
  onSubmitted: (String value) {
    debugPrint('تم الإرسال: \$value');
  },

  // تُستدعى عندما يكتسب/يفقد الحقل التركيز
  onTap: () {
    debugPrint('تم الضغط على الحقل');
  },

  // تُستدعى عند اكتمال التحرير (يفقد الحقل التركيز)
  onEditingComplete: () {
    debugPrint('اكتمل التحرير');
  },

  decoration: const InputDecoration(
    labelText: 'بحث',
    border: OutlineInputBorder(),
  ),

  // نوع زر إجراء لوحة المفاتيح
  textInputAction: TextInputAction.search,
)

حقل كلمة المرور مع تبديل الرؤية

خاصية obscureText تخفي أحرف الإدخال، تُستخدم عادةً لحقول كلمة المرور:

حقل كلمة المرور

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

  @override
  State<PasswordField> createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<PasswordField> {
  final _controller = TextEditingController();
  bool _obscure = true;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      obscureText: _obscure,
      decoration: InputDecoration(
        labelText: 'كلمة المرور',
        prefixIcon: const Icon(Icons.lock),
        suffixIcon: IconButton(
          icon: Icon(
            _obscure ? Icons.visibility_off : Icons.visibility,
          ),
          onPressed: () {
            setState(() {
              _obscure = !_obscure;
            });
          },
        ),
        border: const OutlineInputBorder(),
      ),
    );
  }
}
نصيحة: وفّر دائماً تبديل رؤية لحقول كلمة المرور. يقدّر المستخدمون القدرة على التحقق مما كتبوه، خاصة على الهاتف حيث الكتابة أكثر عرضة للخطأ.

أنواع لوحة المفاتيح في TextField

اضبط keyboardType لإظهار تخطيط لوحة المفاتيح المناسب:

أنواع لوحة المفاتيح

// لوحة مفاتيح البريد الإلكتروني (مع @ و .com)
TextField(
  keyboardType: TextInputType.emailAddress,
  decoration: const InputDecoration(labelText: 'البريد الإلكتروني'),
)

// لوحة مفاتيح الأرقام
TextField(
  keyboardType: TextInputType.number,
  decoration: const InputDecoration(labelText: 'العمر'),
)

// لوحة مفاتيح الهاتف
TextField(
  keyboardType: TextInputType.phone,
  decoration: const InputDecoration(labelText: 'الهاتف'),
)

// نص متعدد الأسطر
TextField(
  keyboardType: TextInputType.multiline,
  maxLines: 4,
  decoration: const InputDecoration(
    labelText: 'النبذة',
    alignLabelWithHint: true,
    border: OutlineInputBorder(),
  ),
)

// لوحة مفاتيح URL
TextField(
  keyboardType: TextInputType.url,
  decoration: const InputDecoration(labelText: 'الموقع الإلكتروني'),
)

ودجة Checkbox

ودجة Checkbox هي مربع اختيار Material Design يمكن أن يكون محدداً أو غير محدد. يتطلب value و دالة رد اتصال onChanged.

Checkbox أساسي

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

  @override
  State<CheckboxDemo> createState() => _CheckboxDemoState();
}

class _CheckboxDemoState extends State<CheckboxDemo> {
  bool _isChecked = false;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Checkbox(
          value: _isChecked,
          onChanged: (bool? value) {
            setState(() {
              _isChecked = value ?? false;
            });
          },
        ),
        const Text('أوافق على الشروط والأحكام'),
      ],
    );
  }
}

خصائص Checkbox الرئيسية:

  • value -- هل مربع الاختيار محدد (true) أو غير محدد (false) أو في حالة ثلاثية (null).
  • onChanged -- دالة رد الاتصال عند الضغط على مربع الاختيار. اضبطها على null لتعطيل مربع الاختيار.
  • activeColor -- لون مربع الاختيار عندما يكون محدداً.
  • checkColor -- لون أيقونة العلامة داخل مربع الاختيار.
  • tristate -- إذا كان true، يمكن لمربع الاختيار أيضاً أن يكون في حالة null (غير محددة).
  • shape -- شكل مربع الاختيار (مثل مستدير).

CheckboxListTile

CheckboxListTile يجمع بين Checkbox و ListTile لتخطيط أنظف وأكثر قابلية للضغط:

أمثلة CheckboxListTile

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

  @override
  State<TaskList> createState() => _TaskListState();
}

class _TaskListState extends State<TaskList> {
  final Map<String, bool> _tasks = {
    'شراء المقاضي': false,
    'تنظيف المنزل': true,
    'تمشية الكلب': false,
    'قراءة كتاب': true,
  };

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: _tasks.entries.map((entry) {
          return CheckboxListTile(
            title: Text(
              entry.key,
              style: TextStyle(
                decoration: entry.value
                    ? TextDecoration.lineThrough
                    : null,
                color: entry.value ? Colors.grey : null,
              ),
            ),
            value: entry.value,
            onChanged: (bool? value) {
              setState(() {
                _tasks[entry.key] = value ?? false;
              });
            },
            controlAffinity:
                ListTileControlAffinity.leading,
            activeColor: Colors.green,
          );
        }).toList(),
      ),
    );
  }
}
ملاحظة: controlAffinity يحدد مكان ظهور مربع الاختيار: ListTileControlAffinity.leading يضعه في البداية (شائع لقوائم المهام)، بينما ListTileControlAffinity.trailing يضعه في النهاية (شائع للإعدادات). الافتراضي هو platform الذي يختلف حسب نظام التشغيل.

مربع الاختيار ثلاثي الحالة

مربع الاختيار ثلاثي الحالة له ثلاث حالات: محدد (true)، غير محدد (false)، وغير محدد (null). هذا مفيد لسيناريوهات "تحديد الكل":

مثال مربع الاختيار ثلاثي الحالة

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

  @override
  State<SelectAllDemo> createState() => _SelectAllDemoState();
}

class _SelectAllDemoState extends State<SelectAllDemo> {
  final List<bool> _items = [false, true, false];

  bool? get _selectAll {
    if (_items.every((item) => item)) return true;
    if (_items.every((item) => !item)) return false;
    return null; // غير محدد
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CheckboxListTile(
          title: const Text(
            'تحديد الكل',
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
          tristate: true,
          value: _selectAll,
          onChanged: (bool? value) {
            setState(() {
              final newValue = value ?? false;
              for (int i = 0; i < _items.length; i++) {
                _items[i] = newValue;
              }
            });
          },
        ),
        const Divider(),
        ...List.generate(_items.length, (index) {
          return CheckboxListTile(
            title: Text('العنصر \${index + 1}'),
            value: _items[index],
            onChanged: (bool? value) {
              setState(() {
                _items[index] = value ?? false;
              });
            },
            controlAffinity: ListTileControlAffinity.leading,
          );
        }),
      ],
    );
  }
}

مثال عملي: نموذج تسجيل الدخول

نموذج تسجيل دخول كامل

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

  @override
  State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  bool _obscurePassword = true;
  bool _rememberMe = false;

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              const Text(
                'مرحباً بعودتك',
                style: TextStyle(
                  fontSize: 28,
                  fontWeight: FontWeight.bold,
                ),
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 32),
              TextField(
                controller: _emailController,
                keyboardType: TextInputType.emailAddress,
                textInputAction: TextInputAction.next,
                decoration: InputDecoration(
                  labelText: 'البريد الإلكتروني',
                  prefixIcon: const Icon(Icons.email_outlined),
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
              ),
              const SizedBox(height: 16),
              TextField(
                controller: _passwordController,
                obscureText: _obscurePassword,
                textInputAction: TextInputAction.done,
                decoration: InputDecoration(
                  labelText: 'كلمة المرور',
                  prefixIcon: const Icon(Icons.lock_outlined),
                  suffixIcon: IconButton(
                    icon: Icon(
                      _obscurePassword
                          ? Icons.visibility_off
                          : Icons.visibility,
                    ),
                    onPressed: () {
                      setState(() {
                        _obscurePassword = !_obscurePassword;
                      });
                    },
                  ),
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
              ),
              const SizedBox(height: 8),
              CheckboxListTile(
                title: const Text('تذكرني'),
                value: _rememberMe,
                onChanged: (value) {
                  setState(() {
                    _rememberMe = value ?? false;
                  });
                },
                controlAffinity: ListTileControlAffinity.leading,
                contentPadding: EdgeInsets.zero,
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () {
                  debugPrint('البريد: \${_emailController.text}');
                  debugPrint('تذكرني: \$_rememberMe');
                },
                style: ElevatedButton.styleFrom(
                  padding: const EdgeInsets.symmetric(vertical: 16),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
                child: const Text('تسجيل الدخول', style: TextStyle(fontSize: 16)),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

مثال عملي: قائمة المهام

قائمة مهام مع TextField

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

  @override
  State<TodoChecklist> createState() => _TodoChecklistState();
}

class _TodoChecklistState extends State<TodoChecklist> {
  final _controller = TextEditingController();
  final List<Map<String, dynamic>> _todos = [];

  void _addTodo() {
    if (_controller.text.isNotEmpty) {
      setState(() {
        _todos.add({
          'title': _controller.text,
          'done': false,
        });
        _controller.clear();
      });
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('قائمة المهام')),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(
              controller: _controller,
              onSubmitted: (_) => _addTodo(),
              decoration: InputDecoration(
                hintText: 'أضف مهمة جديدة...',
                suffixIcon: IconButton(
                  icon: const Icon(Icons.add_circle),
                  onPressed: _addTodo,
                ),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
              ),
            ),
          ),
          Expanded(
            child: ListView.builder(
              itemCount: _todos.length,
              itemBuilder: (context, index) {
                final todo = _todos[index];
                return CheckboxListTile(
                  title: Text(
                    todo['title'] as String,
                    style: TextStyle(
                      decoration: (todo['done'] as bool)
                          ? TextDecoration.lineThrough
                          : null,
                    ),
                  ),
                  value: todo['done'] as bool,
                  onChanged: (value) {
                    setState(() {
                      _todos[index]['done'] = value;
                    });
                  },
                  controlAffinity:
                      ListTileControlAffinity.leading,
                  secondary: IconButton(
                    icon: const Icon(Icons.delete_outline),
                    onPressed: () {
                      setState(() {
                        _todos.removeAt(index);
                      });
                    },
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

تمرين عملي

ابنِ شاشة نموذج تسجيل بما يلي: (1) أربعة حقول TextField: الاسم الكامل والبريد الإلكتروني ورقم الهاتف وكلمة المرور -- كل منها بـ keyboardType مناسب و InputDecoration مع أيقونات بادئة ونص خطأ تحقق. (2) تبديل رؤية كلمة المرور باستخدام obscureText وأيقونة لاحقة. (3) CheckboxListTile لـ "أوافق على الشروط". (4) زر إرسال يكون معطلاً عندما يكون مربع الاختيار غير محدد. (5) عرض أخطاء التحقق باستخدام errorText عندما يرسل المستخدم بحقول فارغة. التحدي: أضف مؤشر قوة كلمة المرور يتحدث في الوقت الحقيقي أثناء كتابة المستخدم، باستخدام onChanged.