Создание кастомного тултипа Jetpack Compose

от автора

Привет, Хабр! Меня зовут Альберт Ханнанов, я Android-разработчик в команде интеграции рассрочки в приложении Wildberries.

В этой статье мы напишем простенькую реализацию тултипов на Jetpack Compose своими руками.

В мире мобильной разработки удобство и интуитивность интерфейса играют ключевую роль. Одним из способов улучшения пользовательского опыта является предоставление дополнительной информации в нужный момент, и для этого идеально подходят тултипы.

В этой статье мы разберём, как создать гибкую и удобную систему тултипов в Jetpack Compose, используя модифайры и специальный оборачивающий блок. Мы шаг за шагом рассмотрим создание необходимых компонентов, их взаимодействие и методы управления тултипом.

Что это вообще такое?

Тултип — это всплывающая подсказка, которая появляется поверх другого UI под/над конкретным элементом. Помогает улучшить UX.

Как выглядит тултип

Как выглядит тултип

Что мы хотим добиться?

Наш тултип должен уметь:

  1. Показываться под якорным элементом в виде облачка

  2. Скрываться согласно нашей кастомной логике

  3. Добавляться в существующие экраны с минимальными затратами

  4. Не блокировать взаимодействие с остальным UI

Выглядеть должно следующим образом
Как будет выглядеть

Как будет выглядеть

Почему существующие решения нам не подойдут?

Material 3 предоставляет нам Composable виджет TooltipBox:

fun TooltipBox(     positionProvider: PopupPositionProvider,     tooltip: @Composable TooltipScope.() -> Unit,     state: TooltipState,     modifier: Modifier = Modifier,     focusable: Boolean = true,     enableUserInput: Boolean = true,     content: @Composable () -> Unit, )

В целом, использование данного виджета заключается в том, что вместо элемента, под которым мы хотим показать тулип, мы внедряем этот TooltipBox, передаем в аргумент tooltip верстку для тултипа, в аргумент content — элемент, над которым мы хотим показать тултип. Все бы ничего, данный подход не вызывает неудобств по добавлению тултипа на экран, но сильно ограничивает нас в использовании:

  1. Когда показывается тултип, мы не можем взаимодействовать с экраном. Как только мы коснемся какой-то другой области, то тултип скроется, и только вторым касанием мы сможем взаимодействовать с экраном. Это блокирует нам скролл и любое другое взаимодействие с чем бы то ни было, кроме тултипа.

  2. Под капотом тултип отрисовывается с помощью Popup. А этот элемент всегда отображается выше всех остальных элементов на экране. Что делает невозможным то, что, например, тултип будет скрываться под TopBar или под BottomBar.

Каких-то сторонних решений я нашел раз… и… всё. Последний раз эта либа обновлялась 4(!) года назад. К тому же, зачем искать что-то стороннее, если хочется разобраться самому и сделать свой велосипед?

Дисклеймер

В статье будет очень много кода, и в целях избежания еще большего количества кода, мы сделаем решение для показа только одного тултипа в рамках экрана. Это решение не будет работать на ленивых списках, также модифайр напишем через composed, а не Modifier.Node.

Если вам зайдет, то сделаю вторую часть, где вместе поддержим то, что не добрали в рамках этой статьи. Также объяснение каждой важной строчки кода написано комментарием сверху этой строки для большей очевидности.

Важное замечание: реализация, представленная в этой статье, не является единственно правильной. Это просто один из возможных вариантов, который первый пришел мне на ум я посчитал оптимальным для своих задач.

ХардКод

Итак, еще раз и более детально, что должен уметь делать наш тултип:

  1. Показываться под якорным элементом

  2. Быть изначально видимым

  3. Исчезать по нажатию на него и по нажатию на какую-нибудь кнопку (считаем это нашей кастомной логикой)

  4. Появляться по нажатию на какую-нибудь кнопку

  5. Без сложностей добавляться в существующие экраны

  6. Не блокировать взаимодействие с остальным UI

Вся задумка заключается в том, что у нас будет блок-обертка, внутри которого мы у любого элемента сможем отрисовывать тултип с помощью простого добавления к нему модифайра.

Давайте договоримся, что оборачивающий блок далее называем блок-обертка, а элемент, под которым мы хотим показать тултип — якорный элемент.

Всего нам понадобится создать 4 файла:

  1. Tooltip.kt — здесь будет лежать Composable верстка тултипа.

  2. TooltipWrapper.kt — блок-обертка.

  3. TooltipModifier.kt — модифайр для добавления тултипа.

  4. TooltipState.kt — сущность, которая будет хранить всю информацию о тултипе и управлять его характеристиками.

Первым делом давайте определимся с TooltipState, а именно — какие поля нам нужны, чтобы это все работало.

@Composable fun rememberTooltipState(): TooltipState = remember { TooltipState() }  @Stable class TooltipState internal constructor() {     // параметры блока-обертки     // длина оборачивающего блока. Нужна для того, чтобы определить максимальную ширину тултипа    internal var tooltipWrapperWidth: Int by mutableIntStateOf(0)    // информация о лейауте для оборачивающего блока. Понадобится нам, когда будем считать смещение тултипа    private var tooltipWrapperLayoutCoordinated: LayoutCoordinates? = null         // параметры тултипа       // данные для отображения в тултипе: заголовок, сабтайтл    internal var data: TooltipData? by mutableStateOf(null)     // видим ли тултип в данный момент    internal var isVisible: Boolean by mutableStateOf(false)     // итоговое смещение тултипа    internal var tooltipOffset: IntOffset by mutableStateOf(IntOffset.Zero)    // информация о лейауте тултипа. Понадобится нам, когда будем считать смещение тултипа    private var tooltipLayoutCoordinates: LayoutCoordinates? = null     // смещение пипочки тултипа    private var triangleXOffset: Int = 0         // параметры якорного блока     // информация о лейауте якорного элемента. Понадобится нам, когда будем считать смещение     internal var anchorLayoutCoordinates: LayoutCoordinates? by mutableStateOf(null) тултипа }  @Stable data class TooltipData(    val title: String?,    val subtitle: String, )

Теперь перейдем в написанию блока-обертки:

@Composable fun TooltipWrapper(    modifier: Modifier = Modifier,    content: @Composable BoxScope.(tooltipState: TooltipState) -> Unit, ) {    val tooltipState = rememberTooltipState()     Box(        modifier = modifier            // Скрываем элементы, которые выходят за границы Box            .clipToBounds()            // отправляем информацию о лейауте в стейт            .onGloballyPositioned { tooltipState.changeTooltipWrapperLayoutCoordinates(it) },    ) {        content(tooltipState)         Tooltip(state = tooltipState)    } }  

Перейдем к написанию модифайра. В созданном модифайре мы должны уметь отправлять данные для тултипа (заголовок, подпись) и информацию об якорном элементе.

@Stable private fun Modifier.tooltipInternal(    state: TooltipState,    subtitle: String,    @DrawableRes dismissIconResource: Int? = null,    title: String? = null,    initialVisibility: Boolean = false, ): Modifier = composed {    LaunchedEffect(Unit) {        state.initialize(            data = TooltipData(                title = title,                subtitle = subtitle,                dismissIconResource = dismissIconResource,            ),            initialVisibility = initialVisibility,        )    }     this.onGloballyPositioned {        state.changeAnchorLayoutCoordinates(layoutCoordinates = it)    } }

С помощью LaunchedEffect вызываем init единожды и передаем данные в стейт. Также реагируем на изменение позиции якорного элемента c помощью onGloballyPositioned. Важное замечание: так не будет работать с ленивыми списками. В случае с ними LaunchedEffect будет вызываться довольно часто из-за того, что виджет будет открепляться и прикрепляться к верстки в процессе скролла.

Теперь перейдем к верстке самого тултипа:

@Composable internal fun Tooltip(state: TooltipState) {    val data = state.data     val animatedTriangleVisibility by animateFloatAsState(        targetValue = if (state.isVisible) 1f else 0f,        animationSpec = tween(300)    )     // Если тултип не виден или для него нет данных или якорного элемента, то ничего не рисуем    if (animatedTriangleVisibility == 0f || data == null || state.anchorLayoutCoordinates == null) return      // высчитываем максимальную ширину тултипа. В данном случае будет 70% от ширины блока для тултипов    val maxTooltipWidth = LocalDensity.current.run {       (state.tooltipWrapperWidth * .7f).toDp()    }     Column(        modifier = Modifier            .widthIn(max = maxTooltipWidth)            // смещаем тултип на расчитанное в стейте значение            .offset { state.tooltipOffset }            // передаем информацию о лейауте тултипа            .onGloballyPositioned { state.changeTooltipLayoutCoordinates(it) }            // рисуем пипочку сверху тултипа            .drawBehind {                val path = state.getTrianglePath()                drawPath(                    path = path,                    alpha = animatedTriangleVisibility,                    color = Color(0xFF18181B),                )            }            // управляем прозрачностью тултипа для плавных действий над ним            .graphicsLayer { alpha = animatedTriangleVisibility }            // скрываем тултип по нажатию на него            .clickable(onClick = state::hide)             .clip(RoundedCornerShape(16.dp))            .background(Color(0xFF18181B))            .padding(vertical = 12.dp, horizontal = 16.dp),    ) {        Row {            if (data.title != null) {                Text(                    text = data.title,                    fontSize = 20.sp,                    fontWeight = FontWeight.Bold,                    color = MaterialTheme.colorScheme.onPrimary                )            }             if (data.dismissIconResource != null) {                Icon(                    modifier = Modifier.clickable(onClick = state::hide),                    painter = painterResource(data.dismissIconResource),                    contentDescription = "",                    tint = Color.White,                )            }        }         Text(            text = data.subtitle,            color = MaterialTheme.colorScheme.onPrimary        )    } }

Самое интересное только впереди. Теперь нам надо научиться всем этим управлять. Перейдем к реализации методов стейта. Первым делом напишем публичные методы стейта. 

@Stable class TooltipState internal constructor() {    //...    fun hide() {        isVisible = false    }     fun show() {        isVisible = true    } }

Данные методы просто меняют поле isVisible, на который подписывается UI и управляет видимостью тултипа.

Не забудем про метод инициализации тултипа.

@Stable class TooltipState internal constructor() {    //...     internal fun initialize(        data: TooltipData,        initialVisibility: Boolean,    ) {        this.data = data         if (initialVisibility) {            show()        }     } }

Данный метод принимает на вход класс, который содержит данные для тултипа, и изначальную его видимость.

Теперь напишем методы, которые будут вызываться при изменении параметром всех зависимых лейаутов.

@Stable class TooltipState internal constructor() {    //...     internal fun changeTooltipWrapperLayoutCoordinates(layoutCoordinates: LayoutCoordinates) {       tooltipWrapperLayoutCoordinated = layoutCoordinates       tooltipWrapperWidth = layoutCoordinates.size.width        syncTooltipOffset()    }     internal fun changeAnchorLayoutCoordinates(layoutCoordinates: LayoutCoordinates) {       anchorLayoutCoordinates = layoutCoordinates        syncTooltipOffset()    }     internal fun changeTooltipLayoutCoordinates(layoutCoordinates: LayoutCoordinates) {       tooltipLayoutCoordinates = layoutCoordinates        syncTooltipOffset()    } }

Каждый из этих методов будет вызываться в момент изменении одного из лейаутов:

  1. Блока-обертки TooltipWrapper (changeTooltipWrapperLayoutCoordinates)

  2. Якорного элемента (changeAnchorLayoutCoordinates)

  3. Самого тултипа (changeTooltipLayoutCoordinates)

При изменении любого из них будет происходить пересчет позиции тултипа — метод syncTooltipOffset(). Перейдем к его реализации:

@Stable class TooltipState internal constructor() {    //...     private fun syncTooltipOffset() {       val tooltipWrapperLC = tooltipWrapperLayoutCoordinated ?: return        // смещение тултипа посередине якорного       val anchorWidgetDisplacement = anchorLayoutCoordinates?.let { anchorLC ->            // позиция опорного элемента в координатах блока TooltipWrapper. Объяснение ниже           val parent = tooltipWrapperLC.localPositionOf(anchorLC, Offset.Zero)           val size = it.size            val x = parent.x + size.width / 2f           val y = parent.y + size.height           IntOffset(               x = x.toInt(),               y = y.toInt() + TRIANGLE_HEIGHT.toInt(),           )       } ?: IntOffset.Zero        // собственное смещение тултипа на половину ширины тултипа       val properDisplacement = tooltipLayoutCoordinates?.let {           IntOffset(it.size.center.x, 0)       } ?: IntOffset.Zero        val tooltipWidth = tooltipLayoutCoordinates?.size?.width ?: 0       // левая верхняя точка тултипа       val newTopLeftOffset = anchorWidgetDisplacement - properDisplacement       // правая верхняя точка тултипа       val newTopRightOffset = newTopLeftOffset + IntOffset(tooltipSize.width, 0)         // Нужно учесть кейсы, если наш тултип вышел за пределы блока и смещать тултип таким образом, чтобы он помещался        val resultDependWindowBoundaries = when {           // кейс, когда тултип выходит за левую границу           newTopLeftOffset.x < 0 -> {               triangleXOffset = newTopLeftOffset.x               IntOffset(0, newTopLeftOffset.y)           }             // Кейс, когда тултип выходит за правую границу           newTopRightOffset.x > tooltipWrapperWidth -> {               triangleXOffset = newTopRightOffset.x - tooltipWrapperWidth               IntOffset(tooltipWrapperWidth - tooltipSize.width, newTopRightOffset.y)           }             // Кейс, когда тултип не выходит за границы           else -> {               triangleXOffset = 0               newTopLeftOffset           }       }        tooltipOffset = resultDependWindowBoundaries    } }

Давайте рассмотрим подробнее строчку номер 11 tooltipWrapperLC.localPositionOf(anchorLC, Offset.Zero)

Всего у нас может быть 2 случая:

  1. Блок-обертка — корневой элемент на экране

  2. Блок-обертка — не корневой элемент на экране

В первом случае мы можем говорить о том, что обертка занимает полностью весь экран, и мы можем считать координаты любого элемента внутри, используя систему отсчета, которая опирается на сам экран.

Во втором случае мы не можем таким образом рассчитывать позицию элементов внутри блока-обертки, потому что, например, на одном уровне иерархии с оберткой может быть топбар или же боттомбар. В этом случае мы должны рассчитывать позицию элемента опираясь на систему отчета, которая относится к самому блоку обертки. Здесь мы так и делаем. У нас есть anchorLC— информация о лейауте якорного элемента, tooltipWrapperLC — информация о лейауте блока обертки. Мы точно знаем, что якорный элемент находится внутри нашего блока-обертки, поэтому мы можем перейти с системы отсчета от якорного элемента к системе отсчета блока-обертки и вычислить позицию якорного элемента относительно блока обертки. С помощью метода localPositionOf мы так и делаем.

Теперь напишем метод, который отвечает за получение координат пипочки для тултипа:

@Stable class TooltipState internal constructor() {    //...     internal fun getTrianglePath(): Path = Path().apply {       val triangleHeight = TRIANGLE_HEIGHT       val triangleWidth = TRIANGLE_WIDTH        val tooltipLayoutCoordinates = tooltipLayoutCoordinates ?: return@apply        val widgetSize = tooltipLayoutCoordinates.size        val offsetToCenterX = widgetSize.center.x.toFloat() + triangleXOffset       val offsetToCenterY = 0f        moveTo(offsetToCenterX, offsetToCenterY - triangleHeight)       lineTo(offsetToCenterX - triangleWidth / 2f, offsetToCenterY)       lineTo(offsetToCenterX + triangleWidth / 2f, offsetToCenterY)       close()    }     private companion object {       const val TRIANGLE_WIDTH = 40f       const val TRIANGLE_HEIGHT = 40f    } }

Поздравляю всех тех, кто дотерпел до этого момента и смог осознать все, что здесь происходит. Теперь у нас все должно работать.

Пример для проверки результата
@Composable fun TooltipExampleScreen() {     Column {         Box(             modifier = Modifier                 .fillMaxWidth()                 .height(97.dp)                 .shadow(10.dp)                 .background(Color.White),             contentAlignment = Alignment.Center,         ) {             Text("Top Bar")         }          TooltipWrapper(             modifier = Modifier.fillMaxWidth(),         ) { tooltipState: TooltipState ->             Column(                 modifier = Modifier                     .fillMaxWidth()                     .verticalScroll(rememberScrollState())             ) {                 Button(onClick = tooltipState::show) {                     Text("Show tooltips")                 }                  (0..20).forEach {                     ScreenItem(                         itemNumber = it,                         tooltipState = tooltipState,                     )                 }             }         }     } }  @Composable private fun ScreenItem(     tooltipState: TooltipState,     itemNumber: Int, ) {     Column(         modifier = Modifier             .fillMaxWidth()             .height(120.dp),     ) {         Row(             modifier = Modifier.weight(1f),             verticalAlignment = Alignment.CenterVertically,         ) {             Spacer(Modifier.weight(1f))              if (itemNumber == 1) {                 Text(                     modifier = Modifier.tooltip(                         state = tooltipState,                         title = "Some title",                         subtitle = "Some tooltip content",                         initialVisibility = true,                     ),                     text = "item with tooltip $itemNumber"                 )             } else {                 Text(text = "item $itemNumber")             }              Spacer(Modifier.weight(1f))         }          Spacer(modifier = Modifier.fillMaxWidth().height(1.dp).background(Color.Black))     } }

Теперь о моментах, которых мы не коснулись

Во-первых, что касается возможности добавления нескольких тултипов на экран. Для решения этой задачи требуется просто текущий стейт размножить. В TooltipsState создать поле типа SnapshotStateMap<String, TooltipState>. Внутри этой структуры будут лежать все тултипы, которые есть на экране. Ключом можно сделать, например UUID. Методы TooltipsState также нужно будет адаптировать к работе с этой мапой.

Во-вторых, что более сложно, — ленивые списки. Как известно, айтемы ленивых списков переиспользуются, чтобы не вываливать сразу все данные списка на экран. В момент, когда какой-то элемент списка уходит за область видимости, он открепляется от композиции и далее может прикрепиться для нового элемента, который только собирается появиться на экране. Нам это все нужно учитывать — не рисовать тултип для айтема, который открепился от верстки и корректно различать тултипы между собой, например, по ключу. Но это уже тема для будущей статьи.

Заключение

Поздравляю! Мы создали свой велосипед разобрали процесс создания кастомного тултипа в Jetpack Compose — начиная со структуры данных и заканчивая реализацией методов управления позиционированием и отображением. 

Теперь, добавив всего один модифайр к любому элементу, можно быстро предоставить пользователям полезные подсказки. Надеюсь, этот разбор поможет вам в создании более удобного и функционального интерфейса! Также буду рад обратной связи от вас.


ссылка на оригинал статьи https://habr.com/ru/articles/905994/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *