Animated Clock
A simple clock
Let's build a simple analog clock display in Compose.
First, let's look at our API for the clock:
show in full file app/src/main/java/com/androidbyexample/composeclock/Clock.kt
// ...
import kotlin.math.min
@Composable
fun AnalogClock(
hour: Int,
minute: Int,
modifier: Modifier = Modifier,
outlineColor: Color = Color.Black,
outlineWidth: Dp = 8.dp,
fillColor: Color = Color.Gray,
hourHandColor: Color = Color.DarkGray,
minuteHandColor: Color = Color.LightGray,
hourTickColor: Color = Color.Black,
hourTickLength: Dp = 16.dp,
) {
with(LocalDensity.current) {
val outlineWidthPx = remember { outlineWidth.toPx() }
// ...
}
We're passing in parameters for
hour
andminute
- the current time to displaymodifier
- our normalModifier
(note thatmodifier
should be the first optional parameter)outlineColor
,outlineWidth
- the border of the clock. Note that the width is inDp
so we'll need to convert it to pixelsfillColor
- the color of the inside of the clockhourHandColor
,minuteHandColor
- the colors of the clock handshourTickColor
,hourTickLength
- the color and length of the markings on the circle indicating the hours. (Again, length is inDp
)
As before, we convert dp
to px
using the LocalDensity
composition local's toPx()
function:
show in full file app/src/main/java/com/androidbyexample/composeclock/Clock.kt
// ...
@Composable
fun AnalogClock(
// ...
hourTickLength: Dp = 16.dp,
) {
with(LocalDensity.current) {
val outlineWidthPx = remember { outlineWidth.toPx() }
val hourTickLengthPx = remember { hourTickLength.toPx() }
Canvas(modifier = modifier) { // this == DrawScope
// ...
}
}
Then, let's draw the filled circle followed by the outline. We draw the outline last so it isn't overlaid by the fill:
show in full file app/src/main/java/com/androidbyexample/composeclock/Clock.kt
// ...
@Composable
fun AnalogClock(
// ...
) {
with(LocalDensity.current) {
// ...
Canvas(modifier = modifier) { // this == DrawScope
val diameter = min(size.width, size.height) * 0.8f
val radius = diameter/2
drawCircle(
color = fillColor,
radius = radius,
// center = this.center -- default uses center of DrawScope
style = Fill,
)
drawCircle(
color = outlineColor,
radius = radius,
// center = this.center -- default uses center of DrawScope
style = Stroke(outlineWidthPx),
)
repeat(12) { hourTick ->
// ...
}
}
}
Call the clock the same way we call other Composables
show in full file app/src/main/java/com/androidbyexample/composeclock/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
ComposeClockTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
// ...
}
//@Preview(showBackground = true)
//@Composable
//fun GreetingPreview() {
// ComposeClockTheme {
// Greeting("Android")
AnalogClock(
hour = hour,
minute = minute,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
)
}
}
}
}
}
And we see
Next, let's draw the hour tick marks. This is what they'll look like:
To create each of these, we will draw vertical lines and rotate the canvas.
show in full file app/src/main/java/com/androidbyexample/composeclock/Clock.kt
// ...
@Composable
fun AnalogClock(
// ...
) {
with(LocalDensity.current) {
// ...
Canvas(modifier = modifier) { // this == DrawScope
// ...
)
repeat(12) { hourTick ->
rotate(hourTick * 30f) {
val start = center - Offset(0f, radius)
val end = start + Offset(0f, hourTickLengthPx)
drawLine(
color = hourTickColor,
start = start,
end = end,
strokeWidth = outlineWidthPx,
)
}
}
val minuteRatio = minute/60f
// ...
}
}
}
We use the repeat
function to loop from 0-11, each time calling rotate
to turn the canvas 30 degrees (1/12 of the 360-degree circle).
The start
and end
properties are Offset
s which contain both an x
and y
value. We calcuate them as offsets from the center
of the DrawScope
. To get to the start
, we subtract the radius
for y
to get to the circle border. Then we add back the hourTickLength
to move down.
To draw the hands, we'll do something very similar. We rotate the canvas to the hand positions, then draw from the center.
show in full file app/src/main/java/com/androidbyexample/composeclock/Clock.kt
// ...
@Composable
fun AnalogClock(
// ...
) {
with(LocalDensity.current) {
// ...
Canvas(modifier = modifier) { // this == DrawScope
// ...
}
val minuteRatio = minute/60f
val hourRatio = (hour + minuteRatio) / 12f
// draw minute hand first in case it overlaps the smaller hour hand
rotate(minuteRatio * 360) {
drawLine(
color = minuteHandColor,
start = center - Offset(0f, radius*0.9f),
end = center,
strokeWidth = outlineWidthPx,
)
}
rotate(hourRatio * 360) {
drawLine(
color = hourHandColor,
start = center - Offset(0f, radius*0.6f),
end = center,
strokeWidth = outlineWidthPx,
)
}
// give the hour hand a pin to look nicer
// ...
}
}
}
The hands don't look great where they meet. Let's give the hour hand a little pin at the center, a small filled circle.
show in full file app/src/main/java/com/androidbyexample/composeclock/Clock.kt
// ...
@Composable
fun AnalogClock(
// ...
) {
with(LocalDensity.current) {
// ...
Canvas(modifier = modifier) { // this == DrawScope
// ...
}
// give the hour hand a pin to look nicer
drawCircle(
color = hourHandColor,
radius = outlineWidthPx,
style = Fill,
)
}
}
}
That's better looking!
We can animate the clock by setting up state for the hour and minute. We could do this in a view model, but for this simple example, we'll use MutableState
in the MainActivity
Composable:
show in full file app/src/main/java/com/androidbyexample/composeclock/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
ComposeClockTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
// Greeting(
// name = "Android",
// modifier = Modifier.padding(innerPadding)
// )
// }
// }
// }
// }
//}
var hour by remember { mutableIntStateOf(0) }
var minute by remember { mutableIntStateOf(0) }
//@Composable
//fun Greeting(name: String, modifier: Modifier = Modifier) {
// Text(
// text = "Hello $name!",
// modifier = modifier
// )
//}
LaunchedEffect(true) {
// because true doesn't change, we never restart this coroutine
while(true) {
minute++
if (minute > 59) {
minute = 0
hour++
}
delay(10)
}
}
// ...
}
}
}
}
}
All code changes
ADDED: app/src/main/java/com/androidbyexample/composeclock/Clock.kt
package com.androidbyexample.composeclock
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.min
@Composable
fun AnalogClock(
hour: Int,
minute: Int,
modifier: Modifier = Modifier,
outlineColor: Color = Color.Black,
outlineWidth: Dp = 8.dp,
fillColor: Color = Color.Gray,
hourHandColor: Color = Color.DarkGray,
minuteHandColor: Color = Color.LightGray,
hourTickColor: Color = Color.Black,
hourTickLength: Dp = 16.dp,
) {
with(LocalDensity.current) {
val outlineWidthPx = remember { outlineWidth.toPx() }
val hourTickLengthPx = remember { hourTickLength.toPx() }
Canvas(modifier = modifier) { // this == DrawScope
val diameter = min(size.width, size.height) * 0.8f
val radius = diameter/2
drawCircle(
color = fillColor,
radius = radius,
// center = this.center -- default uses center of DrawScope
style = Fill,
)
drawCircle(
color = outlineColor,
radius = radius,
// center = this.center -- default uses center of DrawScope
style = Stroke(outlineWidthPx),
)
repeat(12) { hourTick ->
rotate(hourTick * 30f) {
val start = center - Offset(0f, radius)
val end = start + Offset(0f, hourTickLengthPx)
drawLine(
color = hourTickColor,
start = start,
end = end,
strokeWidth = outlineWidthPx,
)
}
}
val minuteRatio = minute/60f
val hourRatio = (hour + minuteRatio) / 12f
// draw minute hand first in case it overlaps the smaller hour hand
rotate(minuteRatio * 360) {
drawLine(
color = minuteHandColor,
start = center - Offset(0f, radius*0.9f),
end = center,
strokeWidth = outlineWidthPx,
)
}
rotate(hourRatio * 360) {
drawLine(
color = hourHandColor,
start = center - Offset(0f, radius*0.6f),
end = center,
strokeWidth = outlineWidthPx,
)
}
// give the hour hand a pin to look nicer
drawCircle(
color = hourHandColor,
radius = outlineWidthPx,
style = Fill,
)
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/composeclock/MainActivity.kt
package com.androidbyexample.composeclock
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.androidbyexample.composeclock.ui.theme.ComposeClockTheme
import kotlinx.coroutines.delay
import kotlin.math.min
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposeClockTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
// Greeting(
// name = "Android",
// modifier = Modifier.padding(innerPadding)
// )
// }
// }
// }
// }
//}
var hour by remember { mutableIntStateOf(0) }
var minute by remember { mutableIntStateOf(0) }
//@Composable
//fun Greeting(name: String, modifier: Modifier = Modifier) {
// Text(
// text = "Hello $name!",
// modifier = modifier
// )
//}
LaunchedEffect(true) {
// because true doesn't change, we never restart this coroutine
while(true) {
minute++
if (minute > 59) {
minute = 0
hour++
}
delay(10)
}
}
//@Preview(showBackground = true)
//@Composable
//fun GreetingPreview() {
// ComposeClockTheme {
// Greeting("Android")
AnalogClock(
hour = hour,
minute = minute,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
)
}
}
}
}
}