فهم الإيماءات

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

التعريفات

لفهم المفاهيم المختلفة في هذه الصفحة، تحتاج إلى فهم بعض المصطلحات المستخدمة:

  • المؤشر: هو كائن مادي يمكنك استخدامه للتفاعل مع تطبيقك. بالنسبة إلى الأجهزة المحمولة، فإن المؤشر الأكثر شيوعًا هو تفاعل إصبعك مع الشاشة التي تعمل باللمس. يمكنك بدلاً من ذلك استخدام قلم شاشة لاستبدال إصبعك. بالنسبة إلى الشاشات الكبيرة، يمكنك استخدام الماوس أو لوحة اللمس للتفاعل بشكل غير مباشر مع الشاشة. يجب أن يكون جهاز الإدخال قادرًا على "الإشارة" إلى إحداثي ليكون مؤشرًا، لذا لا يمكن اعتبار لوحة المفاتيح، على سبيل المثال، مؤشرًا. في Compose، يتم تضمين نوع المؤشر في تغييرات المؤشر باستخدام PointerType.
  • حدث المؤشر: يصف التفاعل منخفض المستوى لمؤشر واحد أو أكثر مع التطبيق في وقت معيّن. سيؤدي أي تفاعل مع المؤشر، مثل وضع إصبع على الشاشة أو سحب الماوس، إلى تشغيل حدث. في Compose، يتم تضمين جميع المعلومات ذات الصلة بهذا الحدث في الصف PointerEvent.
  • الإيماءة: سلسلة من أحداث المؤشر التي يمكن تفسيرها على أنّها إجراء واحد. على سبيل المثال، يمكن اعتبار إيماءة النقر تسلسلاً لحدث لأسفل متبوعًا بحدث لأعلى. هناك إيماءات شائعة يستخدمها العديد من التطبيقات، مثل النقر أو السحب أو التحويل، ولكن يمكنك أيضًا إنشاء إيماءة مخصّصة عند الحاجة.

مستويات مختلفة من التجريد

يوفّر Jetpack Compose مستويات مختلفة من التجريد للتعامل مع الإيماءات. يتمثل المستوى الأعلى في دعم المكونات. تشتمل العناصر القابلة للإنشاء مثل Button تلقائيًا على دعم الإيماءات. لإتاحة استخدام الإيماءات إلى المكوّنات المخصّصة، يمكنك إضافة معدِّلات الإيماءات مثل clickable إلى عناصر عشوائية للإنشاء. أخيرًا، إذا كنت بحاجة إلى إيماءة مخصّصة، يمكنك استخدام مفتاح التعديل pointerInput.

كقاعدة، قم ببناء أعلى مستوى من التجريد الذي يوفر الوظائف التي تحتاجها. بهذه الطريقة، تستفيد من أفضل الممارسات المدرجة في الطبقة. على سبيل المثال، تحتوي السمة Button على معلومات دلالية أكثر من الاسم المستخدَم لتسهيل الاستخدام، مقارنةً بالسمة clickable التي تحتوي على معلومات أكثر من طريقة تنفيذ pointerInput الأولية.

دعم المكونات

يتضمّن العديد من المكوّنات غير التقليدية في Compose نوعًا من التعامل مع الإيماءات الداخلية. على سبيل المثال، تستجيب LazyColumn لإيماءات السحب من خلال تمرير المحتوى، وتعرض Button تمويج عند الضغط عليها، ويحتوي المكوِّن SwipeToDismiss على منطق التمرير السريع لإغلاق أحد العناصر. يعمل هذا النوع من معالجة الإيماءات تلقائيًا.

إلى جانب المعالجة الداخلية للإيماءات، تتطلب العديد من المكوّنات أيضًا من المتصل معالجة الإيماءة. على سبيل المثال، ترصد Button النقرات تلقائيًا وتشغّل حدث نقرة. تمرِّر onClick لامدا إلى Button للتفاعل مع الإيماءة. وبالمثل، يمكنك إضافة onValueChange lambda إلى Slider للتفاعل مع سحب المستخدم لمقبض شريط التمرير.

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

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

لمزيد من المعلومات حول ميزات تسهيل الاستخدام في Compose، يُرجى الاطّلاع على أدوات تسهيل الاستخدام في Compose.

إضافة إيماءات محدّدة إلى عناصر عشوائية قابلة للإنشاء باستخدام أدوات تعديل

يمكنك تطبيق تعديلات الإيماءات على أي عنصر عشوائي قابل للإنشاء كي يستمع العنصر إلى الإيماءات. على سبيل المثال، يمكنك السماح لاستخدام إيماءات النقر العامة Box بجعلها clickable، أو السماح Column بالتعامل مع التمرير العمودي من خلال تطبيق verticalScroll.

هناك العديد من المعدِّلات للتعامل مع أنواع مختلفة من الإيماءات:

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

إضافة إيماءة مخصّصة إلى عناصر عشوائية قابلة للإنشاء باستخدام مفتاح التعديل pointerInput

لا يتم تنفيذ كل إيماءة باستخدام معدِّل الإيماءات غير التقليدي. على سبيل المثال، لا يمكنك استخدام معدِّل للتفاعل مع السحب بعد الضغط مع الاستمرار أو النقر على مفتاح التحكم أو النقر بثلاثة أصابع. بدلاً من ذلك، يمكنك كتابة معالج الإيماءات الخاص بك لتحديد هذه الإيماءات المخصصة. يمكنك إنشاء معالج الإيماءات باستخدام مفتاح التعديل pointerInput، الذي يمنحك إمكانية الوصول إلى أحداث المؤشر الأولية.

ويستمع الرمز البرمجي التالي إلى أحداث المؤشرات الأولية:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

إذا قسّمت هذا المقتطف، فإن المكونات الأساسية هي:

  • مفتاح التعديل pointerInput تمنحها مفتاحًا واحدًا أو أكثر. عندما تتغير قيمة أحد هذه المفاتيح، تتم إعادة تنفيذ محتوى المُعدِّل lambda. يمرِّر النموذج فلترًا اختياريًا إلى العنصر القابل للإنشاء. إذا تغيرت قيمة عامل التصفية، فيجب إعادة تنفيذ معالج حدث المؤشر للتأكد من تسجيل الأحداث الصحيحة.
  • تنشئ awaitPointerEventScope نطاق تسلسلي يمكن استخدامه لانتظار أحداث المؤشر.
  • يعلّق awaitPointerEvent الكوروتين إلى أن يحدث حدث المؤشر التالي.

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

رصد الإيماءات الكاملة

فبدلاً من التعامل مع أحداث المؤشر الأولية، يمكنك الاستماع إلى إيماءات محددة للحدوث والاستجابة بشكل مناسب. توفّر AwaitPointerEventScope طُرقًا للاستماع إلى:

هذه أدوات الرصد ذات المستوى الأعلى، لذا لا يمكنك إضافة أدوات رصد متعدّدة ضمن معدِّل pointerInput واحد. يرصد المقتطف التالي النقرات فقط، وليس السحب:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

داخليًا، تحظر طريقة detectTapGestures الكوروتين ولا يتم الوصول أبدًا إلى أداة الكشف الثانية. إذا كنت بحاجة إلى إضافة أكثر من أداة معالجة إيماءة واحدة إلى عنصر قابل للإنشاء، استخدِم مثيلات أداة تعديل pointerInput منفصلة بدلاً من ذلك:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

التعامل مع الأحداث لكل إيماءة

حسب التعريف، تبدأ الإيماءات بحدث مؤشر الماوس للأسفل. يمكنك استخدام طريقة المساعد awaitEachGesture بدلاً من التكرار الحلقي while(true) الذي يمرّ خلال كل حدث أولي. تؤدي الطريقة awaitEachGesture إلى إعادة تشغيل الكتلة التي تتضمن المحتوى عند رفع جميع المؤشرات، مشيرًا إلى اكتمال الإيماءة:

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

من الناحية العملية، يهمّك استخدام awaitEachGesture دائمًا ما لم تستجيب لأحداث المؤشرات بدون تحديد الإيماءات. مثال على ذلك هو العلامة hoverable، التي لا تستجيب لأحداث المؤشر للأسفل أو للأعلى، بل تحتاج فقط إلى معرفة وقت دخول المؤشر إلى حدوده أو الخروج منها.

انتظار حدث أو إيماءة فرعية معيّنة

هناك مجموعة من الطرق التي تساعد في تحديد الأجزاء الشائعة من الإيماءات:

تطبيق العمليات الحسابية على أحداث اللمس المتعدّد

عندما يُجري المستخدم إيماءة اللمس المتعدد باستخدام أكثر من مؤشر واحد، يكون من الصعب فهم التحويل المطلوب استنادًا إلى القيم الأولية. إذا لم توفِّر طريقة التعديل transformable أو طُرق detectTransformGestures قدرًا كافيًا من التحكّم الدقيق لحالة الاستخدام، يمكنك الاستماع إلى الأحداث الأوّلية وتطبيق العمليات الحسابية عليها. طرق المساعدة هذه هي calculateCentroid وcalculateCentroidSize وcalculatePan وcalculateRotation وcalculateZoom.

إرسال الأحداث واختبار النتائج

لا يتم إرسال كل أحداث المؤشر إلى كل معدِّل pointerInput. يعمل إرسال الأحداث على النحو التالي:

  • يتم إرسال أحداث المؤشر إلى تدرّج هرمي قابل للإنشاء. في اللحظة التي يشغّل فيها المؤشر الجديد حدث المؤشر الأول، يبدأ النظام في اختبار العناصر القابلة للإنشاء "المؤهَّلة". يُعد العنصر القابل للإنشاء مؤهَّلاً عندما يكون مزودًا بإمكانات التعامل مع إدخال المؤشر. يتدفق اختبار النتائج من أعلى شجرة واجهة المستخدم إلى الأسفل. العنصر القابل للإنشاء هو "نتيجة" عندما يقع حدث المؤشر ضمن حدود ذلك العنصر القابل للإنشاء. ينتج عن هذه العملية سلسلة من العناصر القابلة للإنشاء التي تؤدي إلى الاختبار بشكل إيجابي.
  • بشكل تلقائي، عندما تكون هناك عدة عناصر قابلة للإنشاء مؤهلة على نفس مستوى العرض التدرّجي، يكون العنصر القابل للإنشاء ذو مؤشر z الأعلى هو "hit" فقط. على سبيل المثال، عند إضافة عنصرَي Button متداخلين إلى عنصر Box، لن يتلقى سوى العنصر المرسوم في الأعلى أي أحداث مؤشرات. يمكنك تجاوز هذا السلوك نظريًا من خلال إنشاء عملية تنفيذ PointerInputModifierNode الخاصة بك وضبط sharePointerInputWithSiblings على "صحيح".
  • يتم نقل الأحداث الأخرى للمؤشر نفسه إلى سلسلة العناصر القابلة للإنشاء نفسها، وتتدفق وفقًا لمنطق نشر الأحداث. لا يُجري النظام المزيد من اختبارات النتائج لهذا المؤشر. هذا يعني أن كل عنصر قابل للإنشاء في السلسلة يتلقى جميع الأحداث لهذا المؤشر، حتى في حال حدوثها خارج حدود ذلك العنصر. لا تتلقى العناصر القابلة للإنشاء غير الموجودة في السلسلة أحداث المؤشر مطلقًا، حتى عندما يكون المؤشر داخل حدودها.

تُعدّ أحداث التمرير، التي يتم تشغيلها عن طريق تمرير الماوس أو قلم الشاشة، استثناءً للقواعد المحدّدة هنا. يتم إرسال أحداث التمرير إلى أي عنصر قابل للإنشاء يتم النقر عليه. بالتالي، عندما يمرر مستخدم مؤشرًا من حدود أحد العناصر القابلة للإنشاء إلى التالي، يتم إرسال الأحداث إلى العنصر الجديد القابل للإنشاء بدلاً من إرسال الأحداث إلى العنصر الأول القابل للإنشاء.

استهلاك الحدث

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

عنصر قائمة يحتوي على صورة وعمود يحتوي على نصَّين وزر.

عندما ينقر المستخدم على زر الإشارة المرجعية، تتعامل دالة onClick lambda مع هذه الإيماءة. عندما ينقر المستخدم على أي جزء آخر من عنصر القائمة، يعالج ListItem هذه الإيماءة وينتقل إلى المقالة. من حيث إدخال المؤشر، يجب أن يستخدم الزر هذا الحدث، حتى يعرف العامل الرئيسي عدم التفاعل معه بعد الآن. تتضمن الإيماءات المضمّنة في المكونات غير العلبة ومفاتيح تعديل الإيماءات الشائعة سلوك الاستهلاك هذا، ولكن إذا كنت تكتب إيماءة مخصّصة خاصة بك، فيجب عليك استخدام الأحداث يدويًا. يمكنك إجراء ذلك باستخدام طريقة PointerInputChange.consume:

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

لا يؤدي استخدام حدث إلى إيقاف انتشار الحدث في عناصر أخرى قابلة للإنشاء. يجب أن يتجاهل العنصر القابل للإنشاء الأحداث المستهلكة بشكل صريح بدلاً من ذلك. عند كتابة الإيماءات المخصّصة، يجب عليك التحقق مما إذا كان أحد الأحداث قد استهلك فعلاً بواسطة عنصر آخر:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

نشر الأحداث

كما ذكرنا سابقًا، يتم تمرير تغييرات المؤشر إلى كل عنصر قابل للإنشاء يتم الوصول إليه. ولكن في حالة وجود أكثر من واحد من هذه العناصر القابلة للإنشاء، فبأي ترتيب تنتشر الأحداث؟ إذا استخدمت المثال من القسم الأخير، تتم ترجمة واجهة المستخدم هذه إلى شجرة واجهة المستخدم التالية، حيث يستجيب ListItem وButton فقط لأحداث المؤشر:

بنية الشجرة الطبقة العلوية هي ListItem، والطبقة الثانية تحتوي على صورة وعمود وزر، وينقسم العمود إلى نصين. يتم تمييز عنصر القائمة والزر.

تتدفق أحداث المؤشر خلال كل عنصر من هذه العناصر القابلة للإنشاء ثلاث مرات، خلال ثلاث "تمريرات":

  • في البطاقة الأولية، يتدفق الحدث من أعلى شجرة واجهة المستخدم إلى الأسفل. يسمح هذا التدفق لأحد الوالدين باعتراض حدث قبل أن يتمكن الطفل من استهلاكه. على سبيل المثال، تحتاج التلميحات إلى اعتراض ضغطة طويلة بدلاً من تمريرها إلى الأطفال. في المثال الذي نطرحه، يتلقى ListItem الحدث قبل Button.
  • في البطاقة الرئيسية، يتدفق الحدث من العُقد الورقية في شجرة واجهة المستخدم إلى جذور شجرة واجهة المستخدم. هذه المرحلة هي المكان الذي تستخدم فيه الإيماءات عادةً، وهي الاجتياز الافتراضي عند الاستماع إلى الأحداث. تعني معالجة الإيماءات في هذه البطاقة أن العُقد الورقية لها الأولوية على عناصرها الرئيسية، وهو السلوك الأكثر منطقية لمعظم الإيماءات. في المثال الذي نتناوله، يتلقى Button الحدث قبل ListItem.
  • في البطاقة النهائية، يتدفق الحدث مرة أخرى من أعلى شجرة واجهة المستخدم إلى العُقد الورقية. يسمح هذا التدفق للعناصر الأعلى في المكدس بالاستجابة لاستهلاك الحدث من جانبها الرئيسية. على سبيل المثال، يزيل الزر مؤشر التموج عندما تتحول الضغطة إلى سحب للأصل القابل للتمرير.

ويمكن تمثيل مسار الحدث مرئيًا على النحو التالي:

بمجرد استهلاك تغيير المدخلات، يتم تمرير هذه المعلومات من تلك النقطة في التدفق فصاعدًا:

في الرمز، يمكنك تحديد البطاقة التي تهتم بها:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

في مقتطف الرمز هذا، يتم عرض الحدث المتطابق نفسه من خلال كل من استدعاءات طريقة الانتظار هذه، على الرغم من أنه من المحتمل أن تكون البيانات المتعلقة بالاستهلاك قد تغيّرت.

الإيماءات التجريبية

في طرق الاختبار، يمكنك إرسال أحداث المؤشر يدويًا باستخدام طريقة performTouchInput. يتيح لك هذا تنفيذ إيماءات كاملة ذات مستوى أعلى (مثل التصغير أو النقر لمدة طويلة) أو إيماءات منخفضة المستوى (مثل تحريك المؤشر بمقدار كمية معينة من وحدات البكسل):

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

اطّلِع على مستندات performTouchInput للحصول على مزيد من الأمثلة.

مزيد من المعلومات

يمكنك الاطّلاع على المزيد من المعلومات حول الإيماءات في Jetpack Compose من خلال الموارد التالية: