Привет, Хабр! Меня зовут Альберт Ханнанов, я Android-разработчик в команде интеграции рассрочки в приложении Wildberries.
В этой статье мы напишем простенькую реализацию тултипов на Jetpack Compose своими руками.
В мире мобильной разработки удобство и интуитивность интерфейса играют ключевую роль. Одним из способов улучшения пользовательского опыта является предоставление дополнительной информации в нужный момент, и для этого идеально подходят тултипы.
В этой статье мы разберём, как создать гибкую и удобную систему тултипов в Jetpack Compose, используя модифайры и специальный оборачивающий блок. Мы шаг за шагом рассмотрим создание необходимых компонентов, их взаимодействие и методы управления тултипом.
Что это вообще такое?
Тултип — это всплывающая подсказка, которая появляется поверх другого UI под/над конкретным элементом. Помогает улучшить UX.

Что мы хотим добиться?
Наш тултип должен уметь:
Показываться под якорным элементом в виде облачка
Скрываться согласно нашей кастомной логике
Добавляться в существующие экраны с минимальными затратами
Не блокировать взаимодействие с остальным 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 — элемент, над которым мы хотим показать тултип. Все бы ничего, данный подход не вызывает неудобств по добавлению тултипа на экран, но сильно ограничивает нас в использовании:
Когда показывается тултип, мы не можем взаимодействовать с экраном. Как только мы коснемся какой-то другой области, то тултип скроется, и только вторым касанием мы сможем взаимодействовать с экраном. Это блокирует нам скролл и любое другое взаимодействие с чем бы то ни было, кроме тултипа.
Под капотом тултип отрисовывается с помощью Popup. А этот элемент всегда отображается выше всех остальных элементов на экране. Что делает невозможным то, что, например, тултип будет скрываться под TopBar или под BottomBar.
Каких-то сторонних решений я нашел раз… и… всё. Последний раз эта либа обновлялась 4(!) года назад. К тому же, зачем искать что-то стороннее, если хочется разобраться самому и сделать свой велосипед?
Дисклеймер
В статье будет очень много кода, и в целях избежания еще большего количества кода, мы сделаем решение для показа только одного тултипа в рамках экрана. Это решение не будет работать на ленивых списках, также модифайр напишем через composed, а не Modifier.Node.
Если вам зайдет, то сделаю вторую часть, где вместе поддержим то, что не добрали в рамках этой статьи. Также объяснение каждой важной строчки кода написано комментарием сверху этой строки для большей очевидности.
Важное замечание: реализация, представленная в этой статье, не является единственно правильной. Это просто один из возможных вариантов, который первый пришел мне на ум я посчитал оптимальным для своих задач.
ХардКод
Итак, еще раз и более детально, что должен уметь делать наш тултип:
Показываться под якорным элементом
Быть изначально видимым
Исчезать по нажатию на него и по нажатию на какую-нибудь кнопку (считаем это нашей кастомной логикой)
Появляться по нажатию на какую-нибудь кнопку
Без сложностей добавляться в существующие экраны
Не блокировать взаимодействие с остальным UI
Вся задумка заключается в том, что у нас будет блок-обертка, внутри которого мы у любого элемента сможем отрисовывать тултип с помощью простого добавления к нему модифайра.
Давайте договоримся, что оборачивающий блок далее называем блок-обертка, а элемент, под которым мы хотим показать тултип — якорный элемент.
Всего нам понадобится создать 4 файла:
Tooltip.kt— здесь будет лежать Composable верстка тултипа.TooltipWrapper.kt— блок-обертка.TooltipModifier.kt— модифайр для добавления тултипа.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() } }
Каждый из этих методов будет вызываться в момент изменении одного из лейаутов:
Блока-обертки TooltipWrapper (
changeTooltipWrapperLayoutCoordinates)Якорного элемента (
changeAnchorLayoutCoordinates)Самого тултипа (
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 случая:
Блок-обертка — корневой элемент на экране
Блок-обертка — не корневой элемент на экране
В первом случае мы можем говорить о том, что обертка занимает полностью весь экран, и мы можем считать координаты любого элемента внутри, используя систему отсчета, которая опирается на сам экран.
Во втором случае мы не можем таким образом рассчитывать позицию элементов внутри блока-обертки, потому что, например, на одном уровне иерархии с оберткой может быть топбар или же боттомбар. В этом случае мы должны рассчитывать позицию элемента опираясь на систему отчета, которая относится к самому блоку обертки. Здесь мы так и делаем. У нас есть 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 — начиная со структуры данных и заканчивая реализацией методов управления позиционированием и отображением.
Теперь, добавив всего один модифайр к любому элементу, можно быстро предоставить пользователям полезные подсказки. Надеюсь, этот разбор поможет вам в создании более удобного и функционального интерфейса! Также буду рад обратной связи от вас.
