Привет, Хабр! Меня зовут Альберт Ханнанов, я 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 — начиная со структуры данных и заканчивая реализацией методов управления позиционированием и отображением.
Теперь, добавив всего один модифайр к любому элементу, можно быстро предоставить пользователям полезные подсказки. Надеюсь, этот разбор поможет вам в создании более удобного и функционального интерфейса! Также буду рад обратной связи от вас.
Комментарии (4)
renat_f14
27.05.2025 16:32Просто невероятная статья — спасибо большое, Альберт!
Честно, я был вдохновлён после прочтения. Сразу же решил всё повторить у себя в проекте — и получилось! Тултип работает плавно, адаптируется под разные сценарии и вообще выглядит так, будто всегда был частью UI. Такое ощущение, что нашёл золотую жилу — реально стоящая реализация, которую можно подхватить и использовать без лишнего шаманства.
Отдельное спасибо за подробное объяснение и структуру статьи — всё супер понятно, читается на одном дыхании.
Из мелких моментов, на которые обратил внимание:
— Пока тултип поддерживает только одно отображение за раз — для сложных интерфейсов хотелось бы иметь возможность показывать несколько;
— Вариант с LazyColumn пока не работает — очень надеюсь, ты добавишь это в следующих версиях;
— И возможно, стоит рассмотреть переход на Modifier.Node — будет чуть современнее и потенциально эффективнее.А в целом — просто топ. Огромное спасибо за такой материал, очень полезный и вдохновляющий! Жду продолжения!
glider_skobb
27.05.2025 16:32Не хочу сильно душнить, но использование composed модификатора больше не рекомендуется использовать - в пользу Composable фабрик.
Snow_Volf
Прекрасная статья! Вы не поверите, но буквально сегодня столкнулся с проблемой реализации кастомных тултипов. И да, такое ощущение, что на вьюхах библиотек по тултипам больше в разы (если не в десятки раз)
Но все же есть некоторые неточности в представленном коде
У вас в примере ниже используется
Modifier.tooltip()
либо где-то пропала часть кода, либо забыли переименовать функцию (а также сделать ее публичной). Что собственно я и сделал.Пропущено свойство
val dismissIconResource: Int?
. Из-за этого код не компилируется, так как это свойство используется далее по коду.tooltipWidth
остается неиспользованным, при этомtooltipSize.width
, который передается в третьей строке - отсутствует. Хоть это и простая замена переменной, но опять же, код без правок не скомпилируется.Вместо
it.size
, вы имели в видуanchorLC.size
?Ну а в целом, очень интересно и наглядно! Отдельное спасибо за готовый пример экрана для теста, а так же за отсутствие привязок к цветам и ресурсам проекта. С нетерпением жду продолжения уже с ленивыми списками!