التعامل مع تفاعلات المستخدم

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

الشكل 1. أزرار تظهر دائمًا مفعَّلة، بدون تأثير تموّج عند الضغط عليها
الشكل 2. أزرار تتضمّن تأثيرات تموّج عند الضغط عليها تعكس حالتها المفعَّلة وفقًا لذلك

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

التفاعلات

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

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

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

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

تحدث هذه التفاعلات عادةً في أزواج، مع بداية ونهاية. يحتوي التفاعل الثاني على إشارة إلى التفاعل الأول. على سبيل المثال، إذا لمس المستخدم زرًا ثم رفع إصبعه، ستؤدي اللمسة إلى إنشاء تفاعل PressInteraction.Press، وسيؤدي رفع الإصبع إلى إنشاء PressInteraction.Release. يحتوي Release على سمة press تحدد PressInteraction.Press الأولي.

يمكنك رؤية التفاعلات لمكون معين من خلال مراقبة InteractionSource. يستند InteractionSource إلى تدفقات Kotlin، لذا يمكنك جمع التفاعلات منه بالطريقة نفسها التي تستخدمها مع أي تدفق آخر. لمزيد من المعلومات حول قرار التصميم هذا، يُرجى الاطّلاع على منشور المدوّنة توضيح التفاعلات.

حالة التفاعل

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

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

بالإضافة إلى collectIsPressedAsState()، توفّر أداة Compose أيضًا collectIsFocusedAsState() وcollectIsDraggedAsState() وcollectIsHoveredAsState(). هذه الطرق هي في الواقع طرق ملائمة مبنية على واجهات برمجة تطبيقات InteractionSource ذات مستوى أدنى. في بعض الحالات، قد تحتاج إلى استخدام هذه الدوال ذات المستوى الأدنى مباشرةً.

على سبيل المثال، لنفترض أنّك بحاجة إلى معرفة ما إذا كان يتم الضغط على زر، وأيضًا ما إذا كان يتم سحبه. إذا كنت تستخدم كلاً من collectIsPressedAsState() وcollectIsDraggedAsState()، ستؤدي أداة Compose الكثير من العمل المكرّر، وليس هناك ما يضمن حصولك على جميع التفاعلات بالترتيب الصحيح. في مثل هذه الحالات، قد ترغب في العمل مباشرة مع InteractionSource. لمزيد من المعلومات حول تتبُّع التفاعلات بنفسك باستخدام InteractionSource، يُرجى الاطّلاع على مقالة العمل باستخدام InteractionSource.

يوضّح القسم التالي كيفية استخدام التفاعلات وإصدارها باستخدام InteractionSource وMutableInteractionSource على التوالي.

استهلاك Interaction وإصداره

يمثّل InteractionSource مصدر بيانات للقراءة فقط من Interactions، ولا يمكن إرسال Interaction إلى InteractionSource. لإصدار Interaction، عليك استخدام MutableInteractionSource، الذي يمتد من InteractionSource.

يمكن للمعدِّلات والمكوّنات استهلاك Interactions أو إصداره أو استهلاكه وإصداره. توضّح الأقسام التالية كيفية استخدام التفاعلات وإصدارها من كل من المعدِّلات والمكوّنات.

مثال على معدِّل الاستهلاك

بالنسبة إلى المُعدِّل الذي يرسم حدودًا لوضع التركيز، ما عليك سوى مراقبة Interactions، لكي تتمكّن من قبول InteractionSource:

fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier {
    // ...
}

يتضح من توقيع الدالة أنّ هذا المُعدِّل هو مستهلك، أي أنّه يمكنه استهلاك Interaction، ولكن لا يمكنه إصداره.

مثال على معدِّل الإنتاج

بالنسبة إلى المُعدِّل الذي يتعامل مع أحداث التمرير مثل Modifier.hoverable، عليك إصدار Interactions وقبول MutableInteractionSource كمَعلمة بدلاً من ذلك:

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

هذا المُعدِّل هو مُنتِج، ويمكنه استخدام MutableInteractionSource المقدَّم لإصدار HoverInteractions عند تمرير مؤشر الماوس فوقه أو إبعاده عنه.

إنشاء مكوّنات تستهلك وتنتج

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

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,

    // exposes MutableInteractionSource as a parameter
    interactionSource: MutableInteractionSource? = null,

    elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) { /* content() */ }

يسمح ذلك برفع MutableInteractionSource خارج المكوّن ومراقبة جميع Interaction التي ينتجها المكوّن. يمكنك استخدام هذا للتحكم في مظهر هذا المكون، أو أي مكون آخر في واجهة المستخدم الخاصة بك.

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

يتّبع Compose نهجًا معماريًا متعدد الطبقات، لذلك يتم إنشاء مكوّنات Material ذات المستوى الأعلى فوق وحدات البناء الأساسية التي تنتج Interactions التي تحتاجها للتحكّم في التموجات والتأثيرات المرئية الأخرى. توفّر مكتبة الأساس معدِّلات تفاعل عالية المستوى، مثل Modifier.hoverable وModifier.focusable وModifier.draggable.

لإنشاء مكوّن يستجيب لأحداث التمرير، يمكنك ببساطة استخدام Modifier.hoverable وتمرير MutableInteractionSource كمَعلمة. عند تمرير مؤشر الماوس فوق المكوّن، يتم إرسال HoverInteraction، ويمكنك استخدام ذلك لتغيير طريقة ظهور المكوّن.

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

لجعل هذا المكوّن قابلاً للتركيز أيضًا، يمكنك إضافة Modifier.focusable وتمرير MutableInteractionSource نفسه كمَعلمة. الآن، يتم إرسال كل من HoverInteraction.Enter/Exit وFocusInteraction.Focus/Unfocus من خلال MutableInteractionSource نفسه، ويمكنك تخصيص مظهر كلا النوعين من التفاعل في المكان نفسه:

// This InteractionSource will emit hover and focus interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource)
        .focusable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

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

// This InteractionSource will emit hover, focus, and press interactions
val interactionSource = remember { MutableInteractionSource() }
Box(
    Modifier
        .size(100.dp)
        .clickable(
            onClick = {},
            interactionSource = interactionSource,

            // Also show a ripple effect
            indication = ripple()
        ),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

العمل مع InteractionSource

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

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
        }
    }
}

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

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is PressInteraction.Release -> {
                interactions.remove(interaction.press)
            }
            is PressInteraction.Cancel -> {
                interactions.remove(interaction.press)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
            is DragInteraction.Stop -> {
                interactions.remove(interaction.start)
            }
            is DragInteraction.Cancel -> {
                interactions.remove(interaction.start)
            }
        }
    }
}

الآن، إذا أردت معرفة ما إذا كان يتم الضغط على المكوّن أو سحبه حاليًا، ما عليك سوى التحقّق مما إذا كان interactions فارغًا:

val isPressedOrDragged = interactions.isNotEmpty()

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

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

بما أنّ جميع Interactions تتّبع البنية نفسها، لا يوجد اختلاف كبير في الرمز عند العمل مع أنواع مختلفة من تفاعلات المستخدمين، فالنمط العام هو نفسه.

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

هذا مهم للتفاعلات، حيث يمكن أن تبدأ التفاعلات وتنتهي بانتظام في نفس الإطار. على سبيل المثال، باستخدام المثال السابق مع Button:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

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

مثال: إنشاء مكوّن مع معالجة مخصّصة للتفاعل

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

صورة متحركة لزر يضيف رمز عربة تسوّق بشكل ديناميكي عند النقر عليه
الشكل 3. زر يضيف رمزًا ديناميكيًا عند النقر عليه

لإجراء ذلك، أنشئ دالة مركّبة مخصّصة استنادًا إلى Button، واجعلها تأخذ مَعلمة icon إضافية لرسم الرمز (في هذه الحالة، سلّة تسوّق). يمكنك استدعاء collectIsPressedAsState() لتتبُّع ما إذا كان المستخدم يمرّر مؤشر الماوس فوق الزر، وعندما يفعل ذلك، يمكنك إضافة الرمز. إليك شكل الرمز:

@Composable
fun PressIconButton(
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    text: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource? = null
) {
    val isPressed = interactionSource?.collectIsPressedAsState()?.value ?: false

    Button(
        onClick = onClick,
        modifier = modifier,
        interactionSource = interactionSource
    ) {
        AnimatedVisibility(visible = isPressed) {
            if (isPressed) {
                Row {
                    icon()
                    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
                }
            }
        }
        text()
    }
}

في ما يلي كيفية استخدام هذه الدالة المركّبة الجديدة:

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

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

إنشاء مؤثر مخصّص قابل لإعادة الاستخدام وتطبيقه باستخدام "Indication"

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

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

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

لتجنُّب هذه المشاكل وتوسيع نطاق مكوّن مخصّص بسهولة في نظامك، يمكنك استخدام Indication. يمثّل Indication تأثيرًا مرئيًا قابلاً لإعادة الاستخدام يمكن تطبيقه على المكوّنات في تطبيق أو نظام تصميم. ينقسم Indication إلى جزأين:

  • ‫IndicationNodeFactory: مصنع ينشئ مثيلات Modifier.Node التي تعرض المؤثرات البصرية لمكوّن معيّن. بالنسبة إلى عمليات التنفيذ الأبسط التي لا تتغيّر في جميع المكوّنات، يمكن أن يكون هذا العنصر فرديًا (كائنًا) ويمكن إعادة استخدامه في التطبيق بأكمله.

    ويمكن أن تكون هذه النُسخ ذات حالة أو عديمة الحالة. بما أنّها يتم إنشاؤها لكل مكوّن، يمكنها استرداد القيم من CompositionLocal لتغيير طريقة ظهورها أو سلوكها داخل مكوّن معيّن، كما هو الحال مع أي Modifier.Node آخر.

  • Modifier.indication: عنصر تعديل يرسم Indication لمكوّن معيّن تقبل Modifier.clickable ومعدِّلات التفاعل الأخرى العالية المستوى مَعلمة مؤشر مباشرةً، لذا فهي لا تُصدر Interaction فحسب، بل يمكنها أيضًا رسم تأثيرات بصرية لـ Interaction التي تُصدرها. لذلك، في الحالات البسيطة، يمكنك استخدام Modifier.clickable بدون الحاجة إلى Modifier.indication.

استبدال المؤثر بـ Indication

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

ينشئ الرمز التالي زرًا يتم تصغيره عند الضغط عليه:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f, label = "scale")

Button(
    modifier = Modifier.scale(scale),
    onClick = { },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

لتحويل تأثير المقياس في المقتطف أعلاه إلى Indication، اتّبِع الخطوات التالية:

  1. أنشئوا Modifier.Node المسؤول عن تطبيق تأثير التكبير أو التصغير. عند إرفاق العقدة، تراقب مصدر التفاعل، على غرار الأمثلة السابقة. الفرق الوحيد هنا هو أنّه يشغّل الصور المتحركة مباشرةً بدلاً من تحويل التفاعلات الواردة إلى حالة.

    يجب أن تنفّذ العقدة DrawModifierNode لكي تتمكّن من إلغاء ContentDrawScope#draw()، وعرض تأثير المقياس باستخدام أوامر الرسم نفسها كما هو الحال مع أي واجهة برمجة تطبيقات رسومات أخرى في Compose.

    سيؤدي استدعاء دالة drawContent() المتاحة من مستقبل ContentDrawScope إلى رسم المكوّن الفعلي الذي يجب تطبيق Indication عليه، لذا ما عليك سوى استدعاء هذه الدالة ضمن تحويل المقياس. تأكَّد من أنّ عمليات تنفيذ Indication تستدعي دائمًا drawContent() في مرحلة ما، وإلا لن يتم رسم المكوّن الذي تطبّق عليه Indication.

    private class ScaleNode(private val interactionSource: InteractionSource) :
        Modifier.Node(), DrawModifierNode {
    
        var currentPressPosition: Offset = Offset.Zero
        val animatedScalePercent = Animatable(1f)
    
        private suspend fun animateToPressed(pressPosition: Offset) {
            currentPressPosition = pressPosition
            animatedScalePercent.animateTo(0.9f, spring())
        }
    
        private suspend fun animateToResting() {
            animatedScalePercent.animateTo(1f, spring())
        }
    
        override fun onAttach() {
            coroutineScope.launch {
                interactionSource.interactions.collectLatest { interaction ->
                    when (interaction) {
                        is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                        is PressInteraction.Release -> animateToResting()
                        is PressInteraction.Cancel -> animateToResting()
                    }
                }
            }
        }
    
        override fun ContentDrawScope.draw() {
            scale(
                scale = animatedScalePercent.value,
                pivot = currentPressPosition
            ) {
                this@draw.drawContent()
            }
        }
    }

  2. أنشئ IndicationNodeFactory. وتتمثل مسؤوليتها الوحيدة في إنشاء مثيل عقدة جديد لمصدر تفاعل معيّن. بما أنّه ما مِن مَعلمات لضبط المؤشر، يمكن أن يكون المصنع كائنًا:

    object ScaleIndication : IndicationNodeFactory {
        override fun create(interactionSource: InteractionSource): DelegatableNode {
            return ScaleNode(interactionSource)
        }
    
        override fun equals(other: Any?): Boolean = other === ScaleIndication
        override fun hashCode() = 100
    }

  3. يستخدم Modifier.clickable Modifier.indication داخليًا، لذا لإنشاء مكوّن قابل للنقر باستخدام ScaleIndication، ما عليك سوى تقديم Indication كمَعلمة إلى clickable:

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

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

    @Composable
    fun ScaleButton(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        interactionSource: MutableInteractionSource? = null,
        shape: Shape = CircleShape,
        content: @Composable RowScope.() -> Unit
    ) {
        Row(
            modifier = modifier
                .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp)
                .clickable(
                    enabled = enabled,
                    indication = ScaleIndication,
                    interactionSource = interactionSource,
                    onClick = onClick
                )
                .border(width = 2.dp, color = Color.Blue, shape = shape)
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
            content = content
        )
    }

يمكنك بعد ذلك استخدام الزر بالطريقة التالية:

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

صورة متحركة لزر يحمل رمز عربة تسوّق يصغر حجمه عند الضغط عليه
الشكل 4. زر تم إنشاؤه باستخدام Indication مخصّص.

إنشاء Indication متقدّم بحدود متحركة

لا يقتصر Indication على تأثيرات التحويل فقط، مثل تغيير حجم المكوّن. بما أنّ IndicationNodeFactory تعرض Modifier.Node، يمكنك رسم أي نوع من التأثيرات فوق المحتوى أو تحته كما هو الحال مع واجهات برمجة التطبيقات الأخرى للرسم. على سبيل المثال، يمكنك رسم حدود متحركة حول المكوّن وتراكب فوق المكوّن عند الضغط عليه:

زرّ بتأثير قوس قزح رائع عند الضغط عليه
الشكل 5. تأثير حدود متحركة مرسومة باستخدام Indication.

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

data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory {

    override fun create(interactionSource: InteractionSource): DelegatableNode {
        return NeonNode(
            shape,
            // Double the border size for a stronger press effect
            borderWidth * 2,
            interactionSource
        )
    }
}

إنّ تنفيذ Modifier.Node هو نفسه من الناحية النظرية، حتى لو كان رمز الرسم أكثر تعقيدًا. كما في السابق، يراقب InteractionSource عند إرفاقه، ويشغّل الصور المتحركة، وينفّذ DrawModifierNode لرسم التأثير فوق المحتوى:

private class NeonNode(
    private val shape: Shape,
    private val borderWidth: Dp,
    private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
    var currentPressPosition: Offset = Offset.Zero
    val animatedProgress = Animatable(0f)
    val animatedPressAlpha = Animatable(1f)

    var pressedAnimation: Job? = null
    var restingAnimation: Job? = null

    private suspend fun animateToPressed(pressPosition: Offset) {
        // Finish any existing animations, in case of a new press while we are still showing
        // an animation for a previous one
        restingAnimation?.cancel()
        pressedAnimation?.cancel()
        pressedAnimation = coroutineScope.launch {
            currentPressPosition = pressPosition
            animatedPressAlpha.snapTo(1f)
            animatedProgress.snapTo(0f)
            animatedProgress.animateTo(1f, tween(450))
        }
    }

    private fun animateToResting() {
        restingAnimation = coroutineScope.launch {
            // Wait for the existing press animation to finish if it is still ongoing
            pressedAnimation?.join()
            animatedPressAlpha.animateTo(0f, tween(250))
            animatedProgress.snapTo(0f)
        }
    }

    override fun onAttach() {
        coroutineScope.launch {
            interactionSource.interactions.collect { interaction ->
                when (interaction) {
                    is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                    is PressInteraction.Release -> animateToResting()
                    is PressInteraction.Cancel -> animateToResting()
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition(
            currentPressPosition, size
        )
        val brush = animateBrush(
            startPosition = startPosition,
            endPosition = endPosition,
            progress = animatedProgress.value
        )
        val alpha = animatedPressAlpha.value

        drawContent()

        val outline = shape.createOutline(size, layoutDirection, this)
        // Draw overlay on top of content
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha * 0.1f
        )
        // Draw border on top of overlay
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha,
            style = Stroke(width = borderWidth.toPx())
        )
    }

    /**
     * Calculates a gradient start / end where start is the point on the bounding rectangle of
     * size [size] that intercepts with the line drawn from the center to [pressPosition],
     * and end is the intercept on the opposite end of that line.
     */
    private fun calculateGradientStartAndEndFromPressPosition(
        pressPosition: Offset,
        size: Size
    ): Pair<Offset, Offset> {
        // Convert to offset from the center
        val offset = pressPosition - size.center
        // y = mx + c, c is 0, so just test for x and y to see where the intercept is
        val gradient = offset.y / offset.x
        // We are starting from the center, so halve the width and height - convert the sign
        // to match the offset
        val width = (size.width / 2f) * sign(offset.x)
        val height = (size.height / 2f) * sign(offset.y)
        val x = height / gradient
        val y = gradient * width

        // Figure out which intercept lies within bounds
        val intercept = if (abs(y) <= abs(height)) {
            Offset(width, y)
        } else {
            Offset(x, height)
        }

        // Convert back to offsets from 0,0
        val start = intercept + size.center
        val end = Offset(size.width - start.x, size.height - start.y)
        return start to end
    }

    private fun animateBrush(
        startPosition: Offset,
        endPosition: Offset,
        progress: Float
    ): Brush {
        if (progress == 0f) return TransparentBrush

        // This is *expensive* - we are doing a lot of allocations on each animation frame. To
        // recreate a similar effect in a performant way, it would be better to create one large
        // gradient and translate it on each frame, instead of creating a whole new gradient
        // and shader. The current approach will be janky!
        val colorStops = buildList {
            when {
                progress < 1 / 6f -> {
                    val adjustedProgress = progress * 6f
                    add(0f to Blue)
                    add(adjustedProgress to Color.Transparent)
                }
                progress < 2 / 6f -> {
                    val adjustedProgress = (progress - 1 / 6f) * 6f
                    add(0f to Purple)
                    add(adjustedProgress * MaxBlueStop to Blue)
                    add(adjustedProgress to Blue)
                    add(1f to Color.Transparent)
                }
                progress < 3 / 6f -> {
                    val adjustedProgress = (progress - 2 / 6f) * 6f
                    add(0f to Pink)
                    add(adjustedProgress * MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 4 / 6f -> {
                    val adjustedProgress = (progress - 3 / 6f) * 6f
                    add(0f to Orange)
                    add(adjustedProgress * MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 5 / 6f -> {
                    val adjustedProgress = (progress - 4 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                else -> {
                    val adjustedProgress = (progress - 5 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxYellowStop to Yellow)
                    add(MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
            }
        }

        return linearGradient(
            colorStops = colorStops.toTypedArray(),
            start = startPosition,
            end = endPosition
        )
    }

    companion object {
        val TransparentBrush = SolidColor(Color.Transparent)
        val Blue = Color(0xFF30C0D8)
        val Purple = Color(0xFF7848A8)
        val Pink = Color(0xFFF03078)
        val Orange = Color(0xFFF07800)
        val Yellow = Color(0xFFF0D800)
        const val MaxYellowStop = 0.16f
        const val MaxOrangeStop = 0.33f
        const val MaxPinkStop = 0.5f
        const val MaxPurpleStop = 0.67f
        const val MaxBlueStop = 0.83f
    }
}

يتمثل الاختلاف الرئيسي هنا في وجود حد أدنى لمدة الرسوم المتحركة مع الدالة animateToResting()، لذلك حتى إذا تم تحرير الضغط على الفور، فستستمر الرسوم المتحركة للضغط. هناك أيضًا معالجة للضغطات السريعة المتعددة في بداية animateToPressed — إذا حدثت ضغطة أثناء ضغطة حالية أو حركة في وضع السكون، يتم إلغاء الحركة السابقة، وتبدأ حركة الضغط من البداية. لدعم تأثيرات متعددة متزامنة (مثل التموجات، حيث يتم رسم حركة تموج جديدة فوق التموجات الأخرى)، يمكنك تتبع الحركات في قائمة، بدلاً من إلغاء الحركات الحالية وبدء حركات جديدة.