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.
Download the Xcode project with this and many more labs from Step Into Vision.

Follow Step Into Vision