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

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

شکل 1. دکمه هایی که همیشه فعال به نظر می رسند، بدون ریپل فشار دادن.
شکل 2. دکمه هایی با امواج فشاری که وضعیت فعال آنها را بر این اساس منعکس می کند.

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

تعاملات

در بسیاری از موارد، لازم نیست بدانید که مؤلفه Compose چگونه تعاملات کاربر را تفسیر می کند. برای مثال، Button به Modifier.clickable متکی است تا بفهمد آیا کاربر روی دکمه کلیک کرده است یا خیر. اگر یک دکمه معمولی را به برنامه خود اضافه می کنید، می توانید کد onClick دکمه را تعریف کنید و Modifier.clickable آن کد را در صورت لزوم اجرا می کند. این بدان معنی است که شما نیازی به دانستن اینکه آیا کاربر روی صفحه ضربه زده است یا دکمه را با صفحه کلید انتخاب کرده است. Modifier.clickable متوجه می شود که کاربر یک کلیک انجام داده است و با اجرای کد onClick شما پاسخ می دهد.

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

هنگامی که یک کاربر با یک مؤلفه UI تعامل می کند، سیستم رفتار آنها را با ایجاد تعدادی رویداد Interaction نشان می دهد. برای مثال، اگر کاربر دکمه‌ای را لمس کند، دکمه PressInteraction.Press را ایجاد می‌کند. اگر کاربر انگشت خود را در داخل دکمه بلند کند، یک PressInteraction.Release ایجاد می‌کند که به دکمه اطلاع می‌دهد که کلیک تمام شده است. از سوی دیگر، اگر کاربر انگشت خود را به بیرون از دکمه بکشد، سپس انگشت خود را بلند کند، دکمه PressInteraction.Cancel ایجاد می کند، تا نشان دهد که فشار روی دکمه لغو شده است، نه کامل شده است.

این تعاملات بدون نظر است. یعنی این رویدادهای تعامل سطح پایین قصد تفسیر معنای اقدامات کاربر یا توالی آنها را ندارند. آنها همچنین تفسیر نمی کنند که کدام اقدامات کاربر ممکن است بر سایر اقدامات اولویت داشته باشد.

این فعل و انفعالات عموماً به صورت جفت و با یک شروع و یک پایان انجام می شود. تعامل دوم شامل ارجاع به اولی است. برای مثال، اگر کاربر دکمه‌ای را لمس کند و سپس انگشت خود را بلند کند، لمس یک PressInteraction.Release PressInteraction.Press ایجاد می‌کند. Release دارای یک ویژگی press است که PressInteraction.Press اولیه را مشخص می کند.

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

حالت تعامل

ممکن است بخواهید عملکرد داخلی اجزای خود را با ردیابی تعاملات خود گسترش دهید. به عنوان مثال، شاید بخواهید یک دکمه با فشار دادن آن تغییر رنگ دهد. ساده ترین راه برای ردیابی تعاملات مشاهده وضعیت تعامل مناسب است. InteractionSource تعدادی روش ارائه می دهد که وضعیت های مختلف تعامل را به عنوان حالت نشان می دهد. به عنوان مثال، اگر می خواهید ببینید که آیا دکمه خاصی فشرده شده است یا خیر، می توانید متد InteractionSource.collectIsPressedAsState() آن را فراخوانی کنید:

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

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

علاوه بر collectIsPressedAsState() ، Compose همچنین collectIsFocusedAsState() ، collectIsDraggedAsState() و collectIsHoveredAsState() را ارائه می دهد. این روش ها در واقع روش های راحتی هستند که بر روی API های سطح پایین تر InteractionSource ساخته شده اند. در برخی موارد، ممکن است بخواهید مستقیماً از آن توابع سطح پایین استفاده کنید.

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

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

Consume and Emit Interaction

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

اصلاح‌کننده‌ها و مؤلفه‌ها می‌توانند Interactions را مصرف کنند، منتشر کنند، یا مصرف و منتشر کنند. بخش‌های زیر نحوه مصرف و انتشار فعل و انفعالات از اصلاح‌کننده‌ها و اجزاء را شرح می‌دهند.

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

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

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

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

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

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

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

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

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

اجزای سطح بالا مانند Button مواد هم به عنوان تولید کننده و هم به عنوان مصرف کننده عمل می کنند. آن‌ها رویدادهای ورودی و تمرکز را مدیریت می‌کنند و همچنین ظاهر خود را در پاسخ به این رویدادها تغییر می‌دهند، مانند نشان دادن یک موج یا متحرک کردن ارتفاع آن‌ها. در نتیجه، آنها مستقیماً MutableInteractionSource به عنوان یک پارامتر در معرض نمایش می گذارند، به طوری که شما می توانید نمونه به خاطر سپرده شده خود را ارائه دهید:

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

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

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

این اجازه می دهد تا MutableInteractionSource از کامپوننت خارج کرده و تمام Interaction تولید شده توسط کامپوننت را مشاهده کنید. می‌توانید از این برای کنترل ظاهر آن مؤلفه یا هر مؤلفه دیگری در رابط کاربری خود استفاده کنید.

اگر در حال ساخت مؤلفه‌های سطح بالا تعاملی خود هستید، توصیه می‌کنیم MutableInteractionSource به‌عنوان یک پارامتر از این طریق در معرض نمایش قرار دهید . علاوه بر پیروی از بهترین شیوه‌های بالا بردن حالت، خواندن و کنترل وضعیت بصری یک جزء را به همان شیوه‌ای که هر نوع حالت دیگری (مانند حالت فعال) قابل خواندن و کنترل است، آسان می‌کند.

Compose از رویکرد معماری لایه‌ای پیروی می‌کند، بنابراین اجزای متریال سطح بالا بر روی بلوک‌های ساختمانی پایه ساخته می‌شوند که Interaction را که برای کنترل موج‌ها و سایر جلوه‌های بصری نیاز دارند، ایجاد می‌کنند. کتابخانه پایه اصلاح کننده های تعامل سطح بالایی مانند Modifier.hoverable ، Modifier.focusable و Modifier.draggable را ارائه می دهد.

برای ساختن کامپوننتی که به رویدادهای شناور پاسخ می دهد، می توانید به سادگی از Modifier.hoverable استفاده کنید و یک MutableInteractionSource را به عنوان پارامتر ارسال کنید. هر زمان که کامپوننت شناور می شود، HoverInteraction s را منتشر می کند، و می توانید از آن برای تغییر نحوه ظاهر شدن کامپوننت استفاده کنید.

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

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

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

مثال: ساخت مؤلفه با مدیریت تعامل سفارشی

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

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

برای انجام این کار، یک ترکیب سفارشی بر اساس Button بسازید و از آن بخواهید که یک پارامتر icon اضافی برای ترسیم نماد (در این مورد، یک سبد خرید) مصرف کند. شما collectIsPressedAsState() را فراخوانی می کنید تا ردیابی کنید که آیا کاربر روی دکمه شناور است یا خیر. هنگامی که آنها هستند، شما نماد را اضافه کنید. در اینجا کد به نظر می رسد:

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

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

و در اینجا به نظر می رسد که استفاده از آن composable جدید چگونه است:

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

از آنجایی که این PressIconButton جدید بر روی Button مواد موجود ساخته شده است، به تمام روش های معمول به تعاملات کاربر واکنش نشان می دهد. هنگامی که کاربر دکمه را فشار می دهد، کمی شفافیت آن را تغییر می دهد، درست مانند یک Button معمولی Material.

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

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

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

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

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

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

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

  • Modifier.indication : اصلاح کننده ای که Indication برای یک جزء ترسیم می کند. Modifier.clickable و دیگر اصلاح‌کننده‌های تعامل سطح بالا مستقیماً یک پارامتر نشان‌دهنده را می‌پذیرند، بنابراین نه تنها Interaction را منتشر می‌کنند، بلکه می‌توانند جلوه‌های بصری را برای Interaction هایی که منتشر می‌کنند نیز ترسیم کنند. بنابراین، برای موارد ساده، می‌توانید بدون نیاز به Modifier.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 را ایجاد کنید که مسئول اعمال افکت مقیاس است . هنگام اتصال، گره منبع تعامل را مشاهده می کند، مشابه نمونه های قبلی. تنها تفاوت در اینجا این است که مستقیماً انیمیشن ها را به جای تبدیل Interaction های ورودی به حالت راه اندازی می کند.

    گره باید 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!")
}

انیمیشنی از یک دکمه با نماد سبد خرید که با فشار دادن کوچکتر می شود
شکل 4. یک دکمه ساخته شده با یک Indication سفارشی.

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

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

دکمه ای با افکت رنگین کمان فانتزی در مطبوعات
شکل 5. یک افکت حاشیه متحرک که با Indication ترسیم شده است.

پیاده سازی Indication در اینجا بسیار شبیه به مثال قبلی است - فقط یک گره با برخی پارامترها ایجاد می کند. از آنجایی که حاشیه متحرک به شکل و مرز مؤلفه ای که Indication برای آن استفاده می شود بستگی دارد، پیاده سازی Indication همچنین مستلزم ارائه شکل و عرض حاشیه به عنوان پارامتر است:

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

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

پیاده سازی Modifier.Node نیز از نظر مفهومی یکسان است، حتی اگر کد ترسیم پیچیده تر باشد. مانند قبل، InteractionSource هنگام پیوست مشاهده می‌کند، انیمیشن‌ها را راه‌اندازی می‌کند و DrawModifierNode برای ترسیم افکت در بالای محتوا پیاده‌سازی می‌کند:

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

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

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

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

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

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

        drawContent()

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

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

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

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

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

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

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

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

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

{% کلمه به کلمه %} {% آخر کلمه %} {% کلمه به کلمه %} {% آخر کلمه %}