diff --git a/catalog/release/baselineProfiles/0/catalog-release.dm b/catalog/release/baselineProfiles/0/catalog-release.dm index 4c979b9..c14bb20 100644 Binary files a/catalog/release/baselineProfiles/0/catalog-release.dm and b/catalog/release/baselineProfiles/0/catalog-release.dm differ diff --git a/catalog/release/baselineProfiles/1/catalog-release.dm b/catalog/release/baselineProfiles/1/catalog-release.dm index 26c0b30..14f0dde 100644 Binary files a/catalog/release/baselineProfiles/1/catalog-release.dm and b/catalog/release/baselineProfiles/1/catalog-release.dm differ diff --git a/catalog/release/catalog-release.apk b/catalog/release/catalog-release.apk index 232616b..b4c9615 100644 Binary files a/catalog/release/catalog-release.apk and b/catalog/release/catalog-release.apk differ diff --git a/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidBottomTab.kt b/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidBottomTab.kt index 0bb9c38..841633b 100644 --- a/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidBottomTab.kt +++ b/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidBottomTab.kt @@ -24,6 +24,8 @@ fun RowScope.LiquidBottomTab( modifier .clip(ContinuousCapsule) .clickable( + interactionSource = null, + indication = null, role = Role.Tab, onClick = onClick ) diff --git a/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidBottomTabs.kt b/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidBottomTabs.kt index 3d2c7af..86c7b5f 100644 --- a/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidBottomTabs.kt +++ b/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidBottomTabs.kt @@ -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), diff --git a/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidSlider.kt b/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidSlider.kt index 9ceb7d1..ec3452f 100644 --- a/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidSlider.kt +++ b/catalog/src/main/java/com/kyant/backdrop/catalog/components/LiquidSlider.kt @@ -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) diff --git a/catalog/src/main/java/com/kyant/backdrop/catalog/utils/MomentumAnimation.kt b/catalog/src/main/java/com/kyant/backdrop/catalog/utils/MomentumAnimation.kt new file mode 100644 index 0000000..4c9dc52 --- /dev/null +++ b/catalog/src/main/java/com/kyant/backdrop/catalog/utils/MomentumAnimation.kt @@ -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 = + spring(1f, 1000f, 0.01f), + velocityAnimationSpec: FiniteAnimationSpec = + spring(0.5f, 250f, 0.01f), + scaleXAnimationSpec: FiniteAnimationSpec = + spring(0.4f, 400f, 0.01f), + scaleYAnimationSpec: FiniteAnimationSpec = + 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, + private val velocityAnimationSpec: FiniteAnimationSpec, + private val scaleXAnimationSpec: FiniteAnimationSpec, + private val scaleYAnimationSpec: FiniteAnimationSpec +) { + + 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) } + } + } +}