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.

Questions or feedback?