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.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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
Reference in New Issue
Block a user