Lab 074 – Mocking up a clock using Radial Layout
Using SwiftUI views, modifiers, and a layout to create a simple clock.
Just taking a quick break from example code today to mock up a clock. I’m using the Radial Layout that Apple provided in the Canyon Crosser sample from WWDC 2025. The clock is made up of a few layers in a ZStack.
A green circle for the background
Circle()
.fill(.stepGreen)Radial Layout with a list of hour positions. Content is a simple Text view.
RadialLayout(angleOffset: .degrees(180)) {
ForEach([12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], id: \.self) { hour in
Text("\(hour)")
.font(.system(size: 20, weight: .bold))
.foregroundColor(.stepBackgroundPrimary)
}
}Radial Layout for the list of second positions. I used Circle views that I can animate on tick. I scaled this view down a bit to fit inside the hour circle.
RadialLayout(angleOffset: .degrees(180)) {
ForEach(0..<60, id: \.self) { index in
Circle()
.fill(.stepBackgroundSecondary)
.scaleEffect(index == currentSecond ? 2.0 : 1.0)
.opacity(index == currentSecond ? 1.0 : 0.25)
.offset(z: index == currentSecond ? 5 : 0)
.shadow(radius: index == currentSecond ? 5 : 0, x: 0.0, y: 0.0)
.animation(.easeInOut(duration: 0.5), value: currentSecond)
.id(index)
}
}
.scaleEffect(0.74)The Hour and Minute hands are also SwiftUI shapes.
ZStack {
// Hour hand
Rectangle()
.fill(.stepBackgroundSecondary)
.frame(width: 6, height: 80)
.offset(y: -40)
.offset(z: 5)
.rotationEffect(.degrees(Double(currentHour) * 30 + Double(currentMinute) * 0.5))
.shadow(radius: 1, x: 0.0, y: 0.0)
.scaleEffect(min(geometry.size.width, geometry.size.height) / 400)
// Minute hand
Rectangle()
.fill(.stepBackgroundSecondary)
.frame(width: 4, height: 100)
.offset(y: -40)
.offset(z: 3)
.rotationEffect(.degrees(Double(currentMinute) * 6))
.shadow(radius: 1, x: 0.0, y: 0.0)
.scaleEffect(min(geometry.size.width, geometry.size.height) / 400)
}Some utility functions update the value for currentSecond, currentMinute, and currentHour.
Full Lab Code
struct Lab074: View {
var body: some View {
VStack {
ClockView()
.offset(z: 10)
.padding(.vertical, 20)
.manipulable()
}
}
}
fileprivate struct ClockView: View {
@State private var currentSecond: Int = 0
@State private var currentHour: Int = 0
@State private var currentMinute: Int = 0
@State private var timer: Timer?
var body: some View {
GeometryReader { geometry in
ZStack {
Circle()
.fill(.stepGreen)
RadialLayout(angleOffset: .degrees(180)) {
ForEach([12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], id: \.self) { hour in
Text("\(hour)")
.font(.system(size: 20, weight: .bold))
.foregroundColor(.stepBackgroundPrimary)
.scaleEffect(min(geometry.size.width, geometry.size.height) / 400)
}
}
RadialLayout(angleOffset: .degrees(180)) {
ForEach(0..<60, id: \.self) { index in
Circle()
.fill(.stepBackgroundSecondary)
.scaleEffect(index == currentSecond ? 2.0 : 1.0)
.opacity(index == currentSecond ? 1.0 : 0.25)
.offset(z: index == currentSecond ? 5 : 0)
.shadow(radius: index == currentSecond ? 5 : 0, x: 0.0, y: 0.0)
.animation(.easeInOut(duration: 0.5), value: currentSecond)
.id(index)
}
}
.scaleEffect(0.74)
ZStack {
// Hour hand
Rectangle()
.fill(.stepBackgroundSecondary)
.frame(width: 6, height: 80)
.offset(y: -40)
.offset(z: 5)
.rotationEffect(.degrees(Double(currentHour) * 30 + Double(currentMinute) * 0.5))
.shadow(radius: 1, x: 0.0, y: 0.0)
.scaleEffect(min(geometry.size.width, geometry.size.height) / 400)
// Minute hand
Rectangle()
.fill(.stepBackgroundSecondary)
.frame(width: 4, height: 100)
.offset(y: -40)
.offset(z: 3)
.rotationEffect(.degrees(Double(currentMinute) * 6))
.shadow(radius: 1, x: 0.0, y: 0.0)
.scaleEffect(min(geometry.size.width, geometry.size.height) / 400)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear {
startTimer()
}
.onDisappear {
stopTimer()
}
}
private func startTimer() {
// Update to current time immediately
updateTime()
// Calculate time until next second starts
scheduleNextUpdate()
}
private func updateTime() {
let calendar = Calendar.current
let now = Date().addingTimeInterval(0.1) // Add small offset to get current time
currentSecond = calendar.component(.second, from: now)
currentHour = calendar.component(.hour, from: now) % 12
currentMinute = calendar.component(.minute, from: now)
}
private func scheduleNextUpdate() {
// Get the current second interval
let now = Date()
guard let currentSecondInterval = Calendar.current.dateInterval(of: .second, for: now) else { return }
// Calculate time until the start of the next second
let nextSecondStart = currentSecondInterval.end
let timeUntilNextSecond = nextSecondStart.timeIntervalSinceNow
// Schedule update at the exact start of the next second
DispatchQueue.main.asyncAfter(deadline: .now() + timeUntilNextSecond) {
// Get time immediately when this fires
let calendar = Calendar.current
let currentTime = Date()
self.currentSecond = calendar.component(.second, from: currentTime)
self.currentHour = calendar.component(.hour, from: currentTime) % 12
self.currentMinute = calendar.component(.minute, from: currentTime)
self.scheduleNextUpdate() // Schedule the next update
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
}
// Taken from Canyon Crosser from WWDC 2025
// For information on custom layouts, watch https://developer.apple.com/videos/play/wwdc2022/10056.
fileprivate struct RadialLayout: Layout, Animatable {
var angleOffset: Angle = .zero
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let updatedProposal = proposal.replacingUnspecifiedDimensions()
let minDim = min(updatedProposal.width, updatedProposal.height)
return CGSize(width: minDim, height: minDim)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.count > 1 else {
subviews[0].place(
at: .init(x: bounds.midX, y: bounds.midY),
anchor: .center,
proposal: proposal)
return
}
let minDimension = min(bounds.width, bounds.height)
let subViewDim = minDimension / CGFloat((subviews.count / 2) + 1)
let radius = min(bounds.width, bounds.height) / 2
let placementRadius = radius - (subViewDim / 2)
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let angleIncrement = 2 * .pi / CGFloat(subviews.count)
let centerOffset = Double.pi / 2 // Centers the view.
for (index, subview) in subviews.enumerated() {
let angle = angleIncrement * CGFloat(index) + angleOffset.radians + centerOffset
let xPosition = center.x + (placementRadius * cos(angle))
let yPosition = center.y + (placementRadius * sin(angle))
let point = CGPoint(x: xPosition, y: yPosition)
subview.place(
at: point, anchor: .center,
proposal: .init(width: subViewDim, height: subViewDim))
}
}
var animatableData: Angle.AnimatableData {
get { angleOffset.animatableData }
set { angleOffset.animatableData = newValue }
}
}Support our work so we can continue to bring you new examples and articles.

Follow Step Into Vision