الودجات المخصصة والرسم المخصص

تأثيرات التمرير المخصصة باستخدام ScrollController

16 دقيقة الدرس 10 من 12

تأثيرات التمرير المخصصة باستخدام ScrollController

يُعدّ ScrollController في Flutter أداةً قويةً تتيح لك الاستماع إلى إزاحة التمرير الدقيقة لأي ودجت قابل للتمرير في أي لحظة. بقراءة تلك الإزاحة، يمكنك اشتقاق خصائص متحركة — شفافية (opacity)، حجم (scale)، ترجمة (translation)، لون — وبناء تأثيرات تمرير غنية بصرياً باستخدام StatefulWidget وsetState فقط، دون الحاجة إلى slivers أو حزم خارجية.

النمط بسيط: الصق ScrollController بـ ListView أو SingleChildScrollView أو أي Scrollable؛ أضف مستمعاً يستدعي setState؛ ثم داخل build، حوّل الإزاحة الخام إلى قيمة مُعبَّرة تقود خصائص ودجاتك.

ملاحظة: يجب إنشاء ScrollController في initState والتخلص منه في dispose. نسيان استدعاء dispose() يُسرِّب المتحكم ومستمعيه، مما يسبب أخطاء خفية وضغطاً على الذاكرة.

إعداد ScrollController

الربط الأدنى يبدو كالتالي:

class _ScrollDemoState extends State<ScrollDemo> {
  late final ScrollController _scroll;
  double _offset = 0;

  @override
  void initState() {
    super.initState();
    _scroll = ScrollController();
    // أعد بناء شجرة الودجات في كل مرة تتغير موضع التمرير
    _scroll.addListener(() {
      setState(() {
        _offset = _scroll.offset;
      });
    });
  }

  @override
  void dispose() {
    _scroll.dispose(); // تخلص دائماً لتحرير الموارد
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scroll, // الصق المتحكم
      itemCount: 40,
      itemBuilder: (context, index) => ListTile(
        title: Text('العنصر $index  •  الإزاحة: ${_offset.toStringAsFixed(1)}'),
      ),
    );
  }
}

تحويل الإزاحة إلى خصائص ودجات

نادراً ما تكون قيم الإزاحة بالبكسل مفيدةً كما هي. تحتاج إلى تثبيت القيمة وتطبيعها ضمن نطاق [0.0، 1.0] كمعامل تقدم، ثم استيفائها نحو قيمة الخاصية المستهدفة. تعمل lerpDouble من dart:ui أو الحساب البسيط بشكل جيد:

import 'dart:ui' show lerpDouble;

// داخل build()، بعد معرفة _offset:
const double kCollapseDistance = 200.0;

// progress ينتقل من 0.0 (الأعلى) إلى 1.0 (تمرير 200 بكسل أو أكثر)
final double progress = (_offset / kCollapseDistance).clamp(0.0, 1.0);

// الشفافية: يتلاشى الرأس من 1.0 → 0.0 كلما مرر المستخدم للأسفل
final double headerOpacity = lerpDouble(1.0, 0.0, progress)!;

// الحجم: يتقلص الرأس من 1.0 → 0.85
final double headerScale = lerpDouble(1.0, 0.85, progress)!;

// المنظور المتوازي: تنتقل الخلفية للأعلى بنسبة 40٪ من سرعة التمرير
final double parallaxOffset = _offset * 0.4;

return Stack(
  children: [
    // صورة الخلفية مع منظور متوازي
    Positioned(
      top: -parallaxOffset,
      left: 0, right: 0,
      child: Image.asset('assets/hero.jpg', height: 300, fit: BoxFit.cover),
    ),
    // طبقة العنوان المتلاشية
    Positioned(
      top: 0, left: 0, right: 0,
      child: Opacity(
        opacity: headerOpacity,
        child: Transform.scale(
          scale: headerScale,
          child: const _HeroTitle(),
        ),
      ),
    ),
    // المحتوى الفعلي القابل للتمرير
    ListView.builder(
      controller: _scroll,
      itemCount: 30,
      padding: const EdgeInsets.only(top: 260),
      itemBuilder: (context, i) => ListTile(title: Text('العنصر $i')),
    ),
  ],
);

تأثير الرأس المتقلص

يُحوّل الرأس المتقلص من بطل بارز وطويل إلى شريط تطبيقات مضغوط عندما يمرر المستخدم. الفكرة الجوهرية هي أن ارتفاع الرأس وحجم الخط والشفافية كلها دوال لنفس معامل progress:

  • عند progress == 0.0 يكون الرأس بارتفاعه الكامل وشفافيته الكاملة ونصوصه الكبيرة.
  • عند progress == 1.0 ينهار الرأس إلى ارتفاع شريط التطبيقات، ويكون شبه شفاف، وقد ظهر العنوان المضغوط.
  • بين تلك القيم الطرفية، كل خاصية يتم استيفاؤها بسلاسة.
نصيحة: اجعل الرأس المتقلص داخل SizedBox يُحسب ارتفاعه من lerpDouble(expandedHeight, kToolbarHeight, progress). سيعيد Flutter تدفق المحتوى الواقع أسفله بسلاسة في كل استدعاء لـ setState.

تأثير الظهور اللاصق

يعني الظهور اللاصق (يُسمى أحياناً "شريط الإجراءات العائم") أن شريط أدوات ثانوياً أو صف تصفية يُطل منزلقاً من الأعلى فقط بعد تمرير المستخدم فوق حد معين. استخدم Transform.translate مع dy تنتقل من -toolbarHeight إلى 0:

const double kRevealThreshold = 300.0;
const double kRevealHeight   = 56.0;

// 0.0 = مخفي فوق الشاشة، 1.0 = ظاهر بالكامل
final double revealProgress =
    ((_offset - kRevealThreshold) / kRevealHeight).clamp(0.0, 1.0);

final double revealDy = lerpDouble(-kRevealHeight, 0.0, revealProgress)!;

// داخل Stack:
Positioned(
  top: 0, left: 0, right: 0,
  child: Transform.translate(
    offset: Offset(0, revealDy),
    child: Material(
      elevation: 4,
      child: SizedBox(
        height: kRevealHeight,
        child: Row(
          children: const [
            Icon(Icons.filter_list),
            Text('تصفية وترتيب'),
          ],
        ),
      ),
    ),
  ),
),

اعتبارات الأداء

كل حدث تمرير يستدعي setState، مما يُعيد بناء الشجرة الفرعية للودجت. احرص على إبقاء الشجرة الفرعية ضحلة وتجنب العمليات الحسابية المكلفة داخل build:

  • ارفع الودجات الثقيلة إلى منشئات const حتى يتمكن Flutter من تجاوز مقارنتها.
  • فضّل Opacity مع Transform على تحريك خلفية Container، لأن الأول يستخدم طبقة التجميع ويتجنب عمل التخطيط.
  • إذا أصبحت ميزانية إعادة البناء ضيقة، ففكر في استخدام AnimationController مع AnimatedBuilder بدلاً من setState — فهو يُعيد البناء لشجرة AnimatedBuilder الفرعية فقط، لا الودجت بالكامل.
تحذير: لا تقرأ _scroll.offset قبل أن يُربط المتحكم بواجهة التمرير. الوصول إلى offset قبل الربط يطرح StateError. احمِ باستخدام _scroll.hasClients عند الحاجة.

الخلاصة

لديك الآن وصفة كاملة لتأثيرات التمرير المستندة إلى الإزاحة دون الحاجة إلى slivers:

  • أنشئ ScrollController وتخلص منه في initState/dispose.
  • أضف مستمعاً يخزّن _scroll.offset عبر setState.
  • ثبّت الإزاحة وطبّعها إلى قيمة progress في النطاق [0، 1].
  • استخدم lerpDouble لتعيين progress إلى الشفافية أو الحجم أو الترجمة أو أي خاصية أخرى.
  • اجمع التأثيرات باستخدام Opacity وTransform.scale وTransform.translate وPositioned داخل Stack.

تغطي هذه الأساسيات الرؤوس المتقلصة والخلفيات ذات المنظور المتوازي وأشرطة الأدوات اللاصقة — كلها بكود StatefulWidget عادي يمكنك فهمه وتصحيحه بنظرة واحدة.