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 a86e401..b5c629d 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 @@ -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, 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" + ) + } + } }