Анимация всплывающих подсказок

Добро пожаловать в мой первый пост в блоге! Мы погрузимся в мир Jetpack Compose и решим надоедливую проблему, которая в последнее время беспокоила нашу команду. Вы знаете те удобные всплывающие подсказки, которые вы видите в приложениях, привязанных к значкам, таким как информационные кнопки?

Наша команда разработчиков заметила, что у каждого разработчика был свой подход к заключению Tooltip в Popup, что привело к множеству пользовательских popupPositionProvider, которые все делали одно и то же. Решения были... ладно, но у них был главный недостаток: анимации в Tooltip компонуемых отсутствовали!

После долгих экспериментов и мозгового штурма я черпал вдохновение из реализации Android DropdownMenu и придумал инновационное решение. Результат? Теперь мы можем привязать Tooltip к другому составному объекту и убедиться, что анимация по-прежнему воспроизводится без каких-либо проблем!

Я рад провести вас через процесс создания вашего собственного всплывающего окна, которое не только привязано к составному объекту, но и имеет уникальную пользовательскую анимацию. Приготовьтесь поднять пользовательский опыт вашего приложения на новый уровень!

Начнем с определения состояния нашего Popup. В нашем конкретном случае это означает, что нам нужно определить, когда Popup виден, где он должен отображаться (над или под привязкой) и как он должен расширяться и сжиматься по горизонтали по отношению к привязке. После того, как мы определили это состояние, мы можем перейти к следующим шагам, чтобы создать наше пользовательское всплывающее окно.

Вот код для начала:

@Stable
class PopupState(
    isVisible: Boolean = false
) {
    /**
     * Horizontal alignment from which the popup will expand from and shrink to.
     */
    var horizontalAlignment: Alignment.Horizontal by mutableStateOf(Alignment.CenterHorizontally)

    /**
     * Boolean that defines whether the popup is displayed above or below the anchor.
     */
    var isTop: Boolean by mutableStateOf(false)

    /**
     * Boolean that defines whether the popup is currently visible or not.
     */
    var isVisible: Boolean by mutableStateOf(isVisible)
}

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

Поскольку контент может располагаться более чем в одной позиции как по горизонтали, так и по вертикали, нам необходимо установить механизм приоритизации. Как только мы определили окончательную позицию, мы можем соответствующим образом обновить состояние всплывающего окна.

Чтобы реализовать это, мы определим класс, который расширяет PopupPositionProvider, который обрабатывает позиционирование всплывающего окна. Мы назовем этот класс CustomPopupPositionProvider.

Вот код:

@Immutable
private data class CustomPopupPositionProvider(
    val contentOffset: DpOffset,
    val density: Density,
    val onPopupPositionFound: (Alignment.Horizontal, Boolean) -> Unit
) : PopupPositionProvider {

    override fun calculatePosition(
        anchorBounds: IntRect,
        windowSize: IntSize,
        layoutDirection: LayoutDirection,
        popupContentSize: IntSize
    ): IntOffset {
        // The content offset specified using the dropdown offset parameter.
        val contentOffsetX = with(density) { contentOffset.x.roundToPx() }
        // The content offset specified using the dropdown offset parameter.
        val contentOffsetY = with(density) { contentOffset.y.roundToPx() }

        val isFitEnd = (anchorBounds.left + contentOffsetX + popupContentSize.width) < windowSize.width
        val isFitStart = (anchorBounds.left - contentOffsetX - popupContentSize.width) > 0
        val popupHalfWidth = popupContentSize.width / 2
        val halfAnchor = (anchorBounds.right - anchorBounds.left) / 2
        val isFitCenter =
            ((anchorBounds.left + halfAnchor + popupHalfWidth) < windowSize.width) &&
                ((anchorBounds.left + halfAnchor - popupHalfWidth) > 0)

        val endPlacementOffset = anchorBounds.left - contentOffsetX
        val centerPlacementOffset = anchorBounds.left - popupHalfWidth + contentOffsetX
        val startPlacementOffset = anchorBounds.right + contentOffsetX - popupContentSize.width

        val bottomCoordinatesY = anchorBounds.bottom + popupContentSize.height
        val isFitBottom = bottomCoordinatesY <= windowSize.height
        val topCoordinatesY = anchorBounds.top - popupContentSize.height
        val isFitTop = topCoordinatesY > 0 || anchorBounds.top > windowSize.height

        // Compute vertical position.
        val toBottom = anchorBounds.bottom + contentOffsetY
        val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height
        val toCenter = anchorBounds.top - popupContentSize.height / 2
        val toDisplayBottom = windowSize.height - popupContentSize.height
        val yOffset = sequenceOf(
            if (isFitTop) toBottom else toTop,
            toCenter,
            toDisplayBottom
        ).firstOrNull {
            it + popupContentSize.height <= windowSize.height
        } ?: toTop

        val horizontalAndOffset = getHorizontalOffset(
            isFitsStart = isFitStart,
            isFitsEnd = isFitEnd,
            isFitsCenter = isFitCenter,
            endPlacementOffset = endPlacementOffset,
            startPlacementOffset = startPlacementOffset,
            centerPlacementOffset = centerPlacementOffset
        )

        onPopupPositionFound(horizontalAndOffset.first, isFitTop)
        return IntOffset(horizontalAndOffset.second, yOffset)
    }
}

На следующем этапе все становится действительно захватывающим! Мы определим наши собственные анимации и применим их к нашему всплывающему окну. Мы делаем это, создавая компонуемый CustomPopupContent, который принимает изменяемое состояние перехода expandedStates, чтобы определить, расширяется или сжимается всплывающее окно, и transformOrigin, определяющий исходную позицию анимации. Компонуемый также будет принимать контент, который будет отображаться во всплывающем окне.

После этого мы создадим переход, включающий наши собственные анимации. Мы применим переход к составному элементу CustomPopupContent через файл graphicsLayer.

См. блок кода ниже для реализации:

@Composable
private fun CustomPopupContent(
    expandedStates: MutableTransitionState<Boolean>,
    transformOrigin: TransformOrigin,
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit
) {
    // Menu open/close animation.
    val transition = updateTransition(expandedStates, "Popup")

    // Scale animation.
    val scale by transition.animateFloat(
        transitionSpec = {
            if (false isTransitioningTo true) {
                // Dismissed to expanded.
                tween(durationMillis = 200)
            } else {
                // Expanded to dismissed.
                tween(durationMillis = 200)
            }
        },
        label = "Popup Scale"
    ) {
        if (it) {
            // Popup is expanded.
            1f
        } else {
            // Popup is dismissed.
            0f
        }
    }

    // Alpha animation.
    val alpha by transition.animateFloat(
        transitionSpec = {
            if (false isTransitioningTo true) {
                // Dismissed to expanded.
                tween(durationMillis = 200)
            } else {
                // Expanded to dismissed.
                tween(durationMillis = 200)
            }
        },
        label = "Popup Alpha"
    ) {
        if (it) {
            // Popup is expanded.
            1f
        } else {
            // Popup is dismissed.
            0f
        }
    }

    // Helper function for applying animations to graphics layer.
    fun GraphicsLayerScope.graphicsLayerAnim() {
        scaleX = scale
        scaleY = scale
        this.alpha = alpha
        this.transformOrigin = transformOrigin
    }

    Surface(
        modifier = Modifier
            .graphicsLayer {
                graphicsLayerAnim()
            }
    ) {
        Column(
            modifier = modifier
                .width(IntrinsicSize.Max)
                .verticalScroll(rememberScrollState()),
            content = content
        )
    }
}

Чтобы собрать все вместе, мы создаем CustomPopup компонуемый. Сначала мы создаем и запоминаем expandedStates, который мы передаем в CustomPopupContent.

Затем мы создаем экземпляр нашего CustomPopupPositionProvider и вычисляем transformOrigin, который также передается в CustomPopupContent. Наконец, мы оборачиваем наш CustomPopupContent Всплывающим окном Android.

Вот код:

@Composable
fun CustomPopup(
    popupState: PopupState,
    onDismissRequest: () -> Unit,
    modifier: Modifier = Modifier,
    offset: DpOffset = DpOffset(0.dp, 0.dp),
    properties: PopupProperties = PopupProperties(focusable = true),
    content: @Composable ColumnScope.() -> Unit
) {
    // Create a transition state to track whether the popup is expanded.
    val expandedStates = remember { MutableTransitionState(false) }
    expandedStates.targetState = popupState.isVisible

    // Only show the popup if it's visible.
    if (expandedStates.currentState || expandedStates.targetState) {
        val density = LocalDensity.current

        // Instantiate a CustomPopupPositionProvider with the given offset.
        val popupPositionProvider = CustomPopupPositionProvider(
            contentOffset = offset,
            density = density
        ) { alignment, isTop ->
            // Update the PopupState's alignment and direction.
            popupState.horizontalAlignment = alignment
            popupState.isTop = !isTop
        }

        // Display the popup using the Popup composable.
        Popup(
            onDismissRequest = onDismissRequest,
            popupPositionProvider = popupPositionProvider,
            properties = properties
        ) {
            // Display the popup's content using the CustomPopupContent composable.
            CustomPopupContent(
                expandedStates = expandedStates,
                transformOrigin = TransformOrigin(
                    pivotFractionX = when(popupState.horizontalAlignment) {
                        Alignment.Start -> 0f
                        Alignment.CenterHorizontally -> 0.5f
                        else -> 1f
                    },
                    pivotFractionY = if (popupState.isTop) 1f else 0f
                ),
                modifier = modifier,
                content = content
            )
        }
    }
}

Чтобы отобразить CustomPopup, его нужно поместить внутрь составного Box, содержащего привязку, как во всплывающем окне или раскрывающемся меню Android.

Например, если вы хотите показывать всплывающее окно, когда пользователь щелкает значок, вы можете обернуть значок и CustomPopup в Box:

@Composable
    private fun IconWithCustomPopup(
        popupState: PopupState = remember {
            PopupState(false)
        },
        text: String
    ) {
        Box {
            CustomPopup(
                popupState = popupState,
                onDismissRequest = {
                    popupState.isVisible = false
                }
            ) {
                Text(
                    text = text,
                    modifier = Modifier.background(Color.Yellow)
                )
            }
            Icon(
                imageVector = Icons.Default.Info,
                contentDescription = "Info Icon",
                modifier = Modifier.clickable {
                    popupState.isVisible = !popupState.isVisible
                }
            )
        }
    }

Имейте в виду, что CustomPopup не займет места в иерархии макетов, так как отображается в отдельном окне поверх другого контента.

Я включил весь код этого пользовательского всплывающего окна в эту суть. Удачного кодирования!