В постоянно развивающейся сфере разработки приложений создание интуитивно понятных и удобных интерфейсов имеет первостепенное значение для успеха любого проекта.
Одним из мощных инструментов в арсенале разработчика для улучшения пользовательского опыта являются подсказки. Эти ненавязчивые, но информативные элементы могут оказать неоценимую помощь пользователям, позволяя разобраться в технических нюансах приложения, освоить и использовать все его возможности.
Недавно 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