Как стать автором
Обновить
106.37
WBTECH
Технологический фундамент Wildberries

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

Уровень сложностиСредний
Время на прочтение11 мин
Количество просмотров1.7K

Привет, Хабр! Меня зовут Альберт Ханнанов, я 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 — начиная со структуры данных и заканчивая реализацией методов управления позиционированием и отображением. 

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

Теги:
Хабы:
+10
Комментарии4

Публикации

Информация

Сайт
www.wildberries.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия