From 488509db06eca1f9ebfc2c59b35185fe17989764 Mon Sep 17 00:00:00 2001 From: ulfrxdev Date: Mon, 18 May 2026 20:11:33 +0200 Subject: [PATCH] Adjust dock overlay animation --- .../recipe/ui/screens/shell/dock/DockBar.kt | 16 ++- .../ui/screens/shell/dock/DockGesture.kt | 123 +++++++++++++++--- .../ui/screens/shell/dock/DockLayers.kt | 51 +++----- 3 files changed, 133 insertions(+), 57 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt index fcee1b4..71d5cf6 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockBar.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.ulfrx.recipe.navigation.DockDestination @@ -103,17 +104,24 @@ private fun DockBarExpanded( } }, ) { + val anim = rememberDockOverlayAnimations( + pressState = pressState, + activeIndex = activeIndex, + tabBounds = tabBounds, + dockWidthPx = dockWidthPx, + density = LocalDensity.current, + ) DockSubstrate(cornerRadius = height / 2) DockActiveIndicatorLayer( activeIndex = activeIndex, - pressState = pressState, tabBounds = tabBounds, dockWidthPx = dockWidthPx, + alpha = anim.activeIndicatorAlpha, ) DockPressOverlayLayer( - pressState = pressState, - activeIndex = activeIndex, - tabBounds = tabBounds, + overlayCenterX = anim.overlayCenterX, + overlayWidthPx = anim.overlayWidthPx, + overlayAlpha = anim.overlayAlpha, dockWidthPx = dockWidthPx, dockHeight = height, ) diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt index e6ee652..b1bd819 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockGesture.kt @@ -2,6 +2,8 @@ package dev.ulfrx.recipe.ui.screens.shell.dock import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -10,48 +12,131 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Cancelled import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Pressing import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Released +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlin.math.abs -private const val OverlaySlideDurationMs = 200 +private val PressOverlayBleed = 4.dp +private const val SlideOutwardStiffness = Spring.StiffnessMediumLow +private const val SlideSettleStiffness = Spring.StiffnessHigh +private const val OverlayFadeInDurationMs = 120 +private const val OverlayFadeOutDurationMs = 40 +private const val SettleEpsilonPx = 0.5f + +internal data class DockOverlayAnimations( + val overlayCenterX: Float, + val overlayWidthPx: Float, + val overlayAlpha: Float, + val activeIndicatorAlpha: Float, +) @Composable -internal fun rememberPressOverlayCenterX( - pressedCenterX: Float?, - activeCenterX: Float, -): Float { - val animatable = remember { Animatable(activeCenterX) } - var wasPressed by remember { mutableStateOf(false) } +internal fun rememberDockOverlayAnimations( + pressState: DockPressState, + activeIndex: Int, + tabBounds: Map, + dockWidthPx: Float, + density: Density, +): DockOverlayAnimations { + val activeBounds = tabBounds[activeIndex] + val activeCenterX = activeBounds?.let { it.offsetXPx + it.widthPx / 2f } ?: 0f + val bleedPx = with(density) { PressOverlayBleed.toPx() } + val overlayWidthPx = (activeBounds?.widthPx ?: 0f) + 2 * bleedPx + val centerXMin = overlayWidthPx / 2f + val centerXMax = (dockWidthPx - overlayWidthPx / 2f).coerceAtLeast(centerXMin) + val pressingXPx = (pressState as? DockPressState.Pressing)?.xPx + val clampedPressX = pressingXPx?.coerceIn(centerXMin, centerXMax) - LaunchedEffect(pressedCenterX, activeCenterX) { + val centerAnim = remember { Animatable(activeCenterX) } + val overlayAlphaAnim = remember { Animatable(0f) } + val activeAlphaAnim = remember { Animatable(1f) } + + var wasPressed by remember { mutableStateOf(false) } + LaunchedEffect(clampedPressX, activeCenterX) { when { - pressedCenterX == null -> { + clampedPressX == null -> { wasPressed = false - animatable.animateTo( - targetValue = activeCenterX, - animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing), + centerAnim.animateTo( + activeCenterX, + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = SlideSettleStiffness, + visibilityThreshold = SettleEpsilonPx, + ), ) } - !wasPressed -> { wasPressed = true - animatable.animateTo( - targetValue = pressedCenterX, - animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing), + centerAnim.animateTo( + clampedPressX, + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = SlideOutwardStiffness, + visibilityThreshold = SettleEpsilonPx, + ), ) } + else -> centerAnim.snapTo(clampedPressX) + } + } - else -> { - animatable.snapTo(pressedCenterX) + val pressing = pressState is DockPressState.Pressing + val activeCenterXState = rememberUpdatedState(activeCenterX) + LaunchedEffect(pressing) { + if (pressing) { + activeAlphaAnim.snapTo(0f) + overlayAlphaAnim.animateTo( + targetValue = 1f, + animationSpec = tween(OverlayFadeInDurationMs, easing = FastOutSlowInEasing), + ) + } else { + if (overlayAlphaAnim.value <= 0f) return@LaunchedEffect + if (overlayAlphaAnim.value < 1f) { + val tailMs = ((1f - overlayAlphaAnim.value) * OverlayFadeInDurationMs) + .toInt() + .coerceAtLeast(0) + if (tailMs > 0) { + overlayAlphaAnim.animateTo(1f, tween(tailMs, easing = FastOutSlowInEasing)) + } + } + snapshotFlow { + !centerAnim.isRunning && + abs(centerAnim.value - activeCenterXState.value) < SettleEpsilonPx + }.first { it } + coroutineScope { + launch { + overlayAlphaAnim.animateTo( + targetValue = 0f, + animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing), + ) + } + launch { + activeAlphaAnim.animateTo( + targetValue = 1f, + animationSpec = tween(OverlayFadeOutDurationMs, easing = FastOutSlowInEasing), + ) + } } } } - return animatable.value + return DockOverlayAnimations( + overlayCenterX = centerAnim.value, + overlayWidthPx = overlayWidthPx, + overlayAlpha = overlayAlphaAnim.value, + activeIndicatorAlpha = activeAlphaAnim.value, + ) } internal suspend fun PointerInputScope.trackDockGesture(onPressEvent: (DockPressEvent) -> Unit) { diff --git a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt index 3bd400c..4890d02 100644 --- a/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt +++ b/composeApp/src/commonMain/kotlin/dev/ulfrx/recipe/ui/screens/shell/dock/DockLayers.kt @@ -1,8 +1,5 @@ package dev.ulfrx.recipe.ui.screens.shell.dock -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight @@ -12,11 +9,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.util.lerp import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -25,11 +22,9 @@ import dev.ulfrx.recipe.ui.components.glass.GlassSurface import dev.ulfrx.recipe.ui.theme.RecipeTheme import kotlin.math.roundToInt -private val PressOverlayBleed = 4.dp private val PressOverlayVerticalInset = 0.dp private val ActiveIndicatorVerticalInset = 5.dp private const val PressOverlayScale = 1.22f -private const val OverlayFadeDurationMs = 120 @Composable internal fun DockSubstrate(cornerRadius: Dp) { @@ -43,15 +38,10 @@ internal fun DockSubstrate(cornerRadius: Dp) { @Composable internal fun DockActiveIndicatorLayer( activeIndex: Int, - pressState: DockPressState, tabBounds: Map, dockWidthPx: Float, + alpha: Float, ) { - val alpha by animateFloatAsState( - targetValue = if (pressState is DockPressState.Idle) 1f else 0f, - animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing), - label = "Dock active indicator alpha", - ) val bounds = tabBounds[activeIndex] ?: return if (alpha <= 0f || dockWidthPx <= 0f) return @@ -74,30 +64,20 @@ internal fun DockActiveIndicatorLayer( @Composable internal fun DockPressOverlayLayer( - pressState: DockPressState, - activeIndex: Int, - tabBounds: Map, + overlayCenterX: Float, + overlayWidthPx: Float, + overlayAlpha: Float, dockWidthPx: Float, dockHeight: Dp, ) { - val activeBounds = tabBounds[activeIndex] ?: return - val activeCenterX = activeBounds.offsetXPx + activeBounds.widthPx / 2f + if (overlayAlpha <= 0f || dockWidthPx <= 0f || overlayWidthPx <= 0f) return val density = LocalDensity.current - val bleedPx = with(density) { PressOverlayBleed.toPx() } - val overlayWidthPx = activeBounds.widthPx + 2 * bleedPx - val centerXMin = overlayWidthPx / 2f - val centerXMax = (dockWidthPx - overlayWidthPx / 2f).coerceAtLeast(centerXMin) - val pressingXPx = (pressState as? DockPressState.Pressing)?.xPx - val clampedPressPositionPx = pressingXPx?.coerceIn(centerXMin, centerXMax) - val overlayCenterX = rememberPressOverlayCenterX(clampedPressPositionPx, activeCenterX) - - val alpha by animateFloatAsState( - targetValue = if (pressState is DockPressState.Pressing) 1f else 0f, - animationSpec = tween(durationMillis = OverlayFadeDurationMs, easing = FastOutSlowInEasing), - label = "Dock press overlay alpha", - ) - if (alpha <= 0f || dockWidthPx <= 0f) return + val dockHeightPx = with(density) { dockHeight.toPx() } + val activeInsetPx = with(density) { ActiveIndicatorVerticalInset.toPx() } + val activeStartScaleY = if (dockHeightPx > 0f) (dockHeightPx - 2 * activeInsetPx) / dockHeightPx else 1f + val scaleX = lerp(1f, PressOverlayScale, overlayAlpha) + val scaleY = lerp(activeStartScaleY, PressOverlayScale, overlayAlpha) val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2 val leftPx = overlayCenterX - overlayWidthPx / 2f @@ -107,8 +87,11 @@ internal fun DockPressOverlayLayer( .width(with(density) { overlayWidthPx.toDp() }) .fillMaxHeight() .padding(vertical = PressOverlayVerticalInset) - .scale(PressOverlayScale) - .alpha(alpha), + .graphicsLayer { + this.scaleX = scaleX + this.scaleY = scaleY + } + .alpha(overlayAlpha), cornerRadius = cornerRadius, glassStyle = RecipeTheme.glass.dockPress, ) {}