Add MomentumAnimation for catalog

This commit is contained in:
Kyant0 committed 2025-09-28 17:34:58 +08:00
1 parent 60a9493237
commit 8faad0217b
7 files changed
+224 -138

No files matched your search

Binary file not shown.
@@ -24,6 +24,8 @@ fun RowScope.LiquidBottomTab(
modifier
.clip(ContinuousCapsule)
.clickable(
interactionSource = null,
indication = null,
role = Role.Tab,
onClick = onClick
)
@@ -3,6 +3,7 @@ package com.kyant.backdrop.catalog.components
import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.spring
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
@@ -14,6 +15,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -36,8 +38,6 @@ import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -51,6 +51,7 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.catalog.utils.inspectDragGestures
import com.kyant.backdrop.catalog.utils.rememberMomentumAnimation
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refraction
@@ -60,9 +61,12 @@ import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow
import com.kyant.capsule.ContinuousCapsule
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.sign
@Composable
fun LiquidBottomTabs(
@@ -104,10 +108,15 @@ fun LiquidBottomTabs(
val animationScope = rememberCoroutineScope()
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val progressAnimation = remember { Animatable(0f) }
val scaleXAnimation = remember { Animatable(0f) }
val scaleYAnimation = remember { Animatable(0f) }
val velocityAnimation = remember { Animatable(0f) }
val offsetAnimation = remember { Animatable(0f) }
val panelOffset by remember(density) {
derivedStateOf {
val fraction = (offsetAnimation.value / constraints.maxWidth).fastCoerceIn(-1f, 1f)
with(density) {
4f.dp.toPx() * fraction.sign * EaseOut.transform(abs(fraction))
}
}
}
val tabOffsetAnimation = remember(selectedTabIndex, tabWidth) {
Animatable(
Snapshot.withoutReadObservation {
@@ -115,29 +124,9 @@ fun LiquidBottomTabs(
}
)
}
var isDragging by remember { mutableStateOf(false) }
val onDragStart = {
animationScope.launch {
launch {
progressAnimation.animateTo(
1f,
spring(1f, 800f, 0.001f)
)
}
launch {
scaleXAnimation.animateTo(
1f,
spring(0.3f, 300f, 0.001f)
)
}
launch {
scaleYAnimation.animateTo(
1f,
spring(0.5f, 300f, 0.001f)
)
}
}
}
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.333f)
val onDragStop = {
animationScope.launch {
@@ -152,24 +141,6 @@ fun LiquidBottomTabs(
)
}
}
launch {
progressAnimation.animateTo(
0f,
spring(1f, 800f, 0.001f)
)
}
launch {
scaleXAnimation.animateTo(
0f,
spring(0.3f, 300f, 0.001f)
)
}
launch {
scaleYAnimation.animateTo(
0f,
spring(0.5f, 300f, 0.001f)
)
}
}
}
@@ -179,25 +150,29 @@ fun LiquidBottomTabs(
}
LaunchedEffect(Unit) {
var job: Job? = null
snapshotFlow { selectedTabIndex() }
.drop(1)
.collect { index ->
val targetOffset = index * tabWidth
if (progressAnimation.targetValue != 1f) {
launch {
onDragStart()
delay(200)
onDragStop()
}
} else {
onDragStop()
}
launch {
tabOffsetAnimation.animateTo(
targetOffset,
spring(1f, 800f, 0.5f)
)
}
job?.cancel()
job = null
if (!isDragging) {
job = launch {
momentumAnimation.startPressingAnimation()
delay(200L)
momentumAnimation.endPressingAnimation()
}
}
isDragging = false
}
}
@@ -223,7 +198,7 @@ half4 main(float2 coord) {
}
val drawInteractiveHighlightModifier = Modifier.drawWithContent {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
val progress = momentumAnimation.progress
if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader is RuntimeShader) {
drawRect(
@@ -258,6 +233,9 @@ half4 main(float2 coord) {
Row(
Modifier
.graphicsLayer {
translationX = panelOffset
}
.drawBackdrop(
backdrop = backdrop,
shape = { ContinuousCapsule },
@@ -267,7 +245,7 @@ half4 main(float2 coord) {
refraction(24f.dp.toPx(), 24f.dp.toPx())
},
layerBlock = {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
val scale = lerp(1f, 1f + 2f.dp.toPx() / size.height, progress)
scaleX = scale
scaleY = scale
@@ -295,11 +273,14 @@ half4 main(float2 coord) {
) {
Box(
Modifier
.graphicsLayer {
translationX = panelOffset
}
.drawBackdrop(
backdrop = backdrop,
shape = { ContinuousCapsule },
highlight = {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
Highlight.Default.copy(alpha = progress)
},
effects = {
@@ -316,10 +297,11 @@ half4 main(float2 coord) {
Box(
Modifier
.graphicsLayer {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
val scale = lerp(1f, 1f + 2f.dp.toPx() / size.height, progress)
scaleX = scale
scaleY = scale
translationX = panelOffset
}
.drawBehind {
drawLayer(tabsLayer)
@@ -332,102 +314,83 @@ half4 main(float2 coord) {
Box(
Modifier
.layout { measurable, constraints ->
val padding = (4f.dp.toPx() * (1f - progressAnimation.value)).fastRoundToInt()
val progress = momentumAnimation.progress
val padding = (4f.dp.toPx() * (1f - progress)).fastRoundToInt()
val placeable = measurable.measure(constraints.offset(-padding * 2, 0))
layout(placeable.width + padding, placeable.height) {
placeable.place(padding, 0)
}
}
.graphicsLayer {
translationX = tabOffsetAnimation.value.fastCoerceIn(0f, size.width * (tabsCount - 1))
translationX =
tabOffsetAnimation.value.fastCoerceIn(0f, size.width * (tabsCount - 1)) +
panelOffset
}
.pointerInput(Unit) {
val velocityTracker = VelocityTracker()
val updateVelocity: (Float) -> Unit = { v ->
val alpha = 0.05f
val targetVelocity = velocityAnimation.velocity * (1f - alpha) + v * alpha
animationScope.launch {
velocityAnimation.snapTo(targetVelocity)
}
}
val settleVelocity: () -> Unit = {
animationScope.launch {
velocityAnimation.animateTo(
0f,
spring(0.5f, 500f, 1f)
)
}
}
inspectDragGestures(
onDragStart = { down ->
velocityTracker.addPointerInputChange(down)
isDragging = true
pressStartPosition = down.position
onDragStart()
},
onDragEnd = { change ->
velocityTracker.addPointerInputChange(change)
settleVelocity()
velocityTracker.resetTracking()
onDragEnd = {
settle()
onDragStop()
animationScope.launch {
offsetAnimation.animateTo(
0f,
spring(1f, 300f, 0.5f)
)
}
},
onDragCancel = {
settleVelocity()
velocityTracker.resetTracking()
settle()
onDragStop()
}
) { change, dragAmount ->
velocityTracker.addPointerInputChange(change)
updateVelocity(velocityTracker.calculateVelocity().x)
val targetOffset =
) { _, dragAmount ->
val targetTabOffset =
(tabOffsetAnimation.value + dragAmount.x)
.fastCoerceIn(0f, size.width.toFloat() * 3)
animationScope.launch {
tabOffsetAnimation.snapTo(targetOffset)
}
.fastCoerceIn(0f, size.width.toFloat() * (tabsCount - 1))
animationScope.launch { tabOffsetAnimation.snapTo(targetTabOffset) }
val targetOffset = offsetAnimation.value + dragAmount.x
animationScope.launch { offsetAnimation.snapTo(targetOffset) }
}
}
.then(momentumAnimation.modifier)
.drawBackdrop(
backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop),
shape = { ContinuousCapsule },
highlight = {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
Highlight.Default.copy(alpha = progress)
},
shadow = {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
Shadow(alpha = progress)
},
innerShadow = {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
InnerShadow(
radius = 8f.dp * progress,
alpha = progress
)
},
effects = {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
refractionWithDispersion(
10f.dp.toPx() * progress,
12f.dp.toPx() * progress
)
},
layerBlock = {
scaleX = lerp(1f, 1f + 20f.dp.toPx() / size.height, scaleXAnimation.value)
scaleY = lerp(1f, 1f + 20f.dp.toPx() / size.height, scaleYAnimation.value)
val velocity = velocityAnimation.value / size.width
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f)
scaleX = momentumAnimation.scaleX
scaleY = momentumAnimation.scaleY
val velocity = momentumAnimation.velocity / 5000f
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
},
onDrawSurface = {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
drawRect(
if (isLightTheme) Color.Black.copy(0.1f)
else Color.White.copy(0.1f),
@@ -1,7 +1,5 @@
package com.kyant.backdrop.catalog.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
@@ -16,7 +14,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -32,13 +29,14 @@ import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.catalog.utils.rememberMomentumAnimation
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow
import com.kyant.capsule.ContinuousCapsule
import kotlinx.coroutines.launch
@Composable
fun LiquidSlider(
@@ -63,12 +61,10 @@ fun LiquidSlider(
}
}
val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val progressAnimation = remember { Animatable(0f) }
val trackBackdrop = rememberLayerBackdrop()
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
BoxWithConstraints(
modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
@@ -116,51 +112,47 @@ fun LiquidSlider(
val targetValue = lerp(valueRange.start, valueRange.endInclusive, targetFraction)
onValueChange(targetValue.fastCoerceIn(valueRange.start, valueRange.endInclusive))
},
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
animationScope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec)
}
},
onDragStopped = {
animationScope.launch {
progressAnimation.animateTo(0f, progressAnimationSpec)
}
}
Orientation.Horizontal
)
.then(momentumAnimation.modifier)
.drawBackdrop(
backdrop = rememberCombinedBackdrop(backdrop, trackBackdrop),
shape = { ContinuousCapsule },
highlight = {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
Highlight.AmbientDefault.copy(alpha = progress)
},
shadow = {
Shadow(
radius = 4f.dp,
color = Color.Black.copy(0.05f)
color = Color.Black.copy(alpha = 0.05f)
)
},
innerShadow = {
val progress = progressAnimation.value
val progress = momentumAnimation.progress
InnerShadow(
radius = 4f.dp * progress,
alpha = progress
)
},
effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
val progress = momentumAnimation.progress
blur(8f.dp.toPx() * (1f - progress))
refractionWithDispersion(
height = 6f.dp.toPx() * progress,
amount = size.height / 2f * progress
)
},
layerBlock = {
val progress = progressAnimation.value
val scale = lerp(1f, 1.5f, progress)
scaleX = scale
scaleY = scale
scaleX = momentumAnimation.scaleX
scaleY = momentumAnimation.scaleY
val velocity = momentumAnimation.velocity / 5000f
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
},
onDrawSurface = {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
drawRect(Color.White.copy(1f - progress))
val progress = momentumAnimation.progress
drawRect(Color.White.copy(alpha = 1f - progress))
}
)
.size(40f.dp, 24f.dp)
@@ -0,0 +1,129 @@
package com.kyant.backdrop.catalog.utils
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
fun rememberMomentumAnimation(
maxScale: Float,
progressAnimationSpec: FiniteAnimationSpec<Float> =
spring(1f, 1000f, 0.01f),
velocityAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.5f, 250f, 0.01f),
scaleXAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.4f, 400f, 0.01f),
scaleYAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.6f, 400f, 0.01f)
): MomentumAnimation {
val animationScope = rememberCoroutineScope()
return remember(
maxScale,
animationScope,
progressAnimationSpec,
velocityAnimationSpec,
scaleXAnimationSpec,
scaleYAnimationSpec
) {
MomentumAnimation(
maxScale = maxScale,
animationScope = animationScope,
progressAnimationSpec = progressAnimationSpec,
velocityAnimationSpec = velocityAnimationSpec,
scaleXAnimationSpec = scaleXAnimationSpec,
scaleYAnimationSpec = scaleYAnimationSpec
)
}
}
class MomentumAnimation(
val maxScale: Float,
private val animationScope: CoroutineScope,
private val progressAnimationSpec: FiniteAnimationSpec<Float>,
private val velocityAnimationSpec: FiniteAnimationSpec<Float>,
private val scaleXAnimationSpec: FiniteAnimationSpec<Float>,
private val scaleYAnimationSpec: FiniteAnimationSpec<Float>
) {
private val velocityTracker = VelocityTracker()
private val progressAnimation = Animatable(0f)
private val velocityAnimation = Animatable(0f)
private val scaleXAnimation = Animatable(1f)
private val scaleYAnimation = Animatable(1f)
val progress: Float get() = progressAnimation.value
val velocity: Float get() = velocityAnimation.value
val scaleX: Float get() = scaleXAnimation.value
val scaleY: Float get() = scaleYAnimation.value
var isDragging: Boolean by mutableStateOf(false)
private set
val modifier: Modifier = Modifier.pointerInput(Unit) {
inspectDragGestures(
onDragStart = {
isDragging = true
velocityTracker.resetTracking()
startPressingAnimation()
},
onDragEnd = { change ->
isDragging = false
val velocity = velocityTracker.calculateVelocity()
updateVelocity(velocity)
velocityTracker.addPointerInputChange(change)
velocityTracker.resetTracking()
endPressingAnimation()
settleVelocity()
},
onDragCancel = {
isDragging = false
velocityTracker.resetTracking()
endPressingAnimation()
settleVelocity()
}
) { change, _ ->
isDragging = true
velocityTracker.addPointerInputChange(change)
val velocity = velocityTracker.calculateVelocity()
updateVelocity(velocity)
}
}
private fun updateVelocity(velocity: Velocity) {
animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) }
}
private fun settleVelocity() {
animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
}
fun startPressingAnimation() {
animationScope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) }
launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) }
}
}
fun endPressingAnimation() {
animationScope.launch {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) }
launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) }
}
}
}