Анимация всплывающих подсказок
Добро пожаловать в мой первый пост в блоге! Мы погрузимся в мир 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
не займет места в иерархии макетов, так как отображается в отдельном окне поверх другого контента.
Я включил весь код этого пользовательского всплывающего окна в эту суть. Удачного кодирования!