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

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

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

سند اشاره‌ها در 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 هنگام نگه‌داشتن نشانگر روی آن یا برداشتن نشانگر از روی آن استفاده کند.

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

مؤلفه‌های سطح بالا مانند 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 سطح بالا روی بلوک‌های ساختمانی پایه‌ای ساخته می‌شوند که Interactions موردنیاز برای کنترل موج‌ها و سایر جلوه‌های بصری را تولید می‌کنند. کتابخانه پایه اصلاح‌کننده‌های تعامل سطح بالا را ارائه می‌دهد مانند 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 است — این کار مشاهده مقادیر به‌روزشده را آسان می‌کند، زیرا خواندن مقدار وضعیت به‌طور خودکار باعث بازترکیب می‌شود. بااین‌حال، ترکیب‌بندی به‌صورت پیش‌فریم دسته‌بندی می‌شود. This means that if the state changes, and then changes back within the same frame, components observing the state won't see the change.

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

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

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

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

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

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

  • IndicationNodeFactory: کارخانه‌ای که نمونه‌های Modifier.Node ایجاد می‌کند که جلوه‌های دیداری را برای مؤلفه ارائه می‌کند. برای پیاده‌سازی‌های ساده‌تر که در سراسر مؤلفه‌ها تغییر نمی‌کنند، این می‌تواند یک تک‌نمونه (شیء) باشد و در سراسر برنامه دوباره استفاده شود.

    این نمونه‌ها می‌توانند باحالت یا بی‌حالت باشند. ازآنجایی‌که این متغیرها برای هر مؤلفه ایجاد می‌شوند، می‌توانند مقادیر را از CompositionLocal بازیابی کنند تا نحوه ظاهر یا رفتار خود را در یک مؤلفه خاص تغییر دهند، درست مانند هر Modifier.Node دیگری.

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

جایگزین کردن جلوه با Indication

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

کد زیر دکمه‌ای ایجاد می‌کند که با فشار دادن کوچک می‌شود:

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

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

برای تبدیل جلوه مقیاس در قطعه کد بالا به Indication، این مراحل را دنبال کنید:

  1. Modifier.Node را که مسئول اعمال جلوه مقیاس است ایجاد کنید. وقتی گره متصل می‌شود، منبع تعامل را مشاهده می‌کند، مشابه مثال‌های قبلی. تنها تفاوت در اینجا این است که مستقیماً پویانمایی‌ها را راه‌اندازی می‌کند به‌جای اینکه «تعامل‌های» ورودی را به حالت تبدیل کند.

    گره باید DrawModifierNode را پیاده‌سازی کند تا بتواند ContentDrawScope#draw() را لغو کند و جلوه مقیاس را بااستفاده از همان فرمان‌های رسم مانند هر میانای برنامه‌سازی کاربردی گرافیکی دیگر در Compose ارائه دهد.

    فراخوانی drawContent() دردسترس از گیرنده ContentDrawScope، مؤلفه واقعی را که Indication باید روی آن اعمال شود ترسیم می‌کند، بنابراین فقط باید این تابع را در تبدیل مقیاس فراخوانی کنید. مطمئن شوید که پیاده‌سازی‌های Indication شما همیشه در نقطه‌ای drawContent() را فراخوانی می‌کنند؛ درغیراین‌صورت، مؤلفه‌ای که Indication را روی آن اعمال می‌کنید ترسیم نخواهد شد.

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

  2. IndicationNodeFactory را ایجاد کنید. تنها مسئولیت آن ایجاد یک نمونه گره جدید برای منبع تعامل ارائه‌شده است. As there are no parameters to configure the indication, the factory can be an object:

    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 در اینجا بسیار شبیه به مثال قبلی است — فقط یک گره با چند پارامتر ایجاد می‌کند. Since the animated border depends on the shape and the border of the component the Indication is used for, the Indication implementation also requires shape and border width to be provided as parameters:

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