تقدّم عناصر واجهة المستخدم ملاحظات لمستخدم الجهاز من خلال طريقة استجابتها لتفاعلات المستخدم. لكل مكوّن طريقة خاصة للاستجابة للتفاعلات، ما يساعد المستخدم في معرفة تأثير تفاعلاته. على سبيل المثال، إذا لمس المستخدم زرًا على شاشة تعمل باللمس في أحد الأجهزة، من المرجّح أن يتغيّر الزر بطريقة ما، ربما عن طريق إضافة لون تمييز. يُعلم هذا التغيير المستخدم بأنّه قد لمس الزر. إذا لم يكن المستخدم يريد تنفيذ هذا الإجراء، سيعرف أنّه عليه إبعاد إصبعه عن الزر قبل رفعه، وإلا سيتم تفعيل الزر.
توضّح مستندات الإيماءات في 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، لذا يمكنك جمع التفاعلات منه بالطريقة نفسها التي تستخدمها مع أي عملية نقل بيانات أخرى. لمزيد من المعلومات حول قرار التصميم هذا،
يُرجى الاطّلاع على مشاركة المدوّنة Illuminating Interactions.
حالة التفاعل
قد تحتاج إلى توسيع الوظائف المضمّنة في مكوّناتك من خلال تتبُّع التفاعلات بنفسك أيضًا. على سبيل المثال، قد تريد أن يتغير لون الزر عند الضغط عليه. أبسط طريقة لتتبُّع التفاعلات هي مراقبة حالة التفاعل المناسبة. يوفّر النوع 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 Material، كمنتجين ومستهلكين في الوقت نفسه. وتتعامل هذه العناصر مع أحداث الإدخال والتركيز، كما تغيّر مظهرها استجابةً لهذه الأحداث، مثل عرض تموّج أو تحريك مستوى ارتفاعها. نتيجةً لذلك، يعرضون 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 ذات المستوى الأعلى استنادًا إلى وحدات
البناء الأساسية التي تنتج Interaction اللازمة للتحكّم في التأثيرات
المتموجة وغيرها من التأثيرات المرئية. توفّر المكتبة الأساسية أدوات تعديل تفاعلية عالية المستوى، مثل Modifier.hoverable وModifier.focusable وModifier.draggable.
لإنشاء مكوّن يستجيب لأحداث التمرير، يمكنك ببساطة استخدام
Modifier.hoverable وتمرير MutableInteractionSource كمَعلمة.
عندما يتم تمرير مؤشر الماوس فوق المكوّن، يتم إصدار HoverInteractions، ويمكنك استخدام ذلك لتغيير طريقة ظهور المكوّن.
// 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" }
بما أنّ جميع Interaction تتبع البنية نفسها، لا يوجد فرق كبير في الرمز عند التعامل مع أنواع مختلفة من تفاعلات المستخدمين، فالنمط العام هو نفسه.
يُرجى العِلم أنّ الأمثلة السابقة في هذا القسم تمثّل 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 الخاصة بـ collect، بدلاً من الكتابة إلى حالة. يتوفّر مثال على هذا النمط في قسم إنشاء Indication متقدّم بحدود متحرّكة.
مثال: إنشاء مكوّن مع معالجة مخصّصة للتفاعل
للاطّلاع على كيفية إنشاء مكوّنات مع ردّ مخصّص على الإدخال، إليك مثال على زر معدَّل. في هذه الحالة، لنفترض أنّك تريد زرًا يستجيب للنقرات من خلال تغيير مظهره:
لإجراء ذلك، أنشئ عنصرًا قابلاً للإنشاء مخصّصًا استنادًا إلى 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 الحالي، فإنّه يتفاعل مع تفاعلات المستخدمين بالطرق المعتادة. عندما يضغط المستخدم على الزر، يتغير مستوى عتامة الزر قليلاً، تمامًا مثل ButtonMaterialButton العادي.
إنشاء مؤثر مخصّص قابل لإعادة الاستخدام وتطبيقه باستخدام 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، اتّبِع الخطوات التالية:
أنشئ
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() } } }
أنشئ
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 }
يستخدم
Modifier.clickableModifier.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!") }
Indication مخصّصإنشاء Indication متقدّم مع حدود متحركة
لا يقتصر Indication على تأثيرات التحويل، مثل تغيير حجم أحد المكوّنات. بما أنّ IndicationNodeFactory تعرض Modifier.Node، يمكنك رسم أي نوع من التأثيرات أعلى المحتوى أو أسفله كما هو الحال مع واجهات برمجة التطبيقات الأخرى الخاصة بالرسم. على سبيل المثال، يمكنك رسم حدّ متحرك حول المكوّن وتراكب فوق المكوّن عند الضغط عليه:
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، فإذا حدثت عملية نقر أثناء عملية نقر أو حركة استراحة حالية، يتم إلغاء الحركة السابقة، وتبدأ حركة النقر من البداية. لإتاحة تأثيرات متعدّدة متزامنة (مثل التموجات، حيث سيتم رسم حركة تموج جديدة فوق التموجات الأخرى)، يمكنك تتبُّع الحركات في قائمة بدلاً من إلغاء الحركات الحالية وبدء حركات جديدة.
اقتراحات مخصصة لك
- ملاحظة: يتم عرض نص الرابط عندما تكون JavaScript غير مفعّلة.
- التعرّف على الإيماءات
- Kotlin لـ Jetpack Compose
- مكوّنات وتنسيقات Material