mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-30 18:16:42 +00:00
android: improve liquid glass sliders
This commit is contained in:
@@ -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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user