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

اللمس والإيماءات في Flutter: GestureDetector و InkWell

الدرس 23 من 33 · Flutter

الأزرار تعرف كيف تستجيب للضغط، لكن ماذا عن بطاقة منتج؟ أو صورة تريد أن يعجب بها المستخدم بضغطتين كما في تطبيقات التواصل؟ أو مربع يسحبه بإصبعه على الشاشة؟ في Flutter تستطيع أن تجعل أي Widget يستجيب للمس. في هذا الدرس ستتعلم أداتين: GestureDetector و InkWell.

GestureDetector: كاشف الإيماءات

Section titled “GestureDetector: كاشف الإيماءات”

GestureDetector Widget غير مرئي. تلف به أي عنصر، فيراقب ما يفعله الإصبع فوقه ويخبرك.

تخيّله مثل حارس يقف عند باب: لا يغيّر شكل الباب، لكنه يبلغك بكل من طرقه، وكيف طرقه.

GestureDetector(
onTap: () {
debugPrint('ضغطة واحدة');
},
onDoubleTap: () {
debugPrint('ضغطتان متتاليتان');
},
onLongPress: () {
debugPrint('ضغطة مطوّلة');
},
child: Container(
width: 160,
height: 100,
color: Colors.indigo.shade100,
alignment: Alignment.center,
child: const Text('المسني'),
),
)
الإيماءة متى تحدث استخدام شائع
onTap ضغطة سريعة فتح عنصر
onDoubleTap ضغطتان متتاليتان الإعجاب بصورة، أو تكبيرها
onLongPress ضغطة مطوّلة إظهار قائمة خيارات، أو تحديد عنصر
onPanUpdate تحريك الإصبع في أي اتجاه سحب عنصر
onHorizontalDragEnd نهاية سحب أفقي التنقل بين الصور

InkWell: ضغط مع تأثير الموجة

Section titled “InkWell: ضغط مع تأثير الموجة”

GestureDetector صامت: لا يعطي المستخدم أي إشارة مرئية أنه ضغط. أما InkWell فيرسم موجة تنتشر من مكان الإصبع، وهو التأثير الذي تراه في أزرار Material.

Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () {
debugPrint('فُتح المنتج');
},
child: const Padding(
padding: EdgeInsets.all(24),
child: Text('بطاقة قابلة للضغط'),
),
),
)
  • InkWell يدعم onTap و onDoubleTap و onLongPress مثل GestureDetector.
  • clipBehavior: Clip.antiAlias في Card تقص الموجة على شكل البطاقة بزواياها الدائرية. بدونها تخرج الموجة من الزوايا بشكل مربع.
  • عنصر يشبه الأزرار والبطاقات وعناصر القوائم: استخدم InkWell، لأن المستخدم يتوقع إشارة مرئية.
  • إيماءات خاصة مثل السحب، أو عنصر لا تريد له تأثيراً مرئياً: استخدم GestureDetector.

لتحريك عنصر بالإصبع نتابع حركة الإصبع ونضيفها إلى موضع العنصر:

GestureDetector(
onPanUpdate: (details) {
setState(() {
_position += details.delta;
});
},
child: const Icon(Icons.circle, size: 64, color: Colors.indigo),
)
  • onPanUpdate تُستدعى عشرات المرات في الثانية أثناء تحرك الإصبع.
  • details.delta من نوع Offset، وهو مقدار الحركة منذ آخر استدعاء: كم تحرك الإصبع أفقياً ورأسياً.
  • _position من نوع Offset أيضاً، ونجمع عليه الحركة الجديدة بالعامل +=.

ثم نضع العنصر داخل Stack مع Positioned يقرأ _position.dx و _position.dy، كما سترى في المشروع.

منطقة اللمس الفارغة: behavior

Section titled “منطقة اللمس الفارغة: behavior”

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

GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
debugPrint('الصف كله يستجيب');
},
child: const Row(
children: [
Text('الإعدادات'),
Spacer(),
Icon(Icons.chevron_left),
],
),
)

HitTestBehavior.opaque تجعل مساحة العنصر كلها تستجيب، حتى الفارغ منها.

الإصبع ليس دقيقاً مثل مؤشر الفأرة. أيقونة حجمها 16 بكسل يصعب الضغط عليها، ويخطئ المستخدم فيضغط ما بجانبها. القاعدة في إرشادات التصميم: منطقة اللمس لا تقل عن 48×48، حتى لو كان الشكل الظاهر أصغر. أضف Padding حول العنصر الصغير داخل الكاشف لتكبير منطقة لمسه، أو استخدم IconButton الذي يضمن ذلك تلقائياً.

المشروع: إعجاب بضغطتين ومربع يُسحب

Section titled “المشروع: إعجاب بضغطتين ومربع يُسحب”
lib/gestures_demo.dart
class GesturesDemo extends StatefulWidget {
const GesturesDemo({super.key});
@override
State<GesturesDemo> createState() => _GesturesDemoState();
}
class _GesturesDemoState extends State<GesturesDemo> {
bool _liked = false;
Offset _position = const Offset(40, 220);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('الإيماءات')),
body: Stack(
children: [
Positioned(
top: 24,
left: 24,
right: 24,
child: GestureDetector(
onDoubleTap: () {
setState(() {
_liked = !_liked;
});
},
child: Container(
height: 160,
decoration: BoxDecoration(
color: Colors.indigo.shade100,
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: Icon(
_liked ? Icons.favorite : Icons.favorite_border,
size: 64,
color: _liked ? Colors.red : Colors.indigo,
),
),
),
),
Positioned(
left: _position.dx,
top: _position.dy,
child: GestureDetector(
onPanUpdate: (details) {
setState(() {
_position += details.delta;
});
},
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.amber,
borderRadius: BorderRadius.circular(16),
),
alignment: Alignment.center,
child: const Text('اسحبني'),
),
),
),
],
),
);
}
}

في الشاشة عنصران:

  1. صندوق الإعجاب: اضغط عليه ضغطتين متتاليتين فيمتلئ القلب ويصبح أحمر. اضغط ضغطتين مرة أخرى فيعود فارغاً. ضغطة واحدة لا تفعل شيئاً.
  2. المربع الأصفر: ضع إصبعك عليه وحرّكه، فيتبع إصبعك أينما ذهب. كل حركة صغيرة تضيف delta إلى _position، ثم يعيد setState رسم المربع في مكانه الجديد.

لاحظ أن المربع يستخدم left و top لأن Offset يقيس من الزاوية العلوية اليسرى دائماً، بغض النظر عن اتجاه اللغة.

  1. شغّل المشروع وجرّب الإعجاب والسحب.
  2. أضف onLongPress إلى المربع الأصفر يعيده إلى مكانه الأول.
  3. حوّل صندوق الإعجاب إلى Card بداخله InkWell، وشاهد الموجة عند الضغط.
  4. تحدٍّ: امنع المربع من الخروج عن حدود الشاشة. (تلميح: قبل الحفظ، قيّد dx و dy بالدالة clamp.)
  • GestureDetector بلا child ملموس: كاشف يلف Container بلا لون ولا حجم لا يلتقط شيئاً. أعطِ الابن حجماً، أو استخدم HitTestBehavior.opaque.
  • وضع InkWell تحت Container ملون: الموجة مخفية خلف اللون. استخدم Ink أو Material.
  • نسيان setState داخل onPanUpdate: الموضع يتغير في المتغير، لكن الشاشة لا تُرسم من جديد، فيبدو العنصر ثابتاً.

سؤال

ما الفرق الأساسي بين GestureDetector و InkWell؟

سؤال

ماذا تمثل details.delta داخل onPanUpdate؟

  • GestureDetector يجعل أي Widget يستجيب للضغط والضغط المزدوج والمطوّل والسحب.
  • InkWell يضيف موجة Material، ويحتاج Material فوقه دون لون يغطيه.
  • onPanUpdate مع details.delta لسحب العناصر، داخل Stack و Positioned.
  • HitTestBehavior.opaque تجعل المساحات الفارغة داخل العنصر تستجيب.
  • اجعل منطقة اللمس 48×48 على الأقل.