Blog Infos
Author
Published
Topics
,
Published

WatchFace

 

In this blog, I will explain how we can implement this using the Compose Canvas API. I’ll also cover the mathematical concepts related to calculating coordinates on a circle and drawing shapes and text around those coordinates, Many of the formulas used here have been derived through trial and error, so understanding them at first can be challenging. However, I will simplify the explanations with diagrams to make them easier to grasp. I hope you enjoy reading this article.

TL;DR

GitHub – nikhil-mandlik-dev/watchface

Overview

  1. Terminology
  2. Drawing Second & Minute Dials
  3. Drawing Hour Label
  4. Drawing Minute-Second Overlay
  5. Terminology

 

  • Seconds and Minutes Dials: These are rotating circles on the watch face. Both dials are identical, except for their radius.
  • Steps: The dials are divided into 60 steps, There are two types of steps normalStep and fiveStep.
  • Step Labels: The fiveStep intervals are labelled with values such as [00, 05, 10…]
  • Hour Label: The central label represents the hour of the day in a 24-hour format.
  • Minutes-Second Overlay: To highlight the current minute and second, a rounded rectangle is positioned at the right center of the watch face.

2. Drawing Second & Minute Dial

This section is again divided into three subparts

  • Understanding Dial
  • Drawing steps
  • Drawing steps labels

2.1 Understanding Dial

understanding dial

  • Both the Second and Minute dials have the same number of steps but differ in their radius and rotation per second.
  • The Second dial rotates 6 degrees every second, while the Minute dial also rotates 6 degrees every minute.
  • Each dial consists of 60 normal steps and 12 five steps.
  • The angle between two consecutive normal steps is 6 degrees.
  • The angle between two consecutive five steps is 30 degrees.
  • The line height of the five steps is greater than that of the normal steps to ensure proper separation.
  • Five step labels, indicating values like [00, 05, 10…], are drawn with some padding called stepsLabelTopPadding.

2.2 Drawing steps

  • To draw steps, we need the start and end offsets of each individual step, which can be calculated using the following formula
x = r * cos(θ)
y = r * sin(θ)

Job Offers

Job Offers


    Senior Android Engineer

    Carly Solutions GmbH
    Munich
    • Full Time
    apply now

    Senior Android Developer

    SumUp
    Berlin
    • Full Time
    apply now

OUR VIDEO RECOMMENDATION

, ,

Jetpack Compose Navigation: Beyond The Basics

One of the challenges facing Android engineers today is that while there is a myriad of information available surrounding the more established navigation with fragments, there are few practical resources on how to adapt to…
Watch Video

Jetpack Compose Navigation: Beyond The Basics

Miguel Kano
Android Engineer
Robinhood Markets, Inc

Jetpack Compose Navigation: Beyond The Basics

Miguel Kano
Android Engineer
Robinhood Markets, I ...

Jetpack Compose Navigation: Beyond The Basics

Miguel Kano
Android Engineer
Robinhood Markets, Inc

Jobs

  • However, the coordinate system of a circle is different from the coordinate system of the canvas, so we need to modify the above formula to obtain the x, y coordinates on the canvas.
x = center.x + r * cos(θ)
y = center.y - r * sin(θ)
  • In the formula, we require the angle in radians. we can need to multiply angleInDegrees by π/180. Therefore, the modified formula becomes:
x = center.x + r * cos(angleInDegrees * (π / 180))
y = center.y - r * sin(angleInDegrees * (π / 180))
  • each step rotates at a certain degree every second, so we will add a “rotation” state in the above formula, this state will be modify every second to rotate each step
x = center.x + r * cos((angleInDegrees + rotation) * (π / 180))
y = center.y - r * sin((angleInDegrees + rotation) * (π / 180))

2.3 Drawing steps labels

  • To drawText on the canvas we need the topLeft offset of the position where we want to draw a text
  • While calculating this we also need to consider a label width and height to properly position the label at the center of the step

Code Snippet and Output for Drawing Second & Minute Dial

Draw Steps and Labels Output

//data class for wrapping dial customization
data class DialStyle(
val stepsWidth: Dp = 1.2.dp,
val stepsColor: Color = Color.Black,
val normalStepsLineHeight: Dp = 8.dp,
val fiveStepsLineHeight: Dp = 16.dp,
val stepsTextStyle: TextStyle = TextStyle(),
val stepsLabelTopPadding: Dp = 12.dp,
)
data class ClockStyle(
val secondsDialStyle: DialStyle = DialStyle(),
)
@OptIn(ExperimentalTextApi::class)
@Composable
fun Clock(
modifier: Modifier = Modifier.size(320.dp),
clockStyle: ClockStyle = ClockStyle()
) {
val textMeasurer = rememberTextMeasurer()
var minuteRotation by remember { mutableStateOf(0f) }
var secondRotation by remember { mutableStateOf(0f) }
//secondRotation is updated by 6 degree clockwise every one second
//here rotation is in negative, in order to get clockwise rotation
LaunchedEffect(key1 = true) {
while (true) {
//in-order to get smooth transition we are updating rotation angle every 16ms
//1000ms -> 6 degree
//16ms -> 0.096
delay(16)
secondRotation -= 0.096f
}
}
//minuteRotation is updated by 0.1 degree clockwise every one second
//here rotation is in negative, in order to get clockwise rotation
LaunchedEffect(key1 = true) {
while (true) {
delay(1000)
minuteRotation -= 0.1f
}
}
Canvas(
modifier = modifier
) {
val outerRadius = minOf(this.size.width, this.size.height) / 2f
val innerRadius = outerRadius - 60.dp.toPx()
//Seconds Dial
dial(
radius = outerRadius,
rotation = secondRotation,
textMeasurer = textMeasurer,
dialStyle = clockStyle.secondsDialStyle
)
//Minute Dial
dial(
radius = innerRadius,
rotation = minuteRotation,
textMeasurer = textMeasurer,
dialStyle = clockStyle.minutesDialStyle
)
}
}
@OptIn(ExperimentalTextApi::class)
fun DrawScope.dial(
radius: Float,
rotation: Float,
textMeasurer: TextMeasurer,
dialStyle: DialStyle = DialStyle()
) {
var stepsAngle = 0
//this will draw 60 steps
repeat(60) { steps ->
//fiveStep lineHeight > normalStep lineHeight
val stepsHeight = if (steps % 5 == 0) {
dialStyle.fiveStepsLineHeight.toPx()
} else {
dialStyle.normalStepsLineHeight.toPx()
}
//calculate steps, start and end offset
val stepsStartOffset = Offset(
x = center.x + (radius * cos((stepsAngle + rotation) * (Math.PI / 180f))).toFloat(),
y = center.y - (radius * sin((stepsAngle + rotation) * (Math.PI / 180))).toFloat()
)
val stepsEndOffset = Offset(
x = center.x + (radius - stepsHeight) * cos(
(stepsAngle + rotation) * (Math.PI / 180)
).toFloat(),
y = center.y - (radius - stepsHeight) * sin(
(stepsAngle + rotation) * (Math.PI / 180)
).toFloat()
)
//draw step
drawLine(
color = dialStyle.stepsColor,
start = stepsStartOffset,
end = stepsEndOffset,
strokeWidth = dialStyle.stepsWidth.toPx(),
cap = StrokeCap.Round
)
//draw steps labels
if (steps % 5 == 0) {
//measure the given label width and height
val stepsLabel = String.format("%02d", steps)
val stepsLabelTextLayout = textMeasurer.measure(
text = buildAnnotatedString { append(stepsLabel) },
style = dialStyle.stepsTextStyle
)
//calculate the offset
val stepsLabelOffset = Offset(
x = center.x + (radius - stepsHeight - dialStyle.stepsLabelTopPadding.toPx()) * cos(
(stepsAngle + rotation) * (Math.PI / 180)
).toFloat(),
y = center.y - (radius - stepsHeight - dialStyle.stepsLabelTopPadding.toPx()) * sin(
(stepsAngle + rotation) * (Math.PI / 180)
).toFloat()
)
//subtract the label width and height to position label at the center of the step
val stepsLabelTopLeft = Offset(
stepsLabelOffset.x - ((stepsLabelTextLayout.size.width) / 2f),
stepsLabelOffset.y - (stepsLabelTextLayout.size.height / 2f)
)
drawText(
textMeasurer = textMeasurer,
text = stepsLabel,
topLeft = stepsLabelTopLeft,
style = dialStyle.stepsTextStyle
)
}
stepsAngle += 6
}
}
view raw Clock.kt hosted with ❤ by GitHub

3. Drawing Hour Label

  • To draw the overlay, we will utilize the path functions lineTo and cubicTo, which allow us to create rounded corners.
  • When drawing the rounded corners, we need to consider the width of the minute and second labels, as well as the step size.

hour label

//draw hour
val hourString = String.format("%02d", hour)
val hourTextMeasureOutput = textMeasurer.measure(
text = buildAnnotatedString { append(hourString) },
style = clockStyle.hourLabelStyle
)
val hourTopLeft = Offset(
x = this.center.x - (hourTextMeasureOutput.size.width / 2),
y = this.center.y - (hourTextMeasureOutput.size.height / 2)
)
drawText(
textMeasurer = textMeasurer,
text = hourString,
topLeft = hourTopLeft,
style = clockStyle.hourLabelStyle
)
view raw Clock.kt hosted with ❤ by GitHub

3. Drawing the Minute-Second Overlay

  • To draw the overlay, we will utilize the path functions lineTo and cubicTo, which allow us to create rounded corners.
  • When drawing the rounded corners, we need to consider the width of the minute and second labels, as well as the step size.
//drawing minute-second overlay
val minuteHandOverlayPath = Path().apply {
val startOffset = Offset(
x = center.x + (outerRadius * cos(8f * Math.PI / 180f)).toFloat(),
y = center.y - (outerRadius * sin(8f * Math.PI / 180f)).toFloat(),
)
val endOffset = Offset(
x = center.x + (outerRadius * cos(-8f * Math.PI / 180f)).toFloat(),
y = center.y - (outerRadius * sin(-8f * Math.PI / 180f)).toFloat(),
)
val overlayRadius = (endOffset.y - startOffset.y) / 2f
val secondsLabelMaxWidth = textMeasurer.measure(
text = buildAnnotatedString { append("60") },
style = clockStyle.secondsDialStyle.stepsTextStyle
).size.width
val minutesLabelMaxWidth = textMeasurer.measure(
text = buildAnnotatedString { append("60") },
style = clockStyle.minutesDialStyle.stepsTextStyle
).size.width
val overlayLineX =
size.width - clockStyle.secondsDialStyle.fiveStepsLineHeight.toPx() - clockStyle.secondsDialStyle.stepsLabelTopPadding.toPx() - secondsLabelMaxWidth - clockStyle.minutesDialStyle.fiveStepsLineHeight.toPx() - clockStyle.minutesDialStyle.stepsLabelTopPadding.toPx() - (minutesLabelMaxWidth /2f)
moveTo(x = startOffset.x, y = startOffset.y)
lineTo(x = overlayLineX, y = startOffset.y)
cubicTo(
x1 = overlayLineX - overlayRadius,
y1 = startOffset.y,
x2 = overlayLineX - overlayRadius,
y2 = endOffset.y,
x3 = overlayLineX,
y3 = endOffset.y
)
lineTo(endOffset.x, endOffset.y)
}
drawPath(
path = minuteHandOverlayPath,
color = clockStyle.overlayStrokeColor,
style = Stroke(width = clockStyle.overlayStrokeWidth.toPx(),)
)
view raw Clock.kt hosted with ❤ by GitHub

You can customize the clockStyle to update the labels fontFamily, size and colour according to your preference.

If you have any questions, suggestions, or ideas for improvement, please feel free to leave a comment. If you found the article helpful, don’t forget to clap and follow.

Thank You

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu