In this post I will demonstrate how you can build a custom analog clock in Android Compose with a Canvas composable.
The main composable element this is built off of is a Canvas.
Below I will show you two ways to update the time.
- One way is to update via the Calendar library built in with Android to get the real time
- Another way is to set a static time at system start, then using a LaunchEffect to loop through a count up method. Gritty looking but works.
The visuals of the actual clock face could be updated based on your color scheme and use case.
This custom analog clock in Android Compose can be written a couple different ways. I will show two different methods to implement the logic and two different methods to implement the UI.
Code
First I will post the two ways to update the clock face. There is a list of floats that holds the time needed for the UI elements.
- time[0] = hours
- time[1] = minutes
- time[2] = seconds
Method #1
This utilizes the Calendar library built into Android system.
var time by remember {
mutableStateOf(
listOf(
0f, 0f, 0f
)
)
}
LaunchedEffect(Unit) {
while (true) {
val calendar = Calendar.getInstance()
time = listOf(
calendar.get(Calendar.HOUR_OF_DAY).toFloat(),
calendar.get(Calendar.MINUTE).toFloat(),
calendar.get(Calendar.SECOND).toFloat()
)
delay(1000)
}
}
Method #2
This is a simple up counter built, Could be better implemented but I just threw it together.
var time by remember {
mutableStateOf(
listOf(
1f, 26f, 30f // time example will be 1:26:30 - HH:MM:SS
)
)
}
LaunchedEffect(Unit) {
while (true) {
val currentTime = time.toMutableList()
currentTime[2] = currentTime[2] + 1
if(currentTime[2] == 60f){
currentTime[2] = 0f
currentTime[1] = currentTime[1] + 1
}
if(currentTime[1] == 60f){
currentTime[1] = 0f
currentTime[0] = currentTime[0] + 1
}
if(currentTime[0] == 12f){
currentTime[0] = 0f
}
time = currentTime.toList()
delay(1000)
}
}
I am also posting two different ways to create the clock face. They both utilize a Canvas as the main composable but create the UI differently.
Method #1
The approach below needs to get the angle coordinates for each of the hours, minutes, seconds. Then it feeds it as a parameter to the drawLine method.
@Composable
fun AnalogClockFace(modifier: Modifier = Modifier) {
val hoursStrokeWidth = 5f
val minutesStrokeWidth = 2f
val secondsStrokeWidth = Stroke.HairlineWidth
val padding = 20
val textMeasurer = rememberTextMeasurer()
var time by remember {
mutableStateOf(
listOf(
1f, 26f, 30f
)
)
}
LaunchedEffect(Unit) {
while (true) {
val currentTime = time.toMutableList()
currentTime[2] = currentTime[2] + 1
if(currentTime[2] == 60f){
currentTime[2] = 0f
currentTime[1] = currentTime[1] + 1
}
if(currentTime[1] == 60f){
currentTime[1] = 0f
currentTime[0] = currentTime[0] + 1
}
if(currentTime[0] == 12f){
currentTime[0] = 0f
}
time = currentTime.toList()
delay(1000)
}
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Canvas(
modifier = Modifier
.size(300.dp)
.aspectRatio(1f)
.padding(padding.dp)
) {
drawCircle(
brush = Brush.radialGradient(
0.5f to Color.Transparent,
1.0f to Color.LightGray
),
radius = center.x + 20f
)
for (numbers in (1..360)) {
rotate(360 / 360f * numbers, center) {
drawLine(
Color.Black,
Offset(center.x, -2f),
Offset(center.x, 2f),
secondsStrokeWidth
)
}
}
for (numbers in (1..60)) {
rotate(360 / 60f * numbers, center) {
drawLine(
Color.Red,
Offset(center.x, -5f),
Offset(center.x, 10f),
minutesStrokeWidth
)
}
}
for (numbers in (1..12)) {
rotate(360 / 12f * numbers, center) {
drawLine(
Color.Black,
Offset(center.x, -15f),
Offset(center.x, 15f),
strokeWidth = hoursStrokeWidth
)
}
}
drawLine(
Color.Magenta,
calculateTailOffset(
IntSize(
(center.x * 2).roundToInt(),
(center.y * 2).roundToInt()
), (360f * (time[0] / 12f) + (360 / 12f * time[1] / 60f)) + 90
),
calculateHourHandleOffset(
IntSize(
(center.x * 2).roundToInt(),
(center.y * 2).roundToInt()
), (360f * (time[0] / 12f) + (360 / 12f * time[1] / 60f)) - 90
),
hoursStrokeWidth
)
for (numbers in (1..12)) {
drawText(
textMeasurer, numbers.toString(),
calculateHourNumberOffset(
IntSize(
(center.x * 2 - padding).roundToInt(),
(center.y * 2 - padding).roundToInt()
), (360f * (numbers / 12f)) - 90
),
style = TextStyle(color = Color.Black, fontSize = 15.sp)
)
}
drawLine(
Color.Black,
calculateTailOffset(
IntSize(
(center.x * 2).roundToInt(),
(center.y * 2).roundToInt()
), (360f * (time[1] / 60f) + (360 / 60f * time[2] / 60f)) + 90
),
calculateHandleOffset(
IntSize(
(center.x * 2).roundToInt(),
(center.y * 2).roundToInt()
), (360f * (time[1] / 60f) + (360 / 60f * time[2] / 60f)) - 90
),
hoursStrokeWidth
)
drawLine(
Color.Blue,
calculateTailOffset(
IntSize(
(center.x * 2).roundToInt(),
(center.y * 2).roundToInt()
), (360f * time[2] / 60f) + 90
),
calculateHandleOffset(
IntSize(
(center.x * 2).roundToInt(),
(center.y * 2).roundToInt()
), (360f * time[2] / 60f) - 90
),
secondsStrokeWidth
)
}
}
}
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()
)
}
fun calculateHourHandleOffset(size: IntSize, newAngle: Float): Offset {
return Offset(
(size.center.x + Math.cos(Math.toRadians(newAngle.toDouble())) * (size.width * 0.4)).toFloat(),
(size.center.y + Math.sin(Math.toRadians(newAngle.toDouble())) * (size.height * 0.4)).toFloat()
)
}
fun calculateTailOffset(size: IntSize, newAngle: Float): Offset {
return Offset(
(size.center.x + Math.cos(Math.toRadians(newAngle.toDouble())) * (size.width * 0.05)).toFloat(),
(size.center.y + Math.sin(Math.toRadians(newAngle.toDouble())) * (size.height * 0.05)).toFloat()
)
}
fun calculateHourNumberOffset(size: IntSize, newAngle: Float): Offset {
return Offset(
(size.center.x + Math.cos(Math.toRadians(newAngle.toDouble())) * (size.width * 0.4)).toFloat(),
(size.center.y + Math.sin(Math.toRadians(newAngle.toDouble())) * (size.height * 0.4)).toFloat()
)
}
Method #2
This approach uses the rotate function in the draw method of the canvas to create the ticks and the time arms. I prefer this approach. It was faster and more elegant of an approach.
@Composable
fun AnalogClockFace(modifier: Modifier = Modifier) {
val hoursStrokeWidth = 5f
val minutesStrokeWidth = 2f
val padding = 20
val secondsStrokeWidth = Stroke.HairlineWidth
val textMeasurer = rememberTextMeasurer()
var time by remember {
mutableStateOf(
listOf(
0f, 0f, 0f
)
)
}
LaunchedEffect(Unit) {
while (true) {
val calendar = Calendar.getInstance()
time = listOf(
calendar.get(Calendar.HOUR_OF_DAY).toFloat(),
calendar.get(Calendar.MINUTE).toFloat(),
calendar.get(Calendar.SECOND).toFloat()
)
delay(1000)
}
}
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Canvas(
modifier = Modifier
.size(300.dp)
.aspectRatio(1f)
.padding(padding.dp)
) {
drawCircle(
brush = Brush.radialGradient(
0.5f to Color.Transparent,
1.0f to Color.LightGray
),
radius = center.x + 20f
)
for (numbers in (1..360)) {
rotate(360 / 360f * numbers, center) {
drawLine(
Color.Black,
Offset(center.x, -2f),
Offset(center.x, 2f),
secondsStrokeWidth
)
}
}
for (numbers in (1..60)) {
rotate(360 / 60f * numbers, center) {
drawLine(
Color.Red,
Offset(center.x, -5f),
Offset(center.x, 10f),
minutesStrokeWidth
)
}
}
for (numbers in (1..12)) {
rotate(360 / 12f * numbers, center) {
drawLine(
Color.Black,
Offset(center.x, -15f),
Offset(center.x, 15f),
strokeWidth = hoursStrokeWidth
)
}
drawText(textMeasurer, numbers.toString(),
calculateHourNumberOffset(
IntSize(
(center.x * 2 - padding).roundToInt(),
(center.y * 2 - padding).roundToInt()
), (360f * (numbers / 12f)) - 90
),
style = TextStyle(color = Color.Black, fontSize = 15.sp)
)
}
rotate(360 / 12f * time[0] + (360 / 12f * time[1] / 60f), center) {
drawLine(
Color.Magenta,
Offset(center.x, center.y + 30f),
Offset(center.x, 40f),
hoursStrokeWidth
)
}
rotate(360 / 60f * time[1] + (360 / 60f * time[2] / 60f), center) {
drawLine(
Color.Green,
Offset(center.x, center.y + 30f),
Offset(center.x, -12f),
hoursStrokeWidth
)
}
rotate(360 / 60f * time[2], center) {
drawLine(
Color.Black,
Offset(center.x, center.y + 30f),
Offset(center.x, -12f),
secondsStrokeWidth
)
}
}
}
}
fun calculateHourNumberOffset(size: IntSize, newAngle: Float): Offset {
return Offset(
(size.center.x + Math.cos(Math.toRadians(newAngle.toDouble())) * (size.width * 0.4)).toFloat(),
(size.center.y + Math.sin(Math.toRadians(newAngle.toDouble())) * (size.height * 0.4)).toFloat()
)
}
Now you just need to choose your methods from above and add it to your UI. Sample of adding to UI from MainActivity below.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SampleAnalogClockFaceTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
AnalogClockFace(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
)
}
}
}
}
}
The attached gif is very short but sped up version of Method #1. The implementation will count through each second and move the minute as well as the hour arms the fraction required to stay accurate.

Hope this was useful.