diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 07a253a..fbbc463 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(libs.haze) implementation(libs.haze.materials) implementation(libs.androidx.dynamicanimation) + implementation(libs.androidx.compose.ui) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.compose.foundation.layout) // compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) diff --git a/android/app/libs/backdrop-debug.aar b/android/app/libs/backdrop-debug.aar index 2a687c7..9ed9a71 100644 Binary files a/android/app/libs/backdrop-debug.aar and b/android/app/libs/backdrop-debug.aar differ diff --git a/android/app/libs/backdrop-release.aar b/android/app/libs/backdrop-release.aar index 6aad2de..bfddcab 100644 Binary files a/android/app/libs/backdrop-release.aar and b/android/app/libs/backdrop-release.aar differ diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt index 41258bf..01438bd 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt @@ -137,7 +137,7 @@ half4 main(float2 coord) { } }, onDrawFront = null, - highlight = { Highlight.AmbientDefault.copy(alpha = 0f) } + highlight = { Highlight.Ambient.copy(alpha = 0f) } ) } else { Modifier.drawBackdrop( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt index 2f59f72..d666b63 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt @@ -124,7 +124,7 @@ half4 main(float2 coord) { .drawBackdrop( backdrop = backdrop, shape = { RoundedCornerShape(56.dp) }, - highlight = { Highlight.AmbientDefault.copy(alpha = if (isDarkTheme) 1f else 0f) }, + highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) }, shadow = { Shadow( radius = 48f.dp, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt index caa1644..3aba126 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt @@ -21,6 +21,7 @@ package me.kavishdevar.librepods.composables import android.content.res.Configuration import android.util.Log import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.spring import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation @@ -46,21 +47,18 @@ import androidx.compose.runtime.MutableFloatState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.BlurEffect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.TileMode -import androidx.compose.ui.graphics.drawOutline -import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.layer.CompositingStrategy -import androidx.compose.ui.graphics.layer.drawLayer -import androidx.compose.ui.graphics.rememberGraphicsLayer +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.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged @@ -71,6 +69,7 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastCoerceIn @@ -81,14 +80,129 @@ import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberCombinedBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop 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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.inspectDragGestures import kotlin.math.abs import kotlin.math.roundToInt +@Composable +fun rememberMomentumAnimation( + maxScale: Float, + progressAnimationSpec: FiniteAnimationSpec = + spring(1f, 1000f, 0.01f), + velocityAnimationSpec: FiniteAnimationSpec = + spring(0.5f, 250f, 5f), + 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) } + } + } +} + @Composable fun StyledSlider( label: String? = null, @@ -122,14 +236,6 @@ fun StyledSlider( } } - val animationScope = rememberCoroutineScope() - val progressAnimationSpec = spring(0.5f, 300f, 0.001f) - val progressAnimation = remember { Animatable(0f) } - val innerShadowLayer = - rememberGraphicsLayer().apply { - compositingStrategy = CompositingStrategy.Offscreen - } - val sliderBackdrop = rememberLayerBackdrop() val trackWidthState = remember { mutableFloatStateOf(0f) } val trackPositionState = remember { mutableFloatStateOf(0f) } @@ -137,6 +243,8 @@ fun StyledSlider( val endIconWidthState = remember { mutableFloatStateOf(0f) } val density = LocalDensity.current + val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f) + val content = @Composable { Box( Modifier @@ -301,10 +409,22 @@ fun StyledSlider( Box( Modifier .graphicsLayer { +// val startOffset = +// if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() } +// translationX = +// startOffset + fraction * trackWidthState.floatValue - size.width / 2f val startOffset = - if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() } + if (startIcon != null) + startIconWidthState.floatValue + with(density) { 24.dp.toPx() } + else + with(density) { 8.dp.toPx() } + translationX = - startOffset + fraction * trackWidthState.floatValue - size.width / 2f + (startOffset + fraction * trackWidthState.floatValue - size.width / 2f) + .fastCoerceIn( + startOffset - size.width / 4f, + startOffset + trackWidthState.floatValue - size.width * 3f / 4f + ) translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() } } .draggable( @@ -326,23 +446,20 @@ fun StyledSlider( Orientation.Horizontal, startDragImmediately = true, onDragStarted = { - animationScope.launch { - progressAnimation.animateTo(1f, progressAnimationSpec) - } + // Remove this block as momentumAnimation handles pressing }, onDragStopped = { - animationScope.launch { - progressAnimation.animateTo(0f, progressAnimationSpec) - onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f) - } + // Remove this block as momentumAnimation handles pressing + onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f) } ) + .then(momentumAnimation.modifier) .drawBackdrop( rememberCombinedBackdrop(backdrop, sliderBackdrop), { RoundedCornerShape(28.dp) }, highlight = { - val progress = progressAnimation.value - Highlight.AmbientDefault.copy(alpha = progress) + val progress = momentumAnimation.progress + Highlight.Ambient.copy(alpha = progress) }, shadow = { Shadow( @@ -350,43 +467,31 @@ fun StyledSlider( color = Color.Black.copy(0.05f) ) }, + innerShadow = { + val progress = momentumAnimation.progress + InnerShadow( + radius = 4f.dp * progress, + alpha = 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) - - val shape = RoundedCornerShape(28.dp) - val outline = shape.createOutline(size, layoutDirection, this) - val innerShadowOffset = 4f.dp.toPx() - val innerShadowBlurRadius = 4f.dp.toPx() - - innerShadowLayer.alpha = progress - innerShadowLayer.renderEffect = - BlurEffect( - innerShadowBlurRadius, - innerShadowBlurRadius, - TileMode.Decal - ) - innerShadowLayer.record { - drawOutline(outline, Color.Black.copy(0.2f)) - translate(0f, innerShadowOffset) { - drawOutline( - outline, - Color.Transparent, - blendMode = BlendMode.Clear - ) - } - } - drawLayer(innerShadowLayer) - - drawRect(Color.White.copy(1f - progress)) + val progress = momentumAnimation.progress + drawRect(Color.White.copy(alpha = 1f - 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 + ) } ) .size(40f.dp, 24f.dp) @@ -454,7 +559,7 @@ private fun snapIfClose(value: Float, points: List, threshold: Float = 0. @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable fun StyledSliderPreview() { - val a = remember { mutableFloatStateOf(1f) } + val a = remember { mutableFloatStateOf(0.5f) } Box( Modifier .background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0)) @@ -471,11 +576,11 @@ fun StyledSliderPreview() { a.floatValue = it }, valueRange = 0f..2f, - snapPoints = listOf(0f, 0.5f, 1f, 1.5f, 2f), + snapPoints = listOf(1f), snapThreshold = 0.1f, independent = true, - startLabel = "A", - endLabel = "B", + startIcon = "A", + endIcon = "B", ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt index 1a1e21e..a982cd8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt @@ -176,7 +176,7 @@ fun StyledSwitch( { RoundedCornerShape(thumbHeight / 2) }, highlight = { val progress = progressAnimation.value - Highlight.AmbientDefault.copy( + Highlight.Ambient.copy( alpha = progress ) }, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index 60be94c..d7e8df3 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -310,7 +310,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, exportedBackdrop = backdrop, shape = { RoundedCornerShape(0.dp) }, highlight = { - Highlight.AmbientDefault.copy(alpha = 0f) + Highlight.Ambient.copy(alpha = 0f) } ) .padding(horizontal = 8.dp), diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 94dfbc9..554e880 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -16,6 +16,7 @@ dynamicanimation = "1.1.0" foundationLayout = "1.9.1" uiTooling = "1.9.1" mockk = "1.14.3" +ui = "1.9.2" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -37,6 +38,7 @@ androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynam androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } +androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }