Flutter: نظام الحركة

الحركة هي روح واجهة المستخدم — واجهة بدون حركة كعرض شرائح، وواجهة بحركة كفيلم سينمائي.

📋 المتطلبات السابقة: يجب أن تكون ملمًا بـ

1. ما ستتعلمه


2. قصة حقيقية عن واجهة متصلبة

(1) المشكلة: تغييرات الحالة تبدو كعرض شرائح

صفحة تفاصيل المنتج في ShopApp لبوب تقفز فوريًا عند الضغط على "أضف للسلة" — الكمية تقفز من 0 إلى 1، والسعر يقفز من $0 إلى $99.99، وشارة السلة تظهر فجأة "3". يعتقد المستخدمون أن التطبيق "تجمّد" أو "فيه خطأ" — بينما في الواقع، ينقصه فقط حركات انتقالية. 35% من المستخدمين في النسخة بدون حركات أبلغوا عن "تأخر UI".

(2) حل الحركة الضمنية

ويدجتات الحركة الضمنية في Flutter تحتاج فقط قيمة هدف — المنصة تولّد تلقائيًا حركة الانتقال.

DART
import 'package:flutter/material.dart';

// Before: instant change (looks like a bug)
Text('\$$_total')

// After: smooth animation
AnimatedSwitcher(
  duration: const Duration(milliseconds: 300),
  child: Text('\$$_total', key: ValueKey(_total)),
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(3) الفائدة: تحسين السلاسة المُدركة

بعد إضافة بوب للحركات، انخفضت بلاغات "تأخر UI" من 35% إلى 2%, وزادت مدة جلسة المستخدم بنسبة 20%.


3. نظرة عامة على نظام الحركة

100%
graph TD
    A[Animation System] --> IA[Implicit Animation]
    A --> EA[Explicit Animation]
    IA --> AC[AnimatedContainer]
    IA --> AO[AnimatedOpacity]
    IA --> AS2[AnimatedSwitcher]
    EA --> ACTRL[AnimationController]
    ACTRL --> TW[Tween]
    ACTRL --> CURVE[CurvedAnimation]
    EA --> AB[AnimatedBuilder]
    EA --> HERO[Hero Transition]
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(1) حركات ضمنية مقابل صريحة

البُعد الحركة الضمنية الحركة الصريحة
التحكم تلقائي (تعيين قيمة هدف) يدوي (مدفوع بالمتحكم)
التعقيد منخفض مرتفع
المرونة محدودة تحكم كامل
التكرار/العكس غير مدعوم مدعوم
حالة الاستخدام انتقالات بسيطة حركات مخصصة معقدة

4. الحركات الضمنية

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: AnimatedContainer توسيع/طي

DART
import 'package:flutter/material.dart';

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

  @override
  State<ExpandableCard> createState() => _ExpandableCardState();
}

class _ExpandableCardState extends State<ExpandableCard> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _expanded = !_expanded),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 300),
        curve: Curves.easeInOutCubic,
        height: _expanded ? 200 : 80,
        padding: EdgeInsets.all(_expanded ? 16 : 8),
        decoration: BoxDecoration(
          color: _expanded ? Colors.blue[50] : Colors.white,
          borderRadius: BorderRadius.circular(_expanded ? 16 : 8),
          boxShadow: [BoxShadow(blurRadius: _expanded ? 12 : 4, color: Colors.black12)],
        ),
        child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
          Text('Pro Laptop', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
          if (_expanded) ...[
            const SizedBox(height: 12),
            const Text('Latest model with M3 chip. 16GB RAM, 512GB SSD.'),
            const SizedBox(height: 8),
            const Text('\$1,299.99', style: TextStyle(fontSize: 20, color: Colors.green)),
          ],
        ]),
      ),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: AnimatedSwitcher تبديل الأرقام

DART
import 'package:flutter/material.dart';

class AnimatedPrice extends StatelessWidget {
  final double price;
  const AnimatedPrice({super.key, required this.price});

  @override
  Widget build(BuildContext context) {
    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 300),
      transitionBuilder: (child, animation) => FadeTransition(opacity: animation, child: child),
      child: Text('\$${price.toStringAsFixed(2)}',
        key: ValueKey(price),
        style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.green),
      ),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: AnimatedCrossFade تبديل العرض

DART
import 'package:flutter/material.dart';

// Assume isFavorite is defined elsewhere
// bool isFavorite = false;

AnimatedCrossFade(
  firstChild: const Icon(Icons.favorite_border, size: 32),
  secondChild: const Icon(Icons.favorite, size: 32, color: Colors.red),
  crossFadeState: isFavorite ? CrossFadeState.showSecond : CrossFadeState.showFirst,
  duration: const Duration(milliseconds: 300),
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. الحركات الصريحة

(1) AnimationController + Tween + Curve

المكون المسؤولية
AnimationController يتحكم في توقيت الحركة واتجاهها وحالتها
Tween<T> يُحدد قيم البداية والنهاية
CurvedAnimation يُطبق منحنيات التسهيل

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: حركة طيران المنتج للسلة

DART
import 'package:flutter/material.dart';

class FlyToCartAnimation extends StatefulWidget {
  final GlobalKey cartKey;
  final Widget child;
  final VoidCallback onComplete;
  const FlyToCartAnimation({super.key, required this.cartKey, required this.child, required this.onComplete});

  @override
  State<FlyToCartAnimation> createState() => _FlyToCartAnimationState();
}

class _FlyToCartAnimationState extends State<FlyToCartAnimation> with TickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(duration: const Duration(milliseconds: 600), vsync: this);
    _animation = CurvedAnimation(parent: _controller, curve: Curves.easeInOutCubic);
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) widget.onComplete();
    });
  }

  void startFly() {
    _controller.forward(from: 0);
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(animation: _animation, builder: (context, child) {
      final cartBox = widget.cartKey.currentContext?.findRenderObject() as RenderBox?;
      if (cartBox == null) return child!;
      final cartPos = cartBox.localToGlobal(Offset.zero);
      return Transform.translate(
        offset: Offset(cartPos.dx * _animation.value, cartPos.dy * _animation.value),
        child: Transform.scale(scale: 1 - _animation.value * 0.5, child: child),
      );
    }, child: widget.child);
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

6. حركات انتقال Hero

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: انتقال عنصر مشترك لصورة المنتج

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - product: see Lesson 11 json_serializable model

// In product list
Hero(
  tag: 'product_${product.id}',
  child: ClipRRect(
    borderRadius: BorderRadius.circular(12),
    child: Image.network(product.imageUrl, fit: BoxFit.cover),
  ),
)

// In product detail page
Hero(
  tag: 'product_${product.id}',
  child: Image.network(product.imageUrl, fit: BoxFit.cover),
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: PageRouteBuilder انتقال مخصص

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - DetailPage/product: see Lesson 11 / Lesson 14

class SlideUpRoute extends PageRouteBuilder {
  final Widget page;
  SlideUpRoute(this.page) : super(
    pageBuilder: (context, animation, secondaryAnimation) => page,
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      return SlideTransition(
        position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
            .animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
        child: child,
      );
    },
    transitionDuration: const Duration(milliseconds: 400),
  );
}

// Usage
Navigator.push(context, SlideUpRoute(DetailPage(product: product)));
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

7. منحنيات التسهيل Curves

المنحنى التأثير حالة الاستخدام
Curves.linear سرعة ثابتة نادر الاستخدام
Curves.easeIn بطيء ← سريع خروج
Curves.easeOut سريع ← بطيء دخول
Curves.easeInOut بطيء ← سريع ← بطيء عام
Curves.easeOutCubic وصول سريع تأثير مرن
Curves.elasticOut ارتداد مرن حركات مرحة
Curves.bounceOut ارتداد إحساس بالألعاب

8. مثال كامل: مجموعة حركات بطاقة منتج ShopApp

DART
import 'package:flutter/material.dart';

// Custom class definition source:
// - Product: see Lesson 11 json_serializable model

class AnimatedProductCard extends StatefulWidget {
  final Product product;
  final VoidCallback onAddToCart;
  const AnimatedProductCard({super.key, required this.product, required this.onAddToCart});

  @override
  State<AnimatedProductCard> createState() => _AnimatedProductCardState();
}

class _AnimatedProductCardState extends State<AnimatedProductCard> with SingleTickerProviderStateMixin {
  bool _isFavorite = false;
  bool _isAdded = false;
  late AnimationController _scaleController;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _scaleController = AnimationController(duration: const Duration(milliseconds: 200), vsync: this);
    _scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
      CurvedAnimation(parent: _scaleController, curve: Curves.easeInOut));
  }

  void _onTap() {
    _scaleController.forward().then((_) => _scaleController.reverse());
  }

  void _addToCart() {
    setState(() => _isAdded = true);
    widget.onAddToCart();
    Future.delayed(const Duration(seconds: 2), () => setState(() => _isAdded = false));
  }

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapDown: (_) => _scaleController.forward(),
      onTapUp: (_) => _scaleController.reverse(),
      onTapCancel: () => _scaleController.reverse(),
      child: ScaleTransition(
        scale: _scaleAnimation,
        child: Card(
          clipBehavior: Clip.antiAlias,
          child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
            Expanded(flex: 3, child: Stack(children: [
              Hero(tag: 'product_${widget.product.id}',
                child: Image.network(widget.product.imageUrl, fit: BoxFit.cover, width: double.infinity)),
              Positioned(top: 8, right: 8,
                child: AnimatedSwitcher(duration: const Duration(milliseconds: 300),
                  child: IconButton(key: ValueKey(_isFavorite),
                    icon: Icon(_isFavorite ? Icons.favorite : Icons.favorite_border,
                      color: _isFavorite ? Colors.red : Colors.white),
                    onPressed: () => setState(() => _isFavorite = !_isFavorite),
                    style: IconButton.styleFactory(backgroundColor: Colors.white70)))),
            ]),
            Expanded(flex: 2, child: Padding(padding: const EdgeInsets.all(8), child: Column(
              crossAxisAlignment: CrossAxisAlignment.start, children: [
                Text(widget.product.name, maxLines: 1, overflow: TextOverflow.ellipsis),
                const Spacer(),
                Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  Text('\$${widget.product.price.toStringAsFixed(2)}', style: const TextStyle(color: Colors.green)),
                  AnimatedSwitcher(duration: const Duration(milliseconds: 300),
                    child: _isAdded ? const Icon(Icons.check_circle, color: Colors.green, key: ValueKey('added'))
                        : IconButton(icon: const Icon(Icons.add_shopping_cart, size: 20),
                            onPressed: _addToCart, key: const ValueKey('add'))),
                ]),
              ])))),
          ]),
        ),
      ),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

❓ أسئلة شائعة

س كيف أختار بين الحركات الضمنية والصريحة؟
ج استخدم الضمنية عندما يكون ممكنًا (أبسط وأأمن)؛ استخدم الصريحة عندما تحتاج تكرارًا أو عكسًا أو تحكمًا دقيقًا.
س هل يجب التخلص من AnimationController في dispose()؟
ج نعم. عدم التخلص يُسبب تسرب ذاكرة. استخدم SingleTickerProviderStateMixin لتوفير vsync.
س ما الفرق بين AnimatedBuilder وAnimatedWidget؟
ج AnimatedBuilder يستخدم نمط استدعاء، بينما AnimatedWidget يتطلب تصنيفًا فرعيًا. AnimatedBuilder أكثر مرونة وموصى به.
س هل يجب أن يكون وسم Hero نفسه في كلتا الصفحتين؟
ج نعم. Hero يستخدم الوسم لمطابقة الويدجتات عبر الصفحات — يجب أن يكون الوسم فريدًا عالميًا ومتسقًا.
س كيف أحسّن اهتزاز الحركة؟
ج 1) استخدم AnimatedBuilder لتحديد نطاق إعادة البناء؛ 2) استخدم RepaintBoundary لعزل إعادة الرسم؛ 3) تجنب العمليات الثقيلة في استدعاءات الحركة.
س كيف أنسق حركات متعددة؟
ج استخدم Interval لتنسيق حركات متعددة، أو استخدم addStatusListener في AnimationController لربطها يدويًا.

📖 ملخص


📝 تمارين

  1. أساسي (الصعوبة ⭐): استخدم AnimatedContainer لإنشاء بطاقة قابلة للتوسيع/الطي تبدل الارتفاع ولون الخلفية عند النقر.
  2. متوسط (الصعوبة ⭐⭐): نفّذ حركة انتقال Hero من قائمة المنتجات لصفحة التفاصيل، مع تطير الصورة من موضع البطاقة لصورة بطل صفحة التفاصيل.
  3. متقدم (الصعوبة ⭐⭐⭐): نفّذ حركة "طيران للسلة" كاملة: بعد الضغط على أضف للسلة، تطير صورة المنتج المصغرة من البطاقة لأيقونة سلة AppBar، مع تصغير وتلاشي أثناء الطيران.

← الدرس السابق | الدرس التالي →

Web-Tutorial.com

فريق Web-Tutorial التقني

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

100%