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

النماذج والتحقق في Flutter: Form و TextFormField و validator

الدرس 18 من 33 · Flutter

في درس الأزرار والإدخال بنيت نموذجاً فيه حقل واحد، وفحصته بـ if يدوياً. لكن نموذج التسجيل الحقيقي فيه اسم وبريد وكلمة مرور وتأكيدها، ولكل حقل شروطه ورسالة خطئه. لو فحصت كل ذلك يدوياً لامتلأ الكود بالشروط. في هذا الدرس ستتعلم الطريقة الصحيحة: Form الذي يفحص كل الحقول دفعة واحدة.

Form حاوية غير مرئية تجمع حقول الإدخال التي بداخلها، وتسمح لك بالتعامل معها كلها بأمر واحد.

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

المكونات ثلاثة:

  1. Form: الحاوية.
  2. GlobalKey<FormState>: مفتاح نصل به إلى النموذج من خارجه لنأمره بالفحص.
  3. TextFormField: حقل مثل TextField، لكنه يعرف كيف يتحدث مع Form، وله خاصية validator.
final _formKey = GlobalKey<FormState>();
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'الاسم'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'الاسم مطلوب';
}
return null;
},
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
debugPrint('كل البيانات صحيحة');
}
},
child: const Text('إرسال'),
),
],
),
)

هذه أهم قاعدة في الدرس:

  • validator تستقبل النص المكتوب في الحقل.
  • إذا كان فيه مشكلة، تُرجع نص رسالة الخطأ.
  • إذا كان سليماً، تُرجع null.

عندما تستدعي _formKey.currentState!.validate()، يشغّل النموذج دالة validator لكل حقل، ويعرض رسائل الخطأ بالأحمر تحت الحقول المخالفة، ثم يُرجع true إذا كانت كلها سليمة و false إذا وُجد خطأ واحد على الأقل.

يمكنك كتابة الدالة كدالة مستقلة وإعادة استخدامها:

String? validateEmail(String? value) {
final email = value?.trim() ?? '';
if (email.isEmpty) return 'البريد الإلكتروني مطلوب';
if (!email.contains('@') || !email.contains('.')) {
return 'اكتب بريداً إلكترونياً صحيحاً';
}
return null;
}
String? validatePassword(String? value) {
final password = value ?? '';
if (password.isEmpty) return 'كلمة المرور مطلوبة';
if (password.length < 8) return 'كلمة المرور 8 أحرف على الأقل';
return null;
}

لاحظ ترتيب الشروط: نفحص الفراغ أولاً، ثم الشروط الأدق. بهذا يحصل المستخدم على رسالة تصف مشكلته بالضبط. ثم تربطها بالحقل هكذا: validator: validateEmail، بدون أقواس.

متى يظهر الخطأ؟ autovalidateMode

Section titled “متى يظهر الخطأ؟ autovalidateMode”

افتراضياً لا تظهر الأخطاء إلا عند استدعاء validate(). لكن بعد أول محاولة فاشلة، من اللطيف أن تختفي الرسالة فور تصحيح المستخدم لخطئه. هذا ما تفعله autovalidateMode:

Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: const SizedBox(),
)
القيمة متى يحدث الفحص
AutovalidateMode.disabled عند استدعاء validate() فقط (الافتراضي)
AutovalidateMode.onUserInteraction بعد أن يلمس المستخدم الحقل ويغيّر فيه
AutovalidateMode.always دائماً، حتى قبل أن يكتب المستخدم شيئاً

تجنب always: أن يفتح المستخدم النموذج فيجده كله أحمر تجربة مزعجة.

خصائص تجعل النموذج مريحاً

Section titled “خصائص تجعل النموذج مريحاً”
TextFormField(
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(
labelText: 'البريد الإلكتروني',
prefixIcon: Icon(Icons.email_outlined),
border: OutlineInputBorder(),
),
)
  • keyboardType: نوع لوحة المفاتيح المناسب: emailAddress أو number أو phone.
  • textInputAction: TextInputAction.next: تجعل زر الإدخال في لوحة المفاتيح ينقل المؤشر إلى الحقل التالي. وفي آخر حقل استخدم TextInputAction.done.
  • autofillHints: تسمح للهاتف باقتراح البريد أو كلمة المرور المحفوظة.
  • maxLength: أقصى عدد من الأحرف، ويظهر عدّاد تحت الحقل.

المشروع: نموذج إنشاء حساب

Section titled “المشروع: نموذج إنشاء حساب”
lib/signup_form.dart
class SignupForm extends StatefulWidget {
const SignupForm({super.key});
@override
State<SignupForm> createState() => _SignupFormState();
}
class _SignupFormState extends State<SignupForm> {
final _formKey = GlobalKey<FormState>();
final _passwordController = TextEditingController();
bool _hidePassword = true;
String _result = '';
@override
void dispose() {
_passwordController.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState!.validate()) {
setState(() {
_result = 'تم إنشاء الحساب بنجاح';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('حساب جديد')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
TextFormField(
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'الاسم',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().length < 3) {
return 'الاسم 3 أحرف على الأقل';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: 'البريد الإلكتروني',
border: OutlineInputBorder(),
),
validator: (value) {
final email = value?.trim() ?? '';
if (email.isEmpty) return 'البريد الإلكتروني مطلوب';
if (!email.contains('@') || !email.contains('.')) {
return 'اكتب بريداً إلكترونياً صحيحاً';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _hidePassword,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'كلمة المرور',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(
_hidePassword ? Icons.visibility : Icons.visibility_off,
),
tooltip: 'إظهار أو إخفاء كلمة المرور',
onPressed: () {
setState(() {
_hidePassword = !_hidePassword;
});
},
),
),
validator: (value) {
if (value == null || value.length < 8) {
return 'كلمة المرور 8 أحرف على الأقل';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
obscureText: _hidePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: const InputDecoration(
labelText: 'تأكيد كلمة المرور',
border: OutlineInputBorder(),
),
validator: (value) {
if (value != _passwordController.text) {
return 'كلمتا المرور غير متطابقتين';
}
return null;
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submit,
child: const Text('إنشاء الحساب'),
),
),
const SizedBox(height: 16),
Text(_result, style: const TextStyle(color: Colors.green)),
],
),
),
),
);
}
}

ما الذي يستحق الانتباه:

  • حقل التأكيد يقارن قيمته بنص حقل كلمة المرور عبر _passwordController. لهذا احتجنا متحكماً لحقل كلمة المرور فقط، أما باقي الحقول فلا تحتاج متحكمات.
  • زر العين في suffixIcon يبدّل _hidePassword، فتظهر كلمة المرور أو تختفي في الحقلين معاً.
  • onFieldSubmitted في آخر حقل يستدعي _submit، فيرسل المستخدم النموذج من لوحة المفاتيح مباشرة.
  • المحتوى كله داخل SingleChildScrollView، فلا يحدث Overflow عند ظهور لوحة المفاتيح.

جرّب الضغط على «إنشاء الحساب» والحقول فارغة: تظهر أربع رسائل حمراء. اكتب قيماً صحيحة فتختفي الرسائل واحدة واحدة، ثم تظهر رسالة النجاح.

  1. شغّل نموذج إنشاء الحساب وجرّب كل حالات الخطأ.
  2. أضف حقلاً لرقم الهاتف بـ keyboardType: TextInputType.phone، يشترط 11 رقماً.
  3. انقل دوال التحقق إلى دوال مستقلة خارج الكلاس واربطها بالحقول.
  4. تحدٍّ: أضف CheckboxListTile للموافقة على الشروط، ولا تقبل النموذج إلا إذا كان مفعّلاً، مع رسالة SnackBar.
  • إرجاع نص فارغ بدل null عند النجاح: النص الفارغ يُعتبر خطأ بلا رسالة، فيُرفض النموذج دون سبب ظاهر. أرجع null دائماً عند النجاح.
  • استخدام TextField داخل Form: TextField لا يملك validator ولا يشارك في الفحص. استخدم TextFormField.
  • إنشاء GlobalKey داخل build: يُنشأ مفتاح جديد مع كل إعادة بناء، فيفقد النموذج حالته. عرّفه كمتغير في كلاس الحالة.

سؤال

ماذا يجب أن تُرجع دالة validator عندما تكون قيمة الحقل صحيحة؟

سؤال

كيف تفحص كل حقول النموذج دفعة واحدة؟

  • Form مع GlobalKey<FormState> يجمع الحقول ويفحصها بأمر واحد.
  • validator تُرجع رسالة الخطأ، أو null إذا كانت القيمة سليمة.
  • validate() تعرض الأخطاء تحت الحقول وتُرجع نتيجة الفحص.
  • AutovalidateMode.onUserInteraction يخفي الخطأ فور تصحيحه.
  • textInputAction و keyboardType يجعلان الكتابة أسهل، و SingleChildScrollView يمنع Overflow.