android: improve liquid glass sliders

This commit is contained in:
Kavish Devar
2025-09-23 00:27:39 +05:30
parent 4bc76de750
commit 8760757b76

View File

@@ -18,7 +18,6 @@
package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
@@ -29,9 +28,9 @@ import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@@ -60,6 +59,9 @@ import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -96,8 +98,7 @@ fun StyledSlider(
endIcon: String? = null,
startLabel: String? = null,
endLabel: String? = null,
independent: Boolean = false,
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
independent: Boolean = false
) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isLightTheme = !isSystemInDarkTheme()
@@ -119,213 +120,234 @@ fun StyledSlider(
val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val progressAnimation = remember { Animatable(0f) }
val trackBackdrop = rememberBackdrop()
val innerShadowLayer =
rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen
}
val sliderBackdrop = rememberBackdrop()
val trackWidthState = remember { mutableFloatStateOf(0f) }
val trackPositionState = remember { mutableFloatStateOf(0f) }
val startIconWidthState = remember { mutableFloatStateOf(0f) }
val endIconWidthState = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current
val content = @Composable {
Column(
modifier = modifier.fillMaxWidth(1f).padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (label != null) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
if (startLabel != null || endLabel != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
Box(Modifier.fillMaxWidth()) {
Box(Modifier
.backdrop(sliderBackdrop)
.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth(1f)
.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = startLabel ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = endLabel ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
if (startIcon != null) {
Text(
text = startIcon,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(horizontal = 12.dp)
)
}
BoxWithConstraints(
Modifier
.weight(1f),
contentAlignment = Alignment.CenterStart
) {
val density = LocalDensity.current
val trackWidth = constraints.maxWidth
Box(Modifier.backdrop(trackBackdrop)) {
Box(
Modifier
.clip(RoundedCornerShape(28.dp))
.background(trackColor)
.height(6f.dp)
.fillMaxWidth()
)
Box(
Modifier
.clip(RoundedCornerShape(28.dp))
.background(accentColor)
.height(6f.dp)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val fraction = fraction
val width = (fraction * constraints.maxWidth).fastRoundToInt()
layout(width, placeable.height) {
placeable.place(0, 0)
}
}
if (label != null) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Box(
Modifier
.graphicsLayer {
val fraction = fraction
translationX =
(-size.width / 2f + fraction * trackWidth)
.fastCoerceIn(
-size.width / 4f,
trackWidth - size.width * 3f / 4f
)
}
.draggable(
rememberDraggableState { delta ->
val trackWidth = trackWidth - with(density) { 40f.dp.toPx() }
val targetFraction = fraction + delta / trackWidth
val targetValue =
lerp(valueRange.start, valueRange.endInclusive, targetFraction)
.fastCoerceIn(valueRange.start, valueRange.endInclusive)
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
targetValue,
snapPoints,
snapThreshold
) else targetValue
onValueChange(snappedValue)
},
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
animationScope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec)
}
},
onDragStopped = {
animationScope.launch {
progressAnimation.animateTo(0f, progressAnimationSpec)
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
}
}
if (startLabel != null || endLabel != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = startLabel ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
.drawBackdrop(
rememberCombinedBackdropDrawer(backdrop, trackBackdrop),
{ RoundedCornerShape(28.dp) },
highlight = {
val progress = progressAnimation.value
Highlight.AmbientDefault.copy(alpha = progress)
},
shadow = {
Shadow(
elevation = 4f.dp,
color = Color.Black.copy(0.08f)
)
},
layer = {
val progress = progressAnimation.value
val scale = lerp(1f, 1.5f, progress)
scaleX = scale
scaleY = scale
},
onDrawSurface = {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
Text(
text = endLabel ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
val shape = RoundedCornerShape(28.dp)
val outline = shape.createOutline(size, layoutDirection, this)
val innerShadowOffset = 4f.dp.toPx()
val innerShadowBlurRadius = 4f.dp.toPx()
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
if (startIcon != null) {
Text(
text = startIcon,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 12.dp)
.onGloballyPositioned {
startIconWidthState.floatValue = it.size.width.toFloat()
}
)
}
Box(
Modifier
.weight(1f)
.onSizeChanged { trackWidthState.floatValue = it.width.toFloat() }
.onGloballyPositioned {
trackPositionState.floatValue =
it.positionInParent().y + it.size.height / 2f
}
) {
Box(
Modifier
.clip(RoundedCornerShape(28.dp))
.background(trackColor)
.height(6f.dp)
.fillMaxWidth()
)
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
)
Box(
Modifier
.clip(RoundedCornerShape(28.dp))
.background(accentColor)
.height(6f.dp)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val fraction = fraction
val width =
(fraction * constraints.maxWidth).fastRoundToInt()
layout(width, placeable.height) {
placeable.place(0, 0)
}
}
drawLayer(innerShadowLayer)
drawRect(Color.White.copy(1f - progress))
}
) {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
}
.size(40f.dp, 24f.dp)
)
}
if (endIcon != null) {
Text(
text = endIcon,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(horizontal = 12.dp)
)
)
}
if (endIcon != null) {
Text(
text = endIcon,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 12.dp)
.onGloballyPositioned {
endIconWidthState.floatValue = it.size.width.toFloat()
}
)
}
}
}
}
Box(
Modifier
.graphicsLayer {
val startOffset =
if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else 0f
translationX =
startOffset + fraction * trackWidthState.floatValue - size.width / 2f
translationY = trackPositionState.floatValue / 2f
}
.draggable(
rememberDraggableState { delta ->
val trackWidth = trackWidthState.floatValue
if (trackWidth > 0f) {
val targetFraction = fraction + delta / trackWidth
val targetValue =
lerp(valueRange.start, valueRange.endInclusive, targetFraction)
.fastCoerceIn(valueRange.start, valueRange.endInclusive)
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
targetValue,
snapPoints,
snapThreshold
) else targetValue
onValueChange(snappedValue)
}
},
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
animationScope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec)
}
},
onDragStopped = {
animationScope.launch {
progressAnimation.animateTo(0f, progressAnimationSpec)
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
}
}
)
.drawBackdrop(
rememberCombinedBackdropDrawer(backdrop, sliderBackdrop),
{ RoundedCornerShape(28.dp) },
highlight = {
val progress = progressAnimation.value
Highlight.AmbientDefault.copy(alpha = progress)
},
shadow = {
Shadow(
elevation = 4f.dp,
color = Color.Black.copy(0.08f)
)
},
layer = {
val progress = progressAnimation.value
val scale = lerp(1f, 1.5f, progress)
scaleX = scale
scaleY = scale
},
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))
}
) {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
}
.size(40f.dp, 24f.dp)
)
}
}
@@ -350,15 +372,30 @@ private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.
return if (kotlin.math.abs(nearest - value) <= threshold) nearest else value
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
@Composable
fun StyledSliderPreview() {
StyledSlider(
mutableFloatState = remember {mutableFloatStateOf(1f)},
onValueChange = {},
valueRange = 0f..2f,
independent = true,
startIcon = "A",
endIcon = "B"
)
val a = remember { mutableFloatStateOf(1f) }
Box(
Modifier
.background(if (isSystemInDarkTheme()) Color(0xFF121212) else Color(0xFFF0F0F0))
.padding(16.dp)
.fillMaxSize()
) {
Box (
Modifier.align(Alignment.Center)
)
{
StyledSlider(
mutableFloatState = a,
onValueChange = {
a.floatValue = it
},
valueRange = 0f..2f,
independent = true,
startIcon = "A",
endIcon = "B"
)
}
}
}