android: update styled slider thumb

This commit is contained in:
Kavish Devar
2025-09-30 11:33:46 +05:30
parent 993f022087
commit 8b49440d6b
9 changed files with 175 additions and 67 deletions

View File

@@ -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"))))

Binary file not shown.

View File

@@ -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(

View File

@@ -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,

View File

@@ -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<Float> =
spring(1f, 1000f, 0.01f),
velocityAnimationSpec: FiniteAnimationSpec<Float> =
spring(0.5f, 250f, 5f),
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) }
}
}
}
@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<Float>, 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",
)
}
}

View File

@@ -176,7 +176,7 @@ fun StyledSwitch(
{ RoundedCornerShape(thumbHeight / 2) },
highlight = {
val progress = progressAnimation.value
Highlight.AmbientDefault.copy(
Highlight.Ambient.copy(
alpha = progress
)
},

View File

@@ -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),

View File

@@ -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" }