Adjust dock overlay animation

This commit is contained in:
2026-05-18 20:11:33 +02:00
parent ab1630a06b
commit 488509db06
3 changed files with 133 additions and 57 deletions

View File

@@ -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,
)

View File

@@ -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<Int, TabBounds>,
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) {

View File

@@ -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<Int, TabBounds>,
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<Int, TabBounds>,
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,
) {}