Centered Seesaw Slider

Centered Seesaw Slider

Subscribe for Live Previews, in your browser

$3 / month

Code

Seesaw slider

import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
import kotlin.math.absoluteValue
import kotlin.math.roundToInt


@Composable
fun CenteredSeeSawSlider(modifier: Modifier = Modifier) {

    var value by remember { mutableFloatStateOf(.5f) }

    val animatedValueForPlank by animateFloatAsState(
        targetValue = value,
        animationSpec = spring(
            stiffness = Spring.StiffnessHigh,
        )
    )
    val animatedValueForBall by animateFloatAsState(
        targetValue = value,
        animationSpec = spring(stiffness = 50f),
        visibilityThreshold = .00001f
    )

    Box(
        modifier = modifier
            .width(300.dp)
            .padding(vertical = 32.dp),
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            var ballBounds by remember { mutableStateOf(Rect.Zero) }
            var textBounds by remember { mutableStateOf(Rect.Zero) }
            Text(
                text = "${((animatedValueForBall * 20) - 10).roundToInt()}",
                modifier = Modifier
                    .offset {
                        IntOffset(
                            (((1f - animatedValueForBall) - .5f) * 80.dp.toPx()).roundToInt(),
                            ((animatedValueForBall - .5f).absoluteValue * 60.dp.toPx()).roundToInt(),
                        )
                    }

                    .onGloballyPositioned { textBounds = it.boundsInWindow() }
                    .drawBehind {
                        drawLine(
                            brush = Brush.verticalGradient(
                                colors = listOf(
                                    Color.Transparent,
                                    Color(0xFFFB7185),
                                    Color.White.copy(alpha = .5f),
                                    Color.White.copy(alpha = .5f),
                                ),
                                startY = ballBounds.top - textBounds.top,
                            ),
                            start = Offset(size.width / 2, size.height),
                            end = Offset(
                                ballBounds.left - textBounds.left + ballBounds.width / 2,
                                ballBounds.top - textBounds.top,
                            ),
                            strokeWidth = 2f
                        )
                    }
                    .padding(4.dp),
                color = Color.White,
                style = MaterialTheme.typography.labelSmall
            )

            Spacer(Modifier.height(32.dp))

            val center = .5f
            CenteredSlider(
                value = value,
                onValueChanged = {
                    value = it
                },
                modifier = Modifier
                    .padding(vertical = 32.dp),
                valueRange = 0f..1f,
                center = center,
                centerThreshold = 0.0f,
                thumb = {},
                centerTrack = { _ ->
                    var width by remember { mutableFloatStateOf(1f) }
                    Column(
                        Modifier
                            .offset(y = (-16).dp)
                            .onSizeChanged { width = it.width.toFloat() }
                            .fillMaxWidth()
                            .graphicsLayer {
                                transformOrigin = TransformOrigin(
                                    pivotFractionX = center,
                                    pivotFractionY = .5f,
                                )
                                rotationZ = lerp(
                                    start = -10f,
                                    stop = 10f,
                                    fraction = animatedValueForPlank
                                )
                            }
                    ) {
                        Box(
                            Modifier
                                .padding(vertical = 2.dp)
                                .offset {
                                    IntOffset(
                                        x = lerp(
                                            start = 0f,
                                            stop = width - 32.dp.toPx(),
                                            fraction = when {
                                                animatedValueForBall < 0f -> animatedValueForBall.absoluteValue
                                                animatedValueForBall > 1f -> 1f - ((animatedValueForBall - 1f))
                                                else -> animatedValueForBall
                                            },
                                        ).roundToInt(), 0
                                    )
                                }
                                .onGloballyPositioned {
                                    ballBounds = it.boundsInWindow()
                                }
                                .graphicsLayer {
                                    rotationZ = lerp(0f, 360f, animatedValueForBall)
                                }
                        ) {

                            Box(
                                Modifier
                                    .size(32.dp)
                                    .blur(
                                        radius = 40.dp,
                                        edgeTreatment = BlurredEdgeTreatment.Unbounded
                                    )
                                    .drawBall()
                            )
                            Box(
                                Modifier
                                    .size(32.dp)
                                    .blur(
                                        radius = 10.dp,
                                        edgeTreatment = BlurredEdgeTreatment.Unbounded
                                    )
                                    .drawBall()

                            )
                            Box(
                                Modifier
                                    .size(32.dp)
                                    .drawBall()
                            )

                        }
                        Box(
                            Modifier
                                .zIndex(10f)
                                .fillMaxWidth()
                                .height(12.dp)
                                .background(
                                    Color(0xFF020617),
                                    shape = CircleShape
                                )
                                .border(
                                    width = 1.dp,
                                    brush = Brush.verticalGradient(
                                        colors = listOf(
                                            Color.Transparent,
                                            Color.Transparent,
                                            Color(0xFFCBD5E1),
                                        )
                                    ),
                                    shape = CircleShape
                                )
                                .border(
                                    width = 1.dp,
                                    brush = Brush.radialGradient(
                                        colors = listOf(
                                            Color(0xFFF43F5E),
                                            Color(0xFFCBD5E1),
                                        ),
                                        center = Offset(
                                            width * animatedValueForBall,
                                            y = -10f,
                                        ),
                                        radius = width * .3f
                                    ),
                                    shape = CircleShape
                                )
                        )
                    }


                },
                centerIndicator = {
                    Box(
                        Modifier
                            .width(12.dp)
                            .height(32.dp)
                            .offset(y = 24.dp)
                            .border(
                                width = 1.dp,
                                brush = Brush.verticalGradient(
                                    colors = listOf(
                                        Color(0xFF64748B),
                                        Color(0xFFF1F5F9),
                                    )
                                ),
                                shape = TriangleShape
                            )
                    )

                },
            )
        }
    }

}


fun Modifier.drawBall() =
    this.background(
        brush = Brush.verticalGradient(
            colors = listOf(
                Color(0xFF4C0519).copy(alpha = 0f),
                Color(0xFFBE123C).copy(alpha = .5f),
            )
        ),
        shape = CircleShape,
    ).border(
        width = 1.dp,
        brush = Brush.verticalGradient(
            colors = listOf(
                Color(0xFFFB7185),
                Color(0xFFFFE4E6),
            )
        ),
        shape = CircleShape,
    )

val TriangleShape = Triangle()

class Triangle : Shape {
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline = Outline.Generic(
        Path().apply {
            moveTo(0f, size.height)
            lineTo(size.width / 2, 0f)
            lineTo(size.width, size.height)
            close()
        }
    )
}

Centered Slider

Centered Slider in Jetpack Compose
Creating a glowy centered slider based on the material 3 slider
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlin.math.absoluteValue
import kotlin.math.roundToInt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CenteredSlider(
    value: Float,
    onValueChanged: (Float) -> Unit,
    modifier: Modifier = Modifier,
    valueRange: ClosedFloatingPointRange<Float> = -1f..1f,
    thumb: @Composable () -> Unit = DefaultThumb,
    center: Float = 0f,
    centerThreshold: Float = .05f,
    centerIndicator: @Composable () -> Unit = DefaultCenterIndicator,
    centerTrack: @Composable (@Composable (@Composable (Boolean) -> Unit) -> Unit) -> Unit = DefaultTrack,
) {

    val hapticFeedback = LocalHapticFeedback.current
    val valueState = rememberUpdatedState(value)
    LaunchedEffect(Unit) {
        snapshotFlow { valueState.value }
            .map { it == center }
            .filter { it }
            .drop(if (value == center) 1 else 0)
            .collect {
                hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
            }
    }

    Slider(
        value = value,
        onValueChange = {
            onValueChanged(
                when {
                    (it - center).absoluteValue < centerThreshold -> center
                    else -> it
                }
            )
        },
        modifier = modifier,
        valueRange = valueRange,
        thumb = {
            Box(contentAlignment = Alignment.Center) {
                thumb()
            }
        },
        track = { sliderState ->

            val fraction by remember {
                derivedStateOf {
                    (sliderState.value - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start)
                }
            }

            val centerFraction by remember {
                derivedStateOf {
                    (center - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start)
                }
            }

            BoxWithConstraints(
                contentAlignment = Alignment.Center
            ) {
                val isAboveCenter = fraction > centerFraction
                val width = this@BoxWithConstraints.maxWidth
                centerTrack { activeSection ->
                    Box(
                        modifier = Modifier
                            .align(Alignment.CenterStart)
                            .offset {
                                when {
                                    isAboveCenter -> IntOffset(
                                        x = (width.toPx() * centerFraction).roundToInt(),
                                        y = 0
                                    )

                                    else -> IntOffset(
                                        x = (width.toPx() * fraction).roundToInt(),
                                        y = 0
                                    )
                                }
                            }
                            .width(
                                when {
                                    isAboveCenter -> width * (fraction - centerFraction)
                                    else -> width * (centerFraction - fraction)
                                }
                            )
                            .height(20.dp),
                        contentAlignment = Alignment.Center,
                        content = { activeSection(isAboveCenter) }
                    )
                }

                Box(
                    Modifier
                        .align(Alignment.CenterStart)
                        .offset {
                            IntOffset(
                                x = (width.toPx() * centerFraction).roundToInt(),
                                y = 0
                            )
                        }
                        .centerHorizontally(),
                    content = { centerIndicator() }
                )
            }
        }
    )
}


private val DefaultThumb = @Composable {
    Box(
        Modifier
            .size(24.dp)
            .padding(4.dp)
            .background(MaterialTheme.colorScheme.primary, CircleShape)
    )
}

private val DefaultTrack: @Composable (@Composable (@Composable (Boolean) -> Unit) -> Unit) -> Unit =
    @Composable { activeSection ->
        Box(
            Modifier
                .fillMaxWidth()
                .height(4.dp)
                .background(
                    color = MaterialTheme.colorScheme.onSurface.copy(alpha = .2f),
                    shape = CircleShape
                )
        )
        activeSection { isAboveCenter ->
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(4.dp)
                    .background(MaterialTheme.colorScheme.primary, shape = CircleShape)
            )
        }
    }

private val DefaultCenterIndicator = @Composable {
    Box(
        Modifier
            .height(24.dp)
            .width(2.dp)
            .background(
                color = MaterialTheme.colorScheme.primary.copy(alpha = 1f),
                CircleShape
            )
    )
}


@Stable
@Composable
fun Modifier.centerHorizontally(): Modifier {
    var width by remember { mutableIntStateOf(0) }
    return onSizeChanged { width = it.width }
        .offset { IntOffset(x = -width / 2, y = 0) }
}
Mastodon