مدیریت تعاملات کاربر

اجزای رابط کاربری با نحوه‌ی پاسخ به تعاملات کاربر، به کاربر دستگاه بازخورد می‌دهند. هر جزء روش خاص خود را برای پاسخ به تعاملات دارد که به کاربر کمک می‌کند تا بداند تعاملاتش چه کاری انجام می‌دهند. به عنوان مثال، اگر کاربری دکمه‌ای را روی صفحه لمسی دستگاه لمس کند، احتمالاً دکمه به نوعی تغییر می‌کند، شاید با اضافه کردن یک رنگ برجسته. این تغییر به کاربر اطلاع می‌دهد که دکمه را لمس کرده است. اگر کاربر نمی‌خواست این کار را انجام دهد، قبل از رها کردن، می‌داند که باید انگشت خود را از روی دکمه دور کند - در غیر این صورت، دکمه فعال می‌شود.

شکل ۱. دکمه‌هایی که همیشه فعال به نظر می‌رسند، بدون هیچ موجی هنگام فشردن.
شکل ۲. دکمه‌هایی با موج‌های فشاری که وضعیت فعال بودن آنها را به طور متناسب منعکس می‌کنند.

مستندات Compose Gestures نحوه مدیریت رویدادهای اشاره‌گر سطح پایین توسط کامپوننت‌های 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 بر اساس جریان‌های کاتلین ساخته شده است، بنابراین می‌توانید تعاملات را از آن به همان روشی که با هر جریان دیگری کار می‌کنید، جمع‌آوری کنید. برای اطلاعات بیشتر در مورد این تصمیم طراحی، به پست وبلاگ 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() را نیز ارائه می‌دهد. این متدها در واقع متدهای راحتی هستند که بر روی APIهای سطح پایین‌تر InteractionSource ساخته شده‌اند. در برخی موارد، ممکن است بخواهید مستقیماً از این توابع سطح پایین‌تر استفاده کنید.

برای مثال، فرض کنید باید بدانید که آیا یک دکمه فشرده می‌شود یا خیر، و همچنین آیا کشیده می‌شود یا خیر. اگر از هر دو collectIsPressedAsState() و collectIsDraggedAsState() استفاده کنید، Compose کارهای تکراری زیادی انجام می‌دهد و هیچ تضمینی وجود ندارد که همه تعاملات را به ترتیب صحیح دریافت کنید. برای موقعیت‌هایی مانند این، ممکن است بخواهید مستقیماً با InteractionSource کار کنید. برای اطلاعات بیشتر در مورد ردیابی تعاملات خودتان با InteractionSource ، به بخش «کار با InteractionSource مراجعه کنید.

بخش زیر نحوه مصرف و انتشار تعاملات به ترتیب با InteractionSource و MutableInteractionSource را شرح می‌دهد.

Interaction مصرف و انتشار

InteractionSource یک جریان فقط خواندنی از Interactions را نشان می‌دهد - انتشار یک Interaction به یک InteractionSource امکان‌پذیر نیست. برای انتشار Interaction ها، باید از یک MutableInteractionSource استفاده کنید که از InteractionSource امتداد می‌یابد.

اصلاح‌کننده‌ها و کامپوننت‌ها می‌توانند Interactions مصرف (consum)، انتشار (emission) یا مصرف (consum) و انتشار (emission) کنند. بخش‌های زیر نحوه مصرف (consum) و انتشار (emission) تعاملات را از هر دو اصلاح‌کننده و کامپوننت شرح می‌دهند.

مثال اصلاح‌کننده مصرف‌کننده

برای یک اصلاح‌کننده که برای حالت متمرکز، حاشیه رسم می‌کند، فقط باید Interactions مشاهده کنید، بنابراین می‌توانید یک InteractionSource بپذیرید:

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

از امضای تابع مشخص است که این اصلاح‌کننده یک مصرف‌کننده است - می‌تواند Interaction ها را مصرف کند، اما نمی‌تواند آنها را منتشر کند.

مثال اصلاح‌کننده‌ی تولید

برای یک اصلاح‌کننده که رویدادهای شناور مانند Modifier.hoverable مدیریت می‌کند، باید Interactions را منتشر کنید و به جای آن، یک MutableInteractionSource به عنوان پارامتر بپذیرید:

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

این اصلاح‌کننده یک تولیدکننده است - می‌تواند از MutableInteractionSource ارائه شده برای انتشار HoverInteractions هنگام قرار گرفتن ماوس روی آن یا عدم قرار گرفتن ماوس روی آن استفاده کند.

اجزایی بسازید که مصرف و تولید می‌کنند

کامپوننت‌های سطح بالا مانند Material 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 سطح بالا بر روی بلوک‌های سازنده‌ی بنیادی ساخته می‌شوند که Interaction های مورد نیاز برای کنترل موج‌ها و سایر جلوه‌های بصری را تولید می‌کنند. کتابخانه‌ی Foundation، اصلاح‌کننده‌های تعامل سطح بالایی مانند Modifier.hoverable ، Modifier.focusable و Modifier.draggable را ارائه می‌دهد.

برای ساخت کامپوننتی که به رویدادهای hover پاسخ می‌دهد، می‌توانید به سادگی Modifier.hoverable استفاده کنید و یک MutableInteractionSource را به عنوان پارامتر ارسال کنید. هر زمان که کامپوننت hover شود، 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 است - برای اینکه یک کامپوننت قابل کلیک باشد، به طور ضمنی hoverable است و کامپوننت‌هایی که می‌توانند کلیک شوند نیز باید focusable باشند. می‌توانید از Modifier.clickable برای ایجاد کامپوننتی استفاده کنید که تعاملات hover، focus و press را مدیریت می‌کند، بدون اینکه نیازی به ترکیب APIهای سطح پایین‌تر داشته باشید. اگر می‌خواهید کامپوننت خود را نیز قابل کلیک کنید، می‌توانید 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

اگر به اطلاعات سطح پایین در مورد تعاملات با یک کامپوننت نیاز دارید، می‌توانید از APIهای جریان استاندارد برای 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 ripple، پوشش وضعیت مناسب را برای استفاده برای جدیدترین تعامل تشخیص می‌دهد:

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

از آنجا که همه Interaction ها از ساختار یکسانی پیروی می‌کنند، هنگام کار با انواع مختلف تعاملات کاربر، تفاوت زیادی در کد وجود ندارد - الگوی کلی یکسان است.

توجه داشته باشید که مثال‌های قبلی در این بخش، Flow تعاملات با استفاده از State را نشان می‌دهند - این امر مشاهده مقادیر به‌روز شده را آسان می‌کند، زیرا خواندن مقدار state به طور خودکار باعث ترکیب مجدد می‌شود. با این حال، ترکیب از پیش فریم‌بندی شده است. این بدان معناست که اگر state تغییر کند و سپس در همان فریم دوباره تغییر کند، کامپوننت‌هایی که state را مشاهده می‌کنند، تغییر را نمی‌بینند.

این برای تعاملات مهم است، زیرا تعاملات می‌توانند به طور منظم در یک فریم شروع و پایان یابند. برای مثال، با استفاده از مثال قبلی با Button :

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

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

اگر فشار دادن دکمه در یک فریم شروع و پایان یابد، متن هرگز به صورت "فشرده شد!" نمایش داده نمی‌شود. در بیشتر موارد، این مشکلی نیست - نمایش یک جلوه بصری برای چنین مدت زمان کوتاهی منجر به سوسو زدن می‌شود و برای کاربر چندان قابل توجه نخواهد بود. برای برخی موارد، مانند نمایش یک جلوه موج‌دار یا یک انیمیشن مشابه، ممکن است بخواهید جلوه را حداقل برای مدت زمان کوتاهی نمایش دهید، به جای اینکه اگر دکمه دیگر فشرده نشود، فوراً متوقف شود. برای انجام این کار، می‌توانید به جای نوشتن در یک حالت، انیمیشن‌ها را مستقیماً از داخل لامبدا شروع و متوقف کنید. نمونه‌ای از این الگو در بخش "ساخت یک Indication پیشرفته با حاشیه متحرک" وجود دارد.

مثال: ساخت کامپوننت با مدیریت تعاملات سفارشی

برای اینکه ببینید چگونه می‌توانید کامپوننت‌هایی با پاسخ سفارشی به ورودی بسازید، در اینجا مثالی از یک دکمه‌ی اصلاح‌شده آورده شده است. در این حالت، فرض کنید می‌خواهید دکمه‌ای داشته باشید که با تغییر ظاهرش به فشارها پاسخ دهد:

انیمیشن دکمه‌ای که با کلیک روی آن، به صورت پویا یک آیکون سبد خرید مواد غذایی اضافه می‌شود
شکل ۳. دکمه‌ای که با کلیک روی آن، به صورت پویا یک آیکون اضافه می‌شود.

برای انجام این کار، یک composable سفارشی بر اساس 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 جدید بر روی Material Button موجود ساخته شده است، به تمام روش‌های معمول به تعاملات کاربر واکنش نشان می‌دهد. وقتی کاربر دکمه را فشار می‌دهد، میزان شفافیت آن کمی تغییر می‌کند، درست مانند یک Material Button معمولی.

با استفاده از Indication یک افکت سفارشی قابل استفاده مجدد ایجاد و اعمال کنید

در بخش‌های قبلی، یاد گرفتید که چگونه بخشی از یک کامپوننت را در پاسخ به Interaction های مختلف تغییر دهید، مانند نمایش یک آیکون هنگام فشردن. همین رویکرد را می‌توان برای تغییر مقدار پارامترهایی که به یک کامپوننت ارائه می‌دهید یا تغییر محتوای نمایش داده شده در داخل یک کامپوننت استفاده کرد، اما این فقط برای هر کامپوننت قابل اجرا است. اغلب، یک برنامه یا سیستم طراحی یک سیستم عمومی برای جلوه‌های بصری حالت‌دار دارد - جلوه‌ای که باید به طور مداوم به همه کامپوننت‌ها اعمال شود.

اگر در حال ساخت این نوع سیستم طراحی هستید، سفارشی‌سازی یک جزء و استفاده مجدد از این سفارشی‌سازی برای سایر اجزا می‌تواند به دلایل زیر دشوار باشد:

  • هر جزء در سیستم طراحی به الگوی یکسانی نیاز دارد
  • به راحتی می‌توان فراموش کرد که این افکت را روی اجزای تازه ساخته شده و اجزای قابل کلیک سفارشی اعمال کنید.
  • ممکن است ترکیب جلوه سفارشی با جلوه‌های دیگر دشوار باشد

برای جلوگیری از این مشکلات و مقیاس‌پذیری آسان یک کامپوننت سفارشی در سراسر سیستم خود، می‌توانید Indication استفاده کنید. Indication یک جلوه بصری قابل استفاده مجدد است که می‌تواند در سراسر کامپوننت‌های یک برنامه یا سیستم طراحی اعمال شود. Indication به دو بخش تقسیم می‌شود:

  • IndicationNodeFactory : یک factory که نمونه‌های Modifier.Node ایجاد می‌کند که جلوه‌های بصری را برای یک کامپوننت رندر می‌کنند. برای پیاده‌سازی‌های ساده‌تر که در بین کامپوننت‌ها تغییر نمی‌کنند، این می‌تواند یک singleton (شیء) باشد و در کل برنامه مورد استفاده مجدد قرار گیرد.

    این نمونه‌ها می‌توانند دارای وضعیت (stateful) یا بدون وضعیت (stateless) باشند. از آنجایی که برای هر کامپوننت ایجاد می‌شوند، می‌توانند مقادیر را از یک CompositionLocal بازیابی کنند تا نحوه نمایش یا رفتار آنها را درون یک کامپوننت خاص تغییر دهند، مانند هر Modifier.Node دیگر.

  • Modifier.indication : یک اصلاح‌کننده که برای یک کامپوننت، Indication رسم می‌کند. Modifier.clickable و سایر اصلاح‌کننده‌های تعامل سطح بالا، مستقیماً یک پارامتر indication را می‌پذیرند، بنابراین نه تنها Interaction ها را منتشر می‌کنند، بلکه می‌توانند جلوه‌های بصری را نیز برای Interaction هایی که منتشر می‌کنند، رسم کنند. بنابراین، برای موارد ساده، می‌توانید بدون نیاز به Modifier.indication Modifier.clickable استفاده کنید.

جایگزینی اثر با یک 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() لغو کند و با استفاده از همان دستورات ترسیم مانند هر API گرافیکی دیگر در 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 را ایجاد کنید . تنها مسئولیت آن ایجاد یک نمونه گره جدید برای منبع تعامل ارائه شده است. از آنجایی که هیچ پارامتری برای پیکربندی نشانه وجود ندارد، factory می‌تواند یک شیء باشد:

    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!")
}

انیمیشن دکمه‌ای با آیکون سبد خرید مواد غذایی که با فشردن کوچک‌تر می‌شود
شکل ۴. دکمه‌ای که با یک Indication سفارشی ساخته شده است.

ساخت یک Indication پیشرفته با حاشیه متحرک

Indication فقط به جلوه‌های تبدیل، مانند مقیاس‌بندی یک کامپوننت، محدود نمی‌شود. از آنجا که IndicationNodeFactory یک Modifier.Node برمی‌گرداند، می‌توانید هر نوع جلوه‌ای را در بالا یا پایین محتوا، مانند سایر APIهای ترسیم، ترسیم کنید. به عنوان مثال، می‌توانید یک حاشیه متحرک در اطراف کامپوننت و یک پوشش روی کامپوننت هنگام فشرده شدن، ترسیم کنید:

دکمه‌ای با جلوه رنگین‌کمانی فانتزی هنگام فشردن
شکل ۵. یک افکت حاشیه متحرک که با 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 وجود دارد - اگر فشاری در حین یک فشار یا انیمیشن استراحت موجود رخ دهد، انیمیشن قبلی لغو می‌شود و انیمیشن فشار از ابتدا شروع می‌شود. برای پشتیبانی از چندین اثر همزمان (مانند موج‌ها، که در آن یک انیمیشن موج جدید روی موج‌های دیگر رسم می‌شود)، می‌توانید انیمیشن‌ها را در یک لیست ردیابی کنید، به جای اینکه انیمیشن‌های موجود را لغو کرده و انیمیشن‌های جدید را شروع کنید.

{% کلمه به کلمه %} {% فعل کمکی %} {% کلمه به کلمه %} {% فعل کمکی %}