Adjust dock overlay animation
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
Reference in New Issue
Block a user