Introduction to TimelineView

At WWDC21 Apple introduced TimelineView, a new SwiftUI View that update itself according to a schedule instead of relying on state variables.
It may be useful in some scenario, for example if you want to refresh a view only every X seconds, or if you want to animate something providing new frames at a given interval, or even if you just want to show a clock in your app.

Let’s see an example, a view that shows the time of day and changes every minute


TimelineView(.everyMinute) { context in
    Text(context.date.formatted())
}

This is a really simple example. TimelineView is instantiated with a particular TimelineScheduler called everyMinute which fires up, guess what, every minute.
This mean every minute the Text inside TimelineView is evaluated, and the context variable passed to the closure contains the date, so you can show the current time of day.
Note I used the formatted modifier, this is another nice addition of this year and you’ll see other examples.

PeriodicTimelineSchedule


What if we want to show the time, but update is every second?
We can use a PeriodicTimelineSchedule, instantiated via periodic(from:by:) so we can set a start date (if is in the past, the view updates immediately) and define how often we want the view to update.


TimelineView(.periodic(from: .now, by: 1.0)) { context in
    Text(context.date.formatted(date: .omitted, time: .standard))
}

in this example, the view updates every second. Note that I formatted the date differently, this time I omitted the date so it prints like this 4:30:25 PM

ExplicitTimelineSchedule

There is another tipe of schedule if we need more control, it is the ExplicitTimelineSchedule. You can provide an array of dates, and the TimelineView will be updated only at those given times.


TimelineView(.explicit(getDates())) { context in
    Text(context.date.formatted(date: .omitted, time: .standard))
}

private func getDates() -> [Date] {
    let date = Date()
    return [date,
            date.addingTimeInterval(2.0),
            date.addingTimeInterval(4.0),
            date.addingTimeInterval(6.0)]
}

Build an analog clock

Let’s have some fun and build an analog clock in SwiftUI with the help of TimelineView. You can find the code on GitHub.


As we just saw, TimelineView provides us with a convenient way to update our views with a schedule. That sounds perfect for a clock, we need our hands to move as the time passes. If we want to implement the seconds hand, we need the scheduler to refresh the content every second so let’s start with that


var body: some View {
    TimelineView(.periodic(from: Date(), by: 1.0)) { context in
        VStack {
            Text(context.date.formatted(date: .omitted, time: .standard))
            ZStack {
                clockHands(date: context.date)
            }
        }
        .frame(width: 300, height: 300)
    }
}

This TimelineView will refresh every seconds, now it is just a matter or placing the hands correctly


@ViewBuilder
private func clockHands(date: Date) -> some View {
    ClockHand(handScale: 0.5)
        .stroke(lineWidth: 5.0)
        .rotationEffect(angle(fromDate: date, type: .hour))
    ClockHand(handScale: 0.6)
        .stroke(lineWidth: 3.0)
        .rotationEffect(angle(fromDate: date, type: .minute))
    ClockHand(handScale: 0.8)
        .stroke(lineWidth: 1.0)
        .rotationEffect(angle(fromDate: date, type: .second))
}

Each ClockHand is a shape and has a different line width and even a different length, as you know the hours hand is shorter than the minutes and the seconds hand is the thinner and longer.
All we need to do is rotate each hand according to its value.


private func angle(fromDate: Date, type: ClockHandType) -> Angle {
    var timeDegree = 0.0
    let calendar = Calendar.current
    
    switch type {
    case .hour:
        // we have 12 hours so we need to multiply by 5 to have a scale of 60
        timeDegree = CGFloat(calendar.component(.hour, from: fromDate)) * 5
    case .minute:
        timeDegree = CGFloat(calendar.component(.minute, from: fromDate))
    case .second:
        timeDegree = CGFloat(calendar.component(.second, from: fromDate))
    }
    return Angle(degrees: timeDegree * 360.0 / 60.0)
}

there are 60 seconds in every minute and 60 minutes in every hour, but only 12 hours on the clock so we need to multiply the hour by 5 in order to make it work. That’s because I divide by 60 as you can see in the return statement.

That’s it, in a few lines of code we have an analog watch, simple but working.
Happy coding 🙂