Create Custom Android Circular Slider in Compose
Create a custom Circular Slider in Android Compose using a Canvas Composable.

In this post I will show you a sample of how to create a custom Android circular slider in Compose.

Android Circular Slider

Process

The base element used is a Canvas. The Canvas has a method to capture the users touch coordinates to feed into a value for further processing.

Some of the values set are arbitrary that you may want to change for your own use case in your project.

Code

MainActivity

This is the MainActivity.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            SampleCircularSliderTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    CircularSlider(
                        initialAngle = 54f, // 15%
                        desiredSize = 300,
                        handleColor = Color.Blue,
                        inactiveTrackColor = Color.Red.copy(alpha = 0.1f),
                        activeTrackColor = Color.Green,
                        fontSize = 35,
                        fontColor = Color.Black,
                        modifier = Modifier.padding(innerPadding)
                    ) {

                    }
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    SampleCircularSliderTheme {
        CircularSlider(
            initialAngle = 54f,
            desiredSize = 300,
            handleColor = Color.Blue,
            inactiveTrackColor = Color.Red.copy(alpha = 0.1f),
            activeTrackColor = Color.Green,
            fontSize = 35,
            fontColor = Color.Black
        ) {

        }
    }
}

CircularSlider

This is the main composable function for the Android Circular Slider.

Breakdown

There are a few functions added to help with calculations.

This function takes the center coordinates of the custom element as well as the user dragoffset coordinates to calculate the angle of the circle the user is interacting with.
This function takes the user dragoffset coordinates the calculate where to place the handle at the perimeter of the circle.

Callback

I added a callback function so you can send the value captured back to your main project after it has been processed.

onValueChanged : (Float) -> Unit
@Composable
fun CircularSlider(
    initialAngle: Float,
    desiredSize: Int,
    handleColor : Color,
    inactiveTrackColor: Color,
    activeTrackColor: Color,
    fontSize: Int,
    fontColor: Color,
    modifier: Modifier = Modifier,
    onValueChanged : (Float) -> Unit
) {
    var dragOffset by remember {
        mutableStateOf(Offset.Zero)
    }

    var newAngle by remember {
        mutableStateOf(initialAngle)
    }
    var mSize by remember {
        mutableStateOf(IntSize.Zero)
    }
    val textMeasurer = rememberTextMeasurer()

    val animateBGColor by animateColorAsState(targetValue = if(newAngle <= 52){ Color.Red.copy(alpha = 0.1f)} else { activeTrackColor.copy(alpha = 0.1f)})

    Canvas(
        modifier = modifier
            .size(desiredSize.dp)
            .padding(30.dp)
            .onGloballyPositioned {
                mSize = it.size
            }
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    dragOffset = change.position
                    newAngle = calculateNewAngle(mSize.center.toOffset(), dragOffset)
                }
            }
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        dragOffset = it
                        newAngle = calculateNewAngle(mSize.center.toOffset(), dragOffset)
                    }
                )
            }

    ) {
        drawCircle(
            brush = Brush.radialGradient(
                0.0f to Color.Transparent,
                0.25f to animateBGColor,
                1.0f to Color.Black.copy(alpha = 0.25f),

                ),
            radius = mSize.width.toFloat() / 2 - 10
        )
        drawCircle(
            color = inactiveTrackColor,
            radius = size.width / 2,
            center = center,
            style = Stroke(
                20f,
                cap = StrokeCap.Round
            )
        )
        drawArc(
            color = animateBGColor.copy(alpha = 1.0f),
            startAngle = 0f,
            sweepAngle = newAngle,
            style = Stroke(
                30f,
                cap = StrokeCap.Round
            ),
            useCenter = false
        )

        drawCircle(
            color = handleColor,
            radius = 30f,
            center = calculateHandleOffset(mSize, newAngle),
            style = Fill
        )
        drawContext.canvas.nativeCanvas.apply {
            drawText(
                textMeasurer = textMeasurer,
                text = "${(newAngle / 360 * 100).roundToInt()}%",
                topLeft = Offset(center.x - fontSize * 2, center.y - fontSize * 2),
                style = TextStyle(
                    fontSize = fontSize.sp,
                    textAlign = TextAlign.Center,
                    color = fontColor
                )
            )
        }
    }
}

fun calculateNewAngle(mCenter: Offset, dragOffset: Offset): Float {
    val rad = atan2((mCenter.y - dragOffset.y).toDouble(), (mCenter.x - dragOffset.x).toDouble())
    val angle = Math.toDegrees(rad)
    return (angle + 180f).roundToInt().toFloat()
}

fun calculateHandleOffset(size: IntSize, newAngle: Float): Offset {
    return Offset(
        (size.center.x + Math.cos(Math.toRadians(newAngle.toDouble())) * (size.width / 2)).toFloat(),
        (size.center.y + Math.sin(Math.toRadians(newAngle.toDouble())) * (size.height / 2)).toFloat()
    )
}

Hope this was useful.