Making a custom Semicircular Slider and grasping the concept of Canvas in Jetpack Compose

Zaw Wynn Myat
6 min readJun 8, 2024

With the aid of Jetpack Compose canvas, we will make custom semicircular slider which will be able to seek and drag. After reading and following this article, I’m sure that you will get the result elucidated above. The key concepts involved in this article are Arc, Circle and Math.

Figure 1: The final result of this article

To embark upon, we need to understand how to draw and arc.

To draw an arc, you must deal with the following stuff.

  • color
  • startAngle
  • sweepAngle

Obviously, color is the visual element which is added to the arc.

And so, what are “startAngle” and “sweepAngle”. Keep calm, I will elaborate this.

startAngle is the starting angle in degrees. (0 means 3 o’clock).

sweepAngle is the size of the arc in degrees that is drawn in clockwise direction, coherence with the startAngle.

To grasp the concept, let’s take a look up the diagram described below.

Figure 2: Degrees of a circle

For instance, if we want to draw an Arc from 90 degree to 240 degree,

startAngle = 90f ,and

sweepAngle = 240–90 = 150f

Canvas(
modifier = Modifier
.size(350.dp)
.background(Color.Gray.copy(0.3f)),
onDraw = {
drawArc(
color = Color.Blue,
startAngle = 90f,
sweepAngle = 150f,
useCenter = true
)
}
)

The size of canvas is 350 dp and it has given as a fixed value. In the onDraw parameter of the canvas, I draw an arc.

Figure 3: Quarter Circle

However, it is NOT an arc, it is just a quarter circle. How to fix that? It is completely straightforward, just modify “useCenter” and “style” parameters of the “drawArc”.

Canvas(
modifier = Modifier
.size(350.dp)
.background(Color.Gray.copy(0.3f)),
onDraw = {
drawArc(
color = Color.Blue,
startAngle = 90f,
sweepAngle = 150f,
useCenter = true,
style = Stroke(
width = 10f,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}
)
Figure: 4

When we set “useCenter” to “false”,


Canvas(
modifier = Modifier
.size(350.dp)
.background(Color.Gray.copy(0.3f)),
onDraw = {
drawArc(
color = Color.Blue,
startAngle = 90f,
sweepAngle = 150f,
useCenter = false,
style = Stroke(
width = 10f,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)
}
)
Figure 5: Arc

Now, we are going to make the semicircular slider. Add, 2 arcs in you canvas.

Canvas(

modifier = Modifier

.size(350.dp)

.padding(15.dp),

onDraw = {

//background arc for slider with gray color

drawArc(

color = Color.Gray.copy(alpha = 0.5f),

startAngle = 180f,

sweepAngle = 180f,

useCenter = false,

style = Stroke(

width = 20f,

cap = StrokeCap.Round,

join = StrokeJoin.Round

)

)



//arc to show slider values with red color

drawArc(

color = Color.Red,

startAngle = 180f,

sweepAngle = 90f,

useCenter = false,

style = Stroke(

width = 20f,

cap = StrokeCap.Round,

join = StrokeJoin.Round

)

)

}

)

The first arc is created to serve as a background placeholder; therefore, I sets its color to Gray.

The second arc will work as a slider and its’ color is red.

The current output looks like this.

Then, add a “progress” mutable state to keep track of the slider progress (red arc’s path).

var progress by remember {

mutableStateOf(18f)

}

Instead of changing the progress suddenly, I want to animate the arc. For this reason, I used “animateFloatAsState”.

val animatedProgressValue by animateFloatAsState(

targetValue = progress,

animationSpec = tween(

durationMillis = 200,

easing = LinearOutSlowInEasing

)

)

Then, apply the “animatedProgressValue” to the second RED arc.

//arc to show slider values with red color

drawArc(

color = Color.Red,

startAngle = 180f,

sweepAngle = animatedProgressValue,

useCenter = false,

style = Stroke(

width = 20f,

cap = StrokeCap.Round,

join = StrokeJoin.Round

)

)

Let’s add a knob. Knob of a slider is basically a circle with a particular value of radius and center offset in canvas. Add a circle with calculation of center offset to convert “Degrees to X/Y Coordinate” values in the canvas.

var radius = size.width / 2

var x = radius * Math.cos(Math.PI * 2 * animatedProgressValue / 360).toFloat()

var y = radius * Math.sin(Math.PI * 2 * animatedProgressValue / 360).toFloat()



drawCircle(

color = Color.Red,

radius = 25f,

center = Offset(radius - x, radius - y)

)

Now, it is time to add tag gestures and drag gestures. Before add 2 necessary gestures, we will first implement a function to update the progress (actually, path of the red arc).

private fun updateProgress(

offset: Offset,

canvasSize: Float,

progress: Float,

onProgressChanged: (Float) -> Unit

) {

val centerX = canvasSize / 2

val centerY = canvasSize / 2

val x = offset.x - centerX

val y = offset.y - centerY

val angle = Math.toDegrees(atan2(y.toDouble(), x.toDouble())).toFloat()



if (angle in -180f..0f) {

val newProgress = angle + 180f

onProgressChanged(newProgress)

}

}

UpdateProgress() used formulas to convert X/Y Coordinate values into degrees.

Then, add the drag gestures and tap gestures to the canvas to work in action.

Canvas(
modifier = Modifier
.size(350.dp)
.padding(15.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
updateProgress(offset, size.width.toFloat(), progress) { newProgress ->
progress = newProgress
}
}
}
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, _ ->
updateProgress(
change.position,
size.width.toFloat(),
progress
) { newProgress ->
progress = newProgress
}
}
)
},
onDraw = {
//background arc for slider with gray color
drawArc(
….

The final code is

@Composable
fun SemicircularSlider() {

var progress by remember {
mutableStateOf(18f)
}

val animatedProgressValue by animateFloatAsState(
targetValue = progress,
animationSpec = tween(
durationMillis = 200,
easing = LinearOutSlowInEasing
)
)

Canvas(
modifier = Modifier
.size(350.dp)
.padding(15.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
updateProgress(offset, size.width.toFloat(), progress) { newProgress ->
progress = newProgress
}
}
}
.pointerInput(Unit) {
detectDragGestures(
onDrag = { change, _ ->
updateProgress(
change.position,
size.width.toFloat(),
progress
) { newProgress ->
progress = newProgress
}
}
)
},
onDraw = {
//background arc for slider with gray color
drawArc(
color = Color.Gray.copy(alpha = 0.5f),
startAngle = 180f,
sweepAngle = 180f,
useCenter = false,
style = Stroke(
width = 20f,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)

//arc to show slider values with red color
drawArc(
color = Color.Red,
startAngle = 180f,
sweepAngle = animatedProgressValue,
useCenter = false,
style = Stroke(
width = 20f,
cap = StrokeCap.Round,
join = StrokeJoin.Round
)
)

var radius = size.width / 2
var x = radius * Math.cos(Math.PI * 2 * animatedProgressValue / 360).toFloat()
var y = radius * Math.sin(Math.PI * 2 * animatedProgressValue / 360).toFloat()

drawCircle(
color = Color.Red,
radius = 25f,
center = Offset(radius - x, radius - y)
)
}
)
}

private fun updateProgress(
offset: Offset,
canvasSize: Float,
progress: Float,
onProgressChanged: (Float) -> Unit
) {
val centerX = canvasSize / 2
val centerY = canvasSize / 2
val x = offset.x - centerX
val y = offset.y - centerY
val angle = Math.toDegrees(atan2(y.toDouble(), x.toDouble())).toFloat()

if (angle in -180f..0f) {
val newProgress = angle + 180f
onProgressChanged(newProgress)
}
}

The final result can be seen.

Thanks for reading this article till the end.

Zaw Wynn Myat (Mobile Application Developer)

https://github.com/zawwynnmyat

--

--

Zaw Wynn Myat

Mobile Dev # Jetpack Compose # Flutter # Microcontroller Enthusiastic # keen to solve competitive programming problems