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 package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn 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.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.layout.layout 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.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -96,8 +98,7 @@ fun StyledSlider(
endIcon: String? = null, endIcon: String? = null,
startLabel: String? = null, startLabel: String? = null,
endLabel: String? = null, endLabel: String? = null,
independent: Boolean = false, independent: Boolean = false
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
) { ) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isLightTheme = !isSystemInDarkTheme() val isLightTheme = !isSystemInDarkTheme()
@@ -119,213 +120,234 @@ fun StyledSlider(
val animationScope = rememberCoroutineScope() val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
val trackBackdrop = rememberBackdrop()
val innerShadowLayer = val innerShadowLayer =
rememberGraphicsLayer().apply { rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen 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 { val content = @Composable {
Column( Box(Modifier.fillMaxWidth()) {
modifier = modifier.fillMaxWidth(1f).padding(vertical = 8.dp), Box(Modifier
horizontalAlignment = Alignment.CenterHorizontally, .backdrop(sliderBackdrop)
verticalArrangement = Arrangement.spacedBy(8.dp) .fillMaxWidth()) {
) { Column(
if (label != null) { modifier = Modifier
Text( .fillMaxWidth(1f)
text = label, .padding(vertical = 8.dp),
style = TextStyle( horizontalAlignment = Alignment.CenterHorizontally,
fontSize = 16.sp, verticalArrangement = Arrangement.spacedBy(8.dp)
fontWeight = FontWeight.Medium,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
if (startLabel != null || endLabel != null) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( if (label != null) {
text = startLabel ?: "", Text(
style = TextStyle( text = label,
fontSize = 16.sp, style = TextStyle(
fontWeight = FontWeight.Normal, fontSize = 16.sp,
color = labelTextColor, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)) 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)
}
}
) )
} }
Box( if (startLabel != null || endLabel != null) {
Modifier Row(
.graphicsLayer { modifier = Modifier.fillMaxWidth(),
val fraction = fraction horizontalArrangement = Arrangement.SpaceBetween
translationX = ) {
(-size.width / 2f + fraction * trackWidth) Text(
.fastCoerceIn( text = startLabel ?: "",
-size.width / 4f, style = TextStyle(
trackWidth - size.width * 3f / 4f fontSize = 16.sp,
) fontWeight = FontWeight.Normal,
} color = labelTextColor,
.draggable( fontFamily = FontFamily(Font(R.font.sf_pro))
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)
}
}
) )
.drawBackdrop( Text(
rememberCombinedBackdropDrawer(backdrop, trackBackdrop), text = endLabel ?: "",
{ RoundedCornerShape(28.dp) }, style = TextStyle(
highlight = { fontSize = 16.sp,
val progress = progressAnimation.value fontWeight = FontWeight.Normal,
Highlight.AmbientDefault.copy(alpha = progress) color = labelTextColor,
}, fontFamily = FontFamily(Font(R.font.sf_pro))
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) Row(
val outline = shape.createOutline(size, layoutDirection, this) modifier = Modifier.fillMaxWidth(),
val innerShadowOffset = 4f.dp.toPx() verticalAlignment = Alignment.CenterVertically,
val innerShadowBlurRadius = 4f.dp.toPx() 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 Box(
innerShadowLayer.renderEffect = Modifier
BlurEffect( .clip(RoundedCornerShape(28.dp))
innerShadowBlurRadius, .background(accentColor)
innerShadowBlurRadius, .height(6f.dp)
TileMode.Decal .layout { measurable, constraints ->
) val placeable = measurable.measure(constraints)
innerShadowLayer.record { val fraction = fraction
drawOutline(outline, Color.Black.copy(0.2f)) val width =
translate(0f, innerShadowOffset) { (fraction * constraints.maxWidth).fastRoundToInt()
drawOutline( layout(width, placeable.height) {
outline, placeable.place(0, 0)
Color.Transparent,
blendMode = BlendMode.Clear
)
} }
} }
drawLayer(innerShadowLayer) )
}
drawRect(Color.White.copy(1f - progress)) if (endIcon != null) {
} Text(
) { text = endIcon,
refractionWithDispersion(6f.dp.toPx(), size.height / 2f) style = TextStyle(
} fontSize = 16.sp,
.size(40f.dp, 24f.dp) fontWeight = FontWeight.Normal,
) color = labelTextColor,
} fontFamily = FontFamily(Font(R.font.sf_pro))
if (endIcon != null) { ),
Text( modifier = Modifier
text = endIcon, .padding(horizontal = 12.dp)
style = TextStyle( .onGloballyPositioned {
fontSize = 16.sp, endIconWidthState.floatValue = it.size.width.toFloat()
fontWeight = FontWeight.Normal, }
color = labelTextColor, )
fontFamily = FontFamily(Font(R.font.sf_pro)) }
), }
modifier = Modifier.padding(horizontal = 12.dp)
)
} }
} }
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 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 @Composable
fun StyledSliderPreview() { fun StyledSliderPreview() {
StyledSlider( val a = remember { mutableFloatStateOf(1f) }
mutableFloatState = remember {mutableFloatStateOf(1f)}, Box(
onValueChange = {}, Modifier
valueRange = 0f..2f, .background(if (isSystemInDarkTheme()) Color(0xFF121212) else Color(0xFFF0F0F0))
independent = true, .padding(16.dp)
startIcon = "A", .fillMaxSize()
endIcon = "B" ) {
) Box (
Modifier.align(Alignment.Center)
)
{
StyledSlider(
mutableFloatState = a,
onValueChange = {
a.floatValue = it
},
valueRange = 0f..2f,
independent = true,
startIcon = "A",
endIcon = "B"
)
}
}
} }