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

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

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

مستندات 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()، «نوشتن» همچنین collectIsFocusedAsState()، collectIsDraggedAsState()، و collectIsHoveredAsState() را ارائه می‌دهد. این روش‌ها درواقع روش‌های راحتی هستند که روی میاناهای برنامه‌سازی کاربردی InteractionSource سطح پایین‌تر ساخته شده‌اند. در برخی موارد، ممکن است بخواهید مستقیماً از آن توابع سطح پایین‌تر استفاده کنید.

برای مثال، فرض کنید باید بدانید که آیا دکمه‌ای فشار داده می‌شود یا خیر، و همچنین آیا کشیده می‌شود یا خیر. اگر از هر دو collectIsPressedAsState() و collectIsDraggedAsState() استفاده کنید، «نوشتن» کارهای تکراری زیادی انجام می‌دهد و هیچ تضمینی وجود ندارد که همه تعاملات را به‌ترتیب درست دریافت کنید. برای موقعیت‌هایی مانند این، ممکن است بخواهید مستقیماً با 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 هنگام قرار گرفتن یا برداشته شدن اشاره‌گر استفاده کند.

ساختن عناصری که مصرف و تولید می‌کنند

عناصر سطح بالا مثل 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های موردنیاز برای کنترل موج‌ها و دیگر جلوه‌های بصری را تولید می‌کنند. کتابخانه پایه اصلاح‌کننده‌های تعامل سطح بالا مثل 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")
}

اگر فشاری در همان قاب شروع و تمام شود، نوشتار هرگز به‌صورت "فشار داده شد!" نمایش داده نخواهد شد. در اکثر موارد، این یک مشکل نیست — نمایش یک جلوه بصری برای چنین مدت زمان کوتاهی منجر به سوسو زدن می‌شود و برای کاربر بسیار قابل توجه نخواهد بود. برای برخی موارد، مانند نمایش اثر موج یا پویانمایی مشابه، ممکن است بخواهید اثر را حداقل برای مدت زمان معینی نمایش دهید، به‌جای اینکه اگر دکمه دیگر فشار داده نشد، بلافاصله متوقف شود. برای انجام این کار، می‌توانید به‌جای نوشتن در وضعیت، مستقیماً پویانمایی‌ها را از داخل لامبدای جمع‌آوری شروع و متوقف کنید. نمونه‌ای از این الگو در بخش ساختن 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 Material موجود ساخته شده است، به تعاملات کاربر به همه روش‌های معمول واکنش نشان می‌دهد. وقتی کاربر دکمه را فشار می‌دهد، کدورت آن کمی تغییر می‌کند، درست مثل یک 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() را ملغی کند و بااستفاده از همان دستورات رسم که در هر 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. تنها مسئولیت آن ایجاد یک نمونه گره جدید برای منبع تعامل ارائه‌شده است. ازآنجایی‌که هیچ پارامتری برای پیکربندی نشانگر وجود ندارد، کارخانه می‌تواند یک شیء باشد:

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

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