Различные подсказки в Figma

В постоянно развивающейся сфере разработки приложений создание интуитивно понятных и удобных интерфейсов имеет первостепенное значение для успеха любого проекта.

Одним из мощных инструментов в арсенале разработчика для улучшения пользовательского опыта являются подсказки. Эти ненавязчивые, но информативные элементы могут оказать неоценимую помощь пользователям, позволяя разобраться в технических нюансах приложения, освоить и использовать все его возможности.

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Michell Bak: Designing Intuitive Interfaces: A Take on Modifier-based Tooltips in Jetpack Compose

Предыдущая статьяСамый быстрый способ cоздать CRUD API в Golang