Pocket Studio Academy
HomePart 55.14

Focus Flow 3 — the ring dial on Canvas

Full course16 min read·4 questions

Replace the number with a picture of the number. You draw a ring that empties as the phase runs down, plus twelve tick marks placed with a line of trigonometry — all by hand on a Canvas, with no chart library anywhere.

A number you have to read

18:42 is accurate. It is also work. Your eye has to find the digits, parse two numbers, and do a small subtraction before you know how you are doing.

A ring tells you the same thing in the time it takes to glance. Three-quarters full means three-quarters left. Nobody reads anything.

That is the whole job of this chapter. The clock stays — you still want the exact figure sometimes — but it moves inside a ring that drains as the phase runs down. And you are going to draw that ring yourself, on a , out of two arcs and twelve short lines.

No gauge library. None is needed, and by the end of this lesson you will be able to draw any dial you can sketch.

Think of it like this

Think about an old wind-up kitchen timer — the plastic tomato kind.

You twist the dial round to twenty-five and let go. There is no display. There are no digits counting down. There is just a coloured wedge that gets smaller, and a ring of little marks printed round the rim so you can see roughly where you are.

You never read that timer. You look at it and you know.

Everything in this lesson is that tomato: a coloured wedge that shrinks, and the marks round the rim. The only difference is that yours is drawn with maths instead of moulded in plastic.

Angles on a screen start at 3 o'clock

Before any code, one fact that trips up everyone the first time.

When you ask Compose to draw an arc, angle zero points to the right — 3 o'clock — and angles grow clockwise. This is inherited from every graphics system going back decades, and it is not going to change to suit you.

text
1        -90   (12 o'clock)
2              |
3              |
4  180 --------+-------- 0   (3 o'clock)
5              |
6              |
7             90   (6 o'clock)

So twelve o'clock, where a timer obviously ought to start, is -90. That is the entire reason this file opens with a constant called TOP.

The second fact: cos and sin do not speak degrees. They speak , where a full turn is about 6.283 rather than 360. One degree is 0.017453292 radians, so multiplying by that number converts. That is the RAD constant.

ui/timer/RingDial.ktkotlin
1// Degrees to radians. cos and sin want radians.
2private const val RAD = 0.017453292f
3
4// 0 degrees points right, so twelve o'clock is -90.
5private const val TOP = -90f
6
7private const val TICKS = 12

Note the f on the end of 0.017453292f and -90f. Without it Kotlin makes a Double, and every drawing command in Compose wants a . Getting this wrong is the third entry in the Error Doctor below, and it is a very easy mistake to make.

The shape of the composable

ui/timer/RingDial.ktkotlin
1@Composable
2fun RingDial(
3    fraction: Float,
4    trackColor: Color,
5    progressColor: Color,
6    tickColor: Color,
7    modifier: Modifier = Modifier,
8    thickness: Dp = 18.dp,
9    content: @Composable () -> Unit,
10) {
11    Box(
12        modifier = modifier,
13        contentAlignment = Alignment.Center,
14    ) {
15        Canvas(modifier = Modifier.fillMaxSize()) {
16            // all the drawing happens here
17        }
18        content()
19    }
20}

Four things worth pointing at.

fraction is the only piece of state it takes. Not milliseconds, not a Phase, not a TimerUiState. One Float between 0 and 1. That means RingDial knows nothing about timers and could just as easily show a download, a battery or a score. It is TimerUiState.fraction, the you wrote in chapter 2, that does the converting.

Every colour is a parameter. The dial does not reach for MaterialTheme itself, so the caller decides whether this is a focus ring or a break ring.

content is a — a hole the caller fills with whatever they like. Because it is the last parameter, callers can pass it as a , which is why using RingDial looks like using Column.

The stacks, the Canvas and the content do not overlap in code. Box draws its children on top of each other in order, centred. So the Canvas goes down first, then content() sits on top of it, in the hole. This layering is deliberate and it is what stops the second-most-common mistake in this lesson: you cannot put a Text inside the Canvas lambda, because that lambda is not composable — it runs later, during drawing.

Drawing the ring

Here is everything inside the Canvas lambda. It is fifty lines and it is the hardest code in the course, so go through it one step at a time.

Why the track and the progress are two separate arcs

You could draw one arc and be done. It would look wrong.

With only the coloured arc, a nearly-finished phase is a lonely sliver floating in space, and your eye has nothing to measure it against. The grey circle underneath is the scale. It says "this is what a full round looks like", so the coloured part means something.

It is the same reason a fuel gauge has a full arc printed on it and a needle on top.

Twelve marks round the rim

The ticks are the only place with real trigonometry, and it is four lines.

The marks are drawn from radius inner all the way out to outer, which is the very edge of the Canvas. That is why line 11 of the first walkthrough subtracts tickLen and gap before working out the ring's radius — the ring has to get out of the way of them.

Putting it on the screen

TimerContent changes shape. The clock and the rounds line stop being siblings of the controls and become the dial's content instead.

ui/timer/TimerScreen.ktkotlin
1        val scheme = MaterialTheme.colorScheme
2        val focus = state.phase == Phase.FOCUS
3
4        PhasePill(state.phase)
5
6        Spacer(Modifier.height(28.dp))
7
8        RingDial(
9            fraction = state.fraction,
10            trackColor = scheme.surfaceVariant,
11            progressColor =
12                if (focus) scheme.primary
13                else scheme.secondary,
14            tickColor =
15                scheme.outline.copy(alpha = 0.35f),
16            modifier = Modifier.size(280.dp),
17        ) {
18            Column(
19                horizontalAlignment =
20                    Alignment.CenterHorizontally,
21            ) {
22                Text(
23                    text = formatClock(state.leftMs),
24                    style = MaterialTheme.typography
25                        .displayLarge,
26                    color = scheme.onSurface,
27                )
28                Text(
29                    text = roundsLine(state.rounds),
30                    style = MaterialTheme.typography
31                        .bodyMedium,
32                    color = scheme.onSurfaceVariant,
33                )
34            }
35        }
36
37        Spacer(Modifier.height(36.dp))
38
39        Controls(
40            running = state.running,
41            onToggle = onToggle,
42            onReset = onReset,
43        )
44
45        Spacer(Modifier.height(20.dp))
46
47        Text(
48            text = "Up next: " +
49                state.phase.other.label.lowercase(),
50            style = MaterialTheme.typography.bodySmall,
51            color = scheme.onSurfaceVariant,
52        )

Modifier.size(280.dp) is not decoration — it is what gives the Canvas something to fill. The Box inside RingDial takes that size, the Canvas matches the Box, and every radius in the drawing code is worked out from it.

The progress colour switches with the phase: indigo while you focus, teal while you rest. The tick colour is scheme.outline with alpha = 0.35f — the theme's outline colour at 35 percent opacity, so the marks read as a printed rim rather than as twelve more things to look at.

And state.fraction recomputes on every tick of the timer, ten times a second. Each new value is a new TimerUiState, which triggers , which redraws the Canvas with a slightly smaller sweep. There is no animation code anywhere. The ring moves because the state moves.

Finally, "Up next: break" under the controls. state.phase.other is the Phase enum's from chapter 2 doing its second useful thing.

9:41▲ ▮
FOCUS
18:42
No rounds finished yet
❙❙ Pause
Up next: break
Timer
Stats
Settings

Chapter 3, mid-round: 18:42 left of a 25 minute focus phase, so the ring has swept about three-quarters of the way round.

Try it in Pocket Studio
  1. Open Pocket StudioProjectsFocus Flow.
  2. Long-press the ui/timer folder → NewKotlin File. Name it RingDial.
  3. Type the three constants, then the RingDial composable, then drawTicks underneath it.
  4. When Pocket Studio underlines cos and sin in red, tap the underline and choose Import — you want kotlin.math.cos and kotlin.math.sin, not the java.lang.Math versions.
  5. Open ui/timer/TimerScreen.kt and replace the pill-clock-rounds stack in TimerContent with the RingDial version above.
  6. Tap Build, then Run ▶.
  7. The dial should be a complete indigo circle at 25:00. Tap Start and watch the top of the ring pull away clockwise.
  8. Now prove the geometry to yourself. Change TOP to 0f, rebuild, and run. The ring now starts at 3 o'clock — that is Compose's real zero. Change it back to -90f.
  9. One more experiment: change sweepAngle to -360f * fraction. The ring drains anticlockwise. Change it back.
  10. Change Modifier.size(280.dp) to Modifier.size(200.dp) and rebuild. Everything scales together, because every radius is worked out from the size rather than hard-coded.

Focus Flow — end of chapter 3

A complete project. Unzip it, open it in Pocket Studio, and press Run.

Download ZIP
Error Doctor5 common errors
e: file:///.../RingDial.kt:48:37 Unresolved reference 'toPx'.
MeanstoPx() converts dp into real pixels, and to do that it has to know this screen's density. That is only known inside a DrawScope — the Canvas lambda — or a Density receiver. You called it in the composable body instead.
FixMove the thickness.toPx() line inside Canvas(...) { ... }, as the code above does. Pass a Dp into the composable and convert it where you draw, never before.
e: file:///.../RingDial.kt:96:13 @Composable invocations can only happen from the context of a @Composable function
MeansYou put Text(...) inside the Canvas lambda. That lambda is not composable — it runs during the drawing pass, long after composition has finished.
FixThe Canvas and the content sit side by side inside a Box, exactly as RingDial does. That is why the composable ends with content() after the Canvas rather than inside it.
e: file:///.../RingDial.kt:110:24 Argument type mismatch: actual type is 'kotlin.Double', but 'kotlin.Float' was expected.
MeansMath.PI is a Double, so angle came out as a Double, and Offset only accepts Float. One Double anywhere in a sum turns the whole sum into a Double.
FixKeep the maths in Float from the very start — that is what RAD = 0.017453292f is for, and why the f matters. If you do end up with a Double, finish the expression with .toFloat().
The app builds and runs, but the ring starts at 3 o'clock and fills the wrong way round
MeansAngle 0 points right in Compose, not up, and a positive sweep goes clockwise. Both defaults are the opposite of what a timer wants.
FixUse startAngle = -90f — the TOP constant — to begin at twelve o'clock. If the direction is also wrong, flip the sign of sweepAngle: positive sweeps clockwise, negative anticlockwise.
The app builds and runs, the screen is where you expect it, and nothing at all is drawn
MeansThe Canvas has Modifier.fillMaxSize(), so if the Box around it has no size of its own, it fills nothing. size.minDimension is then 0, every radius comes out negative, and Compose quietly draws an empty ring rather than complaining.
FixGive the dial a size where you use it — modifier = Modifier.size(280.dp) — or an aspect ratio. A Canvas is only ever as big as it is told to be, and it never asks.
Recap
  • Angles in Compose start at 3 o'clock and grow clockwise, so twelve o'clock is -90. That is the TOP constant, and it is the whole reason is not zero.
  • want . Multiplying degrees by 0.017453292f converts them, and the trailing f keeps the sum in where Compose needs it.
  • only works inside the draw lambda, because that is the only place the screen's density is known. Take a in and convert late.
  • is described by the box the oval fits inside — an and a — not by a centre and a radius.
  • Two arcs, not one: a full grey track for scale, then the coloured of 360f * fraction on top with round .
  • RingDial takes one Float and a , so it knows nothing about timers and could show anything at all.
  • The ring moves because the state moves. Every tick makes a new fraction, which causes , which redraws the Canvas. There is no animation code.
  • Next: the app finally gets a memory. Finished phases are written into a database, and the line under the clock starts reading today's real totals straight out of it.