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.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.ulfrx.recipe.navigation.DockDestination 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) DockSubstrate(cornerRadius = height / 2)
DockActiveIndicatorLayer( DockActiveIndicatorLayer(
activeIndex = activeIndex, activeIndex = activeIndex,
pressState = pressState,
tabBounds = tabBounds, tabBounds = tabBounds,
dockWidthPx = dockWidthPx, dockWidthPx = dockWidthPx,
alpha = anim.activeIndicatorAlpha,
) )
DockPressOverlayLayer( DockPressOverlayLayer(
pressState = pressState, overlayCenterX = anim.overlayCenterX,
activeIndex = activeIndex, overlayWidthPx = anim.overlayWidthPx,
tabBounds = tabBounds, overlayAlpha = anim.overlayAlpha,
dockWidthPx = dockWidthPx, dockWidthPx = dockWidthPx,
dockHeight = height, 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.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing 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.animation.core.tween
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
@@ -10,48 +12,131 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.positionChanged 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.Cancelled
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Pressing import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Pressing
import dev.ulfrx.recipe.ui.screens.shell.dock.DockPressEvent.Released 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 @Composable
internal fun rememberPressOverlayCenterX( internal fun rememberDockOverlayAnimations(
pressedCenterX: Float?, pressState: DockPressState,
activeCenterX: Float, activeIndex: Int,
): Float { tabBounds: Map<Int, TabBounds>,
val animatable = remember { Animatable(activeCenterX) } dockWidthPx: Float,
var wasPressed by remember { mutableStateOf(false) } 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 { when {
pressedCenterX == null -> { clampedPressX == null -> {
wasPressed = false wasPressed = false
animatable.animateTo( centerAnim.animateTo(
targetValue = activeCenterX, activeCenterX,
animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing), spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = SlideSettleStiffness,
visibilityThreshold = SettleEpsilonPx,
),
) )
} }
!wasPressed -> { !wasPressed -> {
wasPressed = true wasPressed = true
animatable.animateTo( centerAnim.animateTo(
targetValue = pressedCenterX, clampedPressX,
animationSpec = tween(durationMillis = OverlaySlideDurationMs, easing = FastOutSlowInEasing), spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = SlideOutwardStiffness,
visibilityThreshold = SettleEpsilonPx,
),
) )
} }
else -> centerAnim.snapTo(clampedPressX)
}
}
else -> { val pressing = pressState is DockPressState.Pressing
animatable.snapTo(pressedCenterX) 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) { internal suspend fun PointerInputScope.trackDockGesture(onPressEvent: (DockPressEvent) -> Unit) {

View File

@@ -1,8 +1,5 @@
package dev.ulfrx.recipe.ui.screens.shell.dock 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight 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.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha 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.platform.LocalDensity
import androidx.compose.ui.util.lerp
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp 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 dev.ulfrx.recipe.ui.theme.RecipeTheme
import kotlin.math.roundToInt import kotlin.math.roundToInt
private val PressOverlayBleed = 4.dp
private val PressOverlayVerticalInset = 0.dp private val PressOverlayVerticalInset = 0.dp
private val ActiveIndicatorVerticalInset = 5.dp private val ActiveIndicatorVerticalInset = 5.dp
private const val PressOverlayScale = 1.22f private const val PressOverlayScale = 1.22f
private const val OverlayFadeDurationMs = 120
@Composable @Composable
internal fun DockSubstrate(cornerRadius: Dp) { internal fun DockSubstrate(cornerRadius: Dp) {
@@ -43,15 +38,10 @@ internal fun DockSubstrate(cornerRadius: Dp) {
@Composable @Composable
internal fun DockActiveIndicatorLayer( internal fun DockActiveIndicatorLayer(
activeIndex: Int, activeIndex: Int,
pressState: DockPressState,
tabBounds: Map<Int, TabBounds>, tabBounds: Map<Int, TabBounds>,
dockWidthPx: Float, 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 val bounds = tabBounds[activeIndex] ?: return
if (alpha <= 0f || dockWidthPx <= 0f) return if (alpha <= 0f || dockWidthPx <= 0f) return
@@ -74,30 +64,20 @@ internal fun DockActiveIndicatorLayer(
@Composable @Composable
internal fun DockPressOverlayLayer( internal fun DockPressOverlayLayer(
pressState: DockPressState, overlayCenterX: Float,
activeIndex: Int, overlayWidthPx: Float,
tabBounds: Map<Int, TabBounds>, overlayAlpha: Float,
dockWidthPx: Float, dockWidthPx: Float,
dockHeight: Dp, dockHeight: Dp,
) { ) {
val activeBounds = tabBounds[activeIndex] ?: return if (overlayAlpha <= 0f || dockWidthPx <= 0f || overlayWidthPx <= 0f) return
val activeCenterX = activeBounds.offsetXPx + activeBounds.widthPx / 2f
val density = LocalDensity.current val density = LocalDensity.current
val bleedPx = with(density) { PressOverlayBleed.toPx() } val dockHeightPx = with(density) { dockHeight.toPx() }
val overlayWidthPx = activeBounds.widthPx + 2 * bleedPx val activeInsetPx = with(density) { ActiveIndicatorVerticalInset.toPx() }
val centerXMin = overlayWidthPx / 2f val activeStartScaleY = if (dockHeightPx > 0f) (dockHeightPx - 2 * activeInsetPx) / dockHeightPx else 1f
val centerXMax = (dockWidthPx - overlayWidthPx / 2f).coerceAtLeast(centerXMin) val scaleX = lerp(1f, PressOverlayScale, overlayAlpha)
val pressingXPx = (pressState as? DockPressState.Pressing)?.xPx val scaleY = lerp(activeStartScaleY, PressOverlayScale, overlayAlpha)
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 cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2 val cornerRadius = (dockHeight - 2 * PressOverlayVerticalInset) / 2
val leftPx = overlayCenterX - overlayWidthPx / 2f val leftPx = overlayCenterX - overlayWidthPx / 2f
@@ -107,8 +87,11 @@ internal fun DockPressOverlayLayer(
.width(with(density) { overlayWidthPx.toDp() }) .width(with(density) { overlayWidthPx.toDp() })
.fillMaxHeight() .fillMaxHeight()
.padding(vertical = PressOverlayVerticalInset) .padding(vertical = PressOverlayVerticalInset)
.scale(PressOverlayScale) .graphicsLayer {
.alpha(alpha), this.scaleX = scaleX
this.scaleY = scaleY
}
.alpha(overlayAlpha),
cornerRadius = cornerRadius, cornerRadius = cornerRadius,
glassStyle = RecipeTheme.glass.dockPress, glassStyle = RecipeTheme.glass.dockPress,
) {} ) {}