
В постоянно развивающейся сфере разработки приложений создание интуитивно понятных и удобных интерфейсов имеет первостепенное значение для успеха любого проекта.
Одним из мощных инструментов в арсенале разработчика для улучшения пользовательского опыта являются подсказки. Эти ненавязчивые, но информативные элементы могут оказать неоценимую помощь пользователям, позволяя разобраться в технических нюансах приложения, освоить и использовать все его возможности.
Недавно Android-команда Public потратила немало времени и усилий на редизайн экрана Портфеля (Portfolio) — ключевого аспекта приложения компании и пользовательского опыта (Public.com — сервис, позволяющий создавать инвестиционные портфели и инвестировать в криптовалюту и акции).
При внедрении подобных изменений крайне важно сохранить ощущение привычности для пользователей. Это облегчает их адаптацию к новым возможностям. Компонент пользовательского интерфейса с подсказками играет ключевую роль в достижении этой цели.
Подсказки в Jetpack Compose
Android-команда Public использует исключительно Jetpack Compose для написания кода пользовательского интерфейса. Следовательно, компонент пользовательского интерфейса с подсказками также основан здесь на Compose.
Хотя в самом фреймворке Compose нет встроенного компонента подсказок, сообщество Android-разработчиков предлагает несколько альтернативных вариантов. Кроме того, в недавнем выпуске v1.1 Material Design 3 для Compose появились новые возможности.
Несмотря на разнообразие доступных вариантов, Android-инженеры Public постоянно сталкивались с одной и той же проблемой: все эти варианты требовали обертывания компонентов в родительский элемент. Такой подход часто встречается в декларативных API пользовательского интерфейса, однако ему не хватает гибкости. К тому же, он может стать довольно громоздким, особенно при разработке экранов с несколькими подсказками, как в случае с сервисом Public.
Идеальным выходом из положения было бы решение на основе Modifier. Оно позволило бы просто применить Modifier к любому компоненту и без усилий отобразить вокруг него настраиваемую подсказку.
Modifier приходит на помощь
Android-команде Public удалось разработать API, который идеально соответствует потребностям сервиса:
@Composable
fun Modifier.tooltip(
title: String? = null,
text: String,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
arrowAlignment: Alignment = Alignment.BottomCenter,
textAlign: TextAlign = TextAlign.Center,
enabled: Boolean = true,
paddingValues: PaddingValues = PaddingValues(),
showOverlay: Boolean = true,
maxWidth: Dp = Dp.Unspecified,
highlightComponent: Boolean = true,
onDismiss: (() -> Unit)? = null
): Modifier
API позволяет отображать подсказку вместе с заголовком (по выбору) и предоставляет множество опций настройки, упрощая позиционирование подсказки, выделение компонента и другие аспекты.
С его помощью очень просто добавить подсказку для любого компонента:
MyComponent(
modifier = Modifier.tooltip(
text = "This is a tooltip",
enabled = true
)
)
А вот как это выглядит в действии:


Примеры подсказок на основе Modifier в Public-приложении
Функция Modifier помечена аннотацией @Composable, поскольку выполняет код Compose для обработки измерения и рендеринга компонентов пользовательского интерфейса. Это касается и самой подсказки, и полупрозрачного наложения, и различных анимаций.
Однако суть дела заключается в использовании компонента Popup. Popup — плавающий контейнер, который появляется поверх текущей Activity. Это менее известный компонент, с которым многие разработчики могут быть не знакомы.
Не углубляясь в многочисленные детали кода, позволим ему говорить самому за себя. Представляем упрощенную версию реализации, идея которой принадлежит команде инженеров Public:
@Composable
fun Modifier.tooltip(
title: String? = null,
text: String,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
verticalAlignment: Alignment.Vertical = Alignment.Top,
arrowAlignment: Alignment = Alignment.BottomCenter,
textAlign: TextAlign = TextAlign.Center,
enabled: Boolean = true,
paddingValues: PaddingValues = PaddingValues(),
showOverlay: Boolean = true,
maxWidth: Dp = Dp.Unspecified,
highlightComponent: Boolean = true,
onDismiss: () -> Unit = {}
): Modifier {
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val screenWidthPx = remember { with(density) { configuration.screenWidthDp.dp.roundToPx() } }
val screenHeightPx = remember { with(density) { configuration.screenHeightDp.dp.roundToPx() } }
var positionInRoot by remember { mutableStateOf(IntOffset.Zero) }
var tooltipSize by remember { mutableStateOf(IntSize(0, 0)) }
var componentSize by remember { mutableStateOf(IntSize(0, 0)) }
val tooltipOffset by remember(positionInRoot, componentSize, tooltipSize) {
derivedStateOf {
calculateOffset(
positionInRoot, componentSize, tooltipSize, screenWidthPx, screenHeightPx, horizontalAlignment, verticalAlignment
)
}
}
if (enabled) {
Popup(
alignment = Alignment.TopEnd
) {
Box(
modifier = Modifier
.fillMaxSize()
.drawOverlayBackground(
showOverlay = showOverlay,
highlightComponent = highlightComponent,
positionInRoot = positionInRoot,
componentSize = componentSize,
backgroundColor = Color.White,
backgroundAlpha = 0.8f
)
.clickable(
onClick = {
onDismiss()
}
)
) {
ArrowTooltip(
modifier = Modifier
.widthIn(max = maxWidth)
.onSizeChanged { tooltipSize = it }
.offset { tooltipOffset }
.padding(paddingValues),
title = title,
text = text,
arrowAlignment = arrowAlignment,
textAlign = textAlign
)
}
}
}
return this then Modifier.onPlaced {
componentSize = it.size
positionInRoot = it.positionInRoot().toIntOffset()
}
}
private fun calculateOffset(
positionInRoot: IntOffset,
componentSize: IntSize,
tooltipSize: IntSize,
screenWidthPx: Int,
screenHeightPx: Int,
horizontalAlignment: Alignment.Horizontal,
verticalAlignment: Alignment.Vertical
): IntOffset {
val horizontalAlignmentPosition = when (horizontalAlignment) {
Alignment.Start -> positionInRoot.x
Alignment.End -> positionInRoot.x + componentSize.width - tooltipSize.width
else -> positionInRoot.x + (componentSize.width / 2) - (tooltipSize.width / 2)
}
val verticalAlignmentPosition = when (verticalAlignment) {
Alignment.Top -> positionInRoot.y - tooltipSize.height
Alignment.Bottom -> positionInRoot.y + componentSize.height
else -> positionInRoot.y + (componentSize.height / 2)
}
// Возвращаем рассчитанное смещение, следя за тем, чтобы подсказка не выходила за границы
// экрана. Смещение по X никогда не должно быть больше ширины экрана с вычетом
// ширины подсказки, и точно так же смещение по Y не должно превышать высоту экрана
// с вычетом высоты подсказки.
return IntOffset(
x = min(screenWidthPx - tooltipSize.width, horizontalAlignmentPosition),
y = min(screenHeightPx - tooltipSize.height, verticalAlignmentPosition)
)
}
private fun Modifier.drawOverlayBackground(
showOverlay: Boolean,
highlightComponent: Boolean,
positionInRoot: IntOffset,
componentSize: IntSize,
backgroundColor: Color,
backgroundAlpha: Float
) : Modifier {
return if (showOverlay) {
if (highlightComponent) {
// Если значение highlightComponent установлено в true, нам нужно создать Path и Rect с положением
// и размером компонента и нарисовать фон, обрезав Path.
drawBehind {
val highlightPath = Path().apply {
addRect(Rect(positionInRoot.toOffset(), componentSize.toSize()))
}
clipPath(highlightPath, clipOp = ClipOp.Difference) {
drawRect(SolidColor(backgroundColor.copy(alpha = backgroundAlpha)))
}
}
} else {
// Если highlightComponent установлен в false, мы просто добавим полноразмерный фон,
// который закроет все и будет иметь альфа-значение для обеспечения полупрозрачности.
background(backgroundColor.copy(alpha = backgroundAlpha))
}
} else {
this
}
}
Вам нужно будет разработать собственную реализацию ArrowTooltip на основе приведенного выше фрагмента. К счастью, в интернете есть множество надежных вариантов. Кроме того, можете разнообразить его, добавив свой набор анимаций для появления наложения и подсказки.
Хотя модификатор подсказок (TooltipModifier) показал высокую результативность при тестировании, важно отметить, что он все еще находится на ранней стадии разработки. Возможно, в нем есть еще области для оптимизации и скрытые ошибки, которые следует исправить.
Читайте также:
- Освоение различных видов линий в Jetpack Compose с помощью PathEffect
- Секреты в Android. Часть 2
- Как восстановить положение прокрутки виджета RecyclerView
Читайте нас в Telegram, VK и Дзен
Перевод статьи Michell Bak: Designing Intuitive Interfaces: A Take on Modifier-based Tooltips in Jetpack Compose





