الحركة في Flutter: AnimatedContainer والحركات الجاهزة و Hero
تطبيقان يؤديان نفس الوظيفة: في الأول تظهر العناصر وتختفي فجأة، وفي الثاني تتحرك بنعومة من حال إلى حال. الثاني يبدو أغلى وأمتع، مع أن الفرق كله بضعة أسطر. الحركة ليست زينة فقط، فهي تشرح للمستخدم ماذا حدث: من أين جاء هذا العنصر وإلى أين ذهب ذاك. في هذا الدرس ستتعلم أسهل طريق للحركة في Flutter: الحركات الجاهزة.
الحركات الضمنية: غيّر القيمة والباقي تلقائي
Section titled “الحركات الضمنية: غيّر القيمة والباقي تلقائي”Flutter فيه مجموعة Widgets تبدأ أسماؤها بكلمة Animated. فكرتها واحدة: تعطيها القيمة الجديدة، وهي تتحرك إليها من القيمة القديمة وحدها.
تخيّل الفرق بين مصعد وانتقال فوري: في الحالتين تصل إلى الطابق الخامس، لكن المصعد يأخذك عبر الطوابق بسلاسة. أنت تضغط الزر فقط، والمصعد يتولى الرحلة.
AnimatedContainer
Section titled “AnimatedContainer”نسخة متحركة من Container. أي خاصية تتغير فيه، مثل الحجم أو اللون أو الزوايا أو المسافات، تنتقل بنعومة:
AnimatedContainer( duration: const Duration(milliseconds: 400), curve: Curves.easeInOut, width: _big ? 220 : 120, height: _big ? 220 : 120, decoration: BoxDecoration( color: _big ? Colors.indigo : Colors.amber, borderRadius: BorderRadius.circular(_big ? 32 : 12), ),)duration: مدة الحركة، وهي إجبارية. 400 جزء من الألف من الثانية أقل من نصف ثانية.curve: منحنى الحركة، وسنشرحه بعد قليل.- الباقي خصائص
Containerالعادية، لكن قيمها تعتمد على المتغير_big.
عندما تستدعي setState وتغيّر _big، لا يقفز الصندوق إلى شكله الجديد، بل يكبر ويتلوّن وتتدور زواياه معاً خلال 400 ملّي ثانية.
منحنيات الحركة: Curves
Section titled “منحنيات الحركة: Curves”المنحنى يحدد توزيع السرعة خلال الحركة. الأشياء في الواقع لا تتحرك بسرعة ثابتة: السيارة تتسارع ثم تتباطأ قبل أن تقف.
| المنحنى | الإحساس |
|---|---|
Curves.linear |
سرعة ثابتة، تبدو آلية |
Curves.easeInOut |
بداية هادئة ثم تسارع ثم نهاية هادئة. الاختيار الآمن |
Curves.easeOut |
بداية سريعة ونهاية هادئة، مناسب لعنصر يدخل الشاشة |
Curves.easeIn |
بداية هادئة ونهاية سريعة، مناسب لعنصر يخرج |
Curves.bounceOut |
يرتد في النهاية مثل كرة |
Curves.elasticOut |
يتجاوز الهدف ثم يرجع مثل زنبرك |
AnimatedOpacity: الظهور والاختفاء
Section titled “AnimatedOpacity: الظهور والاختفاء”AnimatedOpacity( duration: const Duration(milliseconds: 300), opacity: _visible ? 1 : 0, child: const Text('أظهر وأختفي بنعومة'),)opacity رقم من 0 (شفاف تماماً) إلى 1 (ظاهر تماماً). انتبه: العنصر الشفاف ما زال موجوداً ويحتل مكانه ويستجيب للضغط. إذا أردت أن يختفي ويترك مكانه لغيره، فهذا عمل AnimatedSwitcher.
AnimatedSwitcher: تبديل عنصر بآخر
Section titled “AnimatedSwitcher: تبديل عنصر بآخر”عندما يُستبدل Widget بآخر، يجعل AnimatedSwitcher القديم يتلاشى والجديد يظهر:
AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: Text( '$_count', key: ValueKey(_count), style: const TextStyle(fontSize: 48), ),)السطر key: ValueKey(_count) هو سر العمل. AnimatedSwitcher يتحرك فقط إذا رأى أن الابن تغيّر. والنص القديم والنص الجديد كلاهما من نوع Text، فيظنهما نفس العنصر. المفتاح المختلف يخبره أنهما عنصران مختلفان، فيشغّل الحركة.
أفراد العائلة الآخرون
Section titled “أفراد العائلة الآخرون”| الـ Widget | ماذا يحرّك |
|---|---|
AnimatedPadding |
المسافة الداخلية |
AnimatedAlign |
المحاذاة: ينزلق العنصر من زاوية إلى أخرى |
AnimatedPositioned |
الموضع داخل Stack |
AnimatedDefaultTextStyle |
حجم الخط ولونه |
AnimatedScale |
التكبير والتصغير |
AnimatedRotation |
الدوران |
AnimatedCrossFade |
التبديل بين عنصرين مع تغيّر الحجم |
كلها تعمل بنفس القاعدة: duration، ثم القيمة التي تتغير مع setState.
Hero: عنصر يطير بين شاشتين
Section titled “Hero: عنصر يطير بين شاشتين”تضغط على صورة صغيرة في قائمة، فتفتح شاشة التفاصيل، وتطير الصورة من مكانها الصغير إلى مكانها الكبير. هذا التأثير يحتاج فقط أن تلف العنصر في الشاشتين بـ Hero له نفس الوسم:
const Hero( tag: 'product-7', child: Icon(Icons.headphones, size: 48),)وفي شاشة التفاصيل:
const Hero( tag: 'product-7', child: Icon(Icons.headphones, size: 200),)عند التنقل بـ Navigator.push، يبحث Flutter عن Hero في الشاشتين له نفس tag، ويحرّك العنصر بينهما. القاعدة الوحيدة: الوسم يجب أن يكون فريداً داخل كل شاشة. في القوائم استخدم رقم العنصر التعريفي جزءاً من الوسم.
وماذا عن الحركات المعقدة؟
Section titled “وماذا عن الحركات المعقدة؟”الحركات الضمنية تغطي أغلب ما تحتاجه. لكنها تتحرك مرة واحدة من قيمة إلى قيمة. إذا أردت حركة تتكرر بلا توقف، أو تتوقف وتُعكس بأمرك، فأنت تحتاج الحركات الصريحة باستخدام AnimationController. هذا موضوع متقدم، ويكفي الآن أن تعرف اسمه لتبحث عنه عندما تحتاجه. والقاعدة: ابدأ دائماً بالحركة الضمنية، ولا تنتقل إلى الصريحة إلا إذا لم تكفِ.
المشروع: لوحة حركات
Section titled “المشروع: لوحة حركات”class AnimationsDemo extends StatefulWidget { const AnimationsDemo({super.key});
@override State<AnimationsDemo> createState() => _AnimationsDemoState();}
class _AnimationsDemoState extends State<AnimationsDemo> { bool _big = false; bool _visible = true; int _count = 0;
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('الحركة')), body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ GestureDetector( onTap: () => setState(() => _big = !_big), child: AnimatedContainer( duration: const Duration(milliseconds: 400), curve: Curves.easeInOut, width: _big ? 220 : 120, height: _big ? 160 : 120, alignment: Alignment.center, decoration: BoxDecoration( color: _big ? Colors.indigo : Colors.amber, borderRadius: BorderRadius.circular(_big ? 32 : 12), ), child: const Text('اضغطني'), ), ), const SizedBox(height: 24), AnimatedOpacity( duration: const Duration(milliseconds: 300), opacity: _visible ? 1 : 0, child: const Text('نص يظهر ويختفي', style: TextStyle(fontSize: 20)), ), TextButton( onPressed: () => setState(() => _visible = !_visible), child: Text(_visible ? 'إخفاء' : 'إظهار'), ), const SizedBox(height: 24), AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: Text( '$_count', key: ValueKey(_count), style: const TextStyle(fontSize: 48), ), ), FilledButton( onPressed: () => setState(() => _count++), child: const Text('زد العدّاد'), ), ], ), ), ); }}ثلاث تجارب في شاشة واحدة:
- اضغط الصندوق الأصفر: يكبر، ويتحول إلى الأزرق، وتتدور زواياه، كل ذلك معاً.
- اضغط «إخفاء»: يتلاشى النص، ويتغير الزر إلى «إظهار».
- اضغط «زد العدّاد»: يتلاشى الرقم القديم ويظهر الجديد مكانه.
وكل هذا بدون متحكم حركة واحد. فقط setState وقيم تتغير.
جرّب بنفسك
Section titled “جرّب بنفسك”- شغّل اللوحة وجرّب الحركات الثلاث.
- غيّر
curveفي الصندوق إلىCurves.bounceOutثمCurves.elasticOut. - اجعل المدة 3 ثوانٍ وراقب الحركة بالتصوير البطيء، ثم أعدها.
- تحدٍّ: أضف دائرة داخل
Stackتنزلق بين اليمين واليسار بـAnimatedAlignعند الضغط على زر.
أخطاء شائعة
Section titled “أخطاء شائعة”- نسيان key في ابن AnimatedSwitcher: يتغير النص بدون أي حركة، لأن المبدّل يظن أن العنصر لم يتغير.
- مدة طويلة جداً: حركة تستغرق ثانيتين تبدو جميلة أول مرة ومملة في المرة العاشرة.
- وسم Hero مكرر في نفس الشاشة: يتوقف التطبيق بخطأ «multiple heroes that share the same tag».
اختبر نفسك
Section titled “اختبر نفسك”سؤال
كيف تبدأ الحركة في AnimatedContainer؟
الحركات الضمنية تراقب قيمها. عندما تتغير قيمة بعد setState، تنتقل إليها بنعومة خلال duration.
سؤال
لماذا نعطي ابن AnimatedSwitcher مفتاحاً يتغير مع القيمة؟
عنصران من نفس النوع يبدوان للمبدّل عنصراً واحداً. المفتاح المختلف يميّز الجديد عن القديم.
الخلاصة
Section titled “الخلاصة”- الحركات الضمنية تبدأ باسم
Animated: تغيّر القيمة بـsetStateفتتحرك تلقائياً. durationإجبارية، والأفضل بين 200 و 400 ملّي ثانية، وcurveتحدد توزيع السرعة.AnimatedContainerللحجم واللون والشكل، وAnimatedOpacityللشفافية.AnimatedSwitcherلتبديل عنصر بآخر، ويحتاجkeyمختلفاً.Heroبنفسtagفي شاشتين يجعل العنصر يطير بينهما.