Swift Talk # 302

Visualizing Async Algorithms: Timeline View

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start a new project to visualize the algorithms from the new Swift Async Algorithms package.

00:06 Today we'll start a new project exploring Apple's Swift Async Algorithms package, which provides algorithms built on top of AsyncSequence. We like to think of this as a reactive library because it adds a lot of reactive operators, such as merge, zip, and combineLatest.

00:30 When working with reactive operators, we like to check the visualizations on RxMarbles to understand what the different operators do. Today, we'll try to rebuild the same interface by generating random events and visualizing the output of various async algorithms using SwiftUI.

00:59 The first things we'll do are defining a model and thinking of a way to visualize an array of events. In the next episode, we'll put this to use and see how we can combine event arrays.

Model

01:19 We write an Identifiable struct called Event. We'll also need to compare events, so we make it Hashable. And since we'll be doing async stuff, we mark it as Sendable. For the ID, we want something that's stable over time — by using an Int, the ID can be the same value every time we launch the app, which will help with animations. In addition to the identifier, Event also has properties for a timestamp, a value, and a color:

struct Event: Identifiable, Hashable, Sendable {
    var id: Int
    var time: TimeInterval
    var color: Color = .green
    var value: Value
}

02:15 We create a Value enum to store values that can be either a string or an integer:

enum Value: Hashable, Sendable {
    case int(Int)
    case string(String)
}

03:16 The compiler complains that Color isn't Sendable. The warning comes with a fix-it to silence the warning, even though it doesn't actually fix any of the problems that might arise from not being Sendable:

@preconcurrency import SwiftUI

03:38 For viewers who are following along: we've downloaded the latest Swift toolchain from the Swift.org website. This will be required once we start using Swift Async Algorithms in an upcoming episode.

04:08 We also need some sample data, so we paste in two arrays — one containing .int events, and one with .string events. The events in both arrays are sorted to have ascending time values, the integers all have a red color, and the string events are slightly offset in time compared to the integer events. Finally, we make sure to use distinct IDs for all events, because we'll be combining the arrays into one array:

var sampleInt: [Event] = [
    .init(id: 0, time:  0, color: .red, value: .int(1)),
    .init(id: 1, time:  1, color: .red, value: .int(2)),
    .init(id: 2, time:  2, color: .red, value: .int(3)),
    .init(id: 3, time:  5, color: .red, value: .int(4)),
    .init(id: 4, time:  8, color: .red, value: .int(5)),
]

var sampleString: [Event] = [
    .init(id: 100_0, time:  1.5, value: .string("a")),
    .init(id: 100_1, time:  2.5, value: .string("b")),
    .init(id: 100_2, time:  4.5, value: .string("c")),
    .init(id: 100_3, time:  6.5, value: .string("d")),
    .init(id: 100_4, time:  7.5, value: .string("e")),
]

Timeline View

04:56 Next, we want to build a timeline view that visualizes events as circles on a horizontal axis. To lay out the events on a timeline, we need to know the overall duration for the timeline. We'll be showing multiple timelines stacked on top of one another, each with different events, and these timelines should use the same duration. That's why we can't just take the maximum time from the events array; we need to be able to pass the duration in from the outside:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {

    }
}

06:13 To position the events on the axis, we also need to know the size of the view, so we add a geometry reader to measure the available width. When we use a geometry reader, most often it's to measure a view and propagate the dimensions up as a preference. This time, however, we use a top-level geometry reader, because we want to place event views inside it, and we want the timeline view to take up the proposed width:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in

        }
        .frame(height: 50)
    }
}

07:08 Let's now try to draw some circles. We start with a leading-aligned ZStack. Inside the stack view, we loop over the events using a ForEach view:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                ForEach(events) { event in

                }
            }
        }
        .frame(height: 50)
    }
}

07:35 We extend Value to conform it to View by displaying the string or integer value as a Text:

extension Value: View {
    var body: some View {
        switch self {
        case .int(let i): Text("\(i)")
        case .string(let s): Text(s)
        }
    }
}

08:48 In TimelineView, we add each event's value as a view to the ZStack, applying both a fixed frame and a background layer containing a circle filled with the event's color:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                ForEach(events) { event in
                    event.value
                        .frame(width: 30, height: 30)
                        .background {
                            Circle().fill(event.color)
                        }
                }
            }
        }
        .frame(height: 50)
    }
}

09:25 We add a TimelineView to ContentView, passing in the sample array containing the integer events. For the timeline's duration, we pass in the last event's time value. This already draws the events, but they're all placed on top of each other:

struct ContentView: View {
    var body: some View {
        VStack {
            TimelineView(events: sampleInt, duration: sampleInt.last!.time)
        }
    }
}

10:04 We override the .leading alignment of the views to give them the correct position on the timeline. We find this position by dividing each event's time by the timeline's duration, and by scaling that by the available width:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                ForEach(events) { event in
                    event.value
                        .frame(width: 30, height: 30)
                        .background {
                            Circle().fill(event.color)
                        }
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = event.time / duration
                            return -proxy.size.width * relativeTime
                        }
                }
            }
        }
        .frame(height: 50)
    }
}

11:03 The last event is now drawn out of bounds. To fix this, we need to subtract the width of one circle from the available width:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                ForEach(events) { event in
                    event.value
                        .frame(width: 30, height: 30)
                        .background {
                            Circle().fill(event.color)
                        }
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = event.time / duration
                            return -(proxy.size.width-30) * relativeTime
                        }
                }
            }
        }
        .frame(height: 50)
    }
}

Multiple Timelines

11:32 Let's also add a timeline with the second array of sample events. For the total duration of the timelines, we get the maximum time from the last events of both arrays:

struct ContentView: View {
    var duration: TimeInterval {
        max(sampleInt.last!.time, sampleString.last!.time)
    }

    var body: some View {
        VStack {
            TimelineView(events: sampleInt, duration: duration)
            TimelineView(events: sampleString, duration: duration)
        }
    }
}

12:14 The timelines are now aligned on their first event, even though these events have different times. Adding Color.clear to the timeline's stack view fixes the alignment, because it has a leading alignment value of zero:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                Color.clear
                ForEach(events) { event in
                    event.value
                        .frame(width: 30, height: 30)
                        .background {
                            Circle().fill(event.color)
                        }
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = event.time / duration
                            return -(proxy.size.width-30) * relativeTime
                        }
                }
            }
        }
        .frame(height: 50)
    }
}

12:44 But rather than Color.clear, it'd make more sense to add some tick marks to the timeline — one at every whole second:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                ForEach(0..<Int(duration.rounded(.up))) { tick in
                    Rectangle()
                        .frame(width: 1)
                        .foregroundColor(.secondary)
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = CGFloat(tick) / duration
                            return -(proxy.size.width-30) * relativeTime
                        }
                }
                ForEach(events) { event in
                    event.value
                        .frame(width: 30, height: 30)
                        .background {
                            Circle().fill(event.color)
                        }
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = event.time / duration
                            return -(proxy.size.width-30) * relativeTime
                        }
                }
            }
        }
        .frame(height: 50)
    }
}

14:01 It'd be nicer to center the tick marks on the circles, so we offset them by half the width of a circle:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                ForEach(0..<Int(duration.rounded(.up))) { tick in
                    Rectangle()
                        .frame(width: 1)
                        .foregroundColor(.secondary)
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = CGFloat(tick) / duration
                            return -(proxy.size.width-30) * relativeTime
                        }
                }
                .offset(x: 15)

                ForEach(events) { event in
                    event.value
                        .frame(width: 30, height: 30)
                        .background {
                            Circle().fill(event.color)
                        }
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = event.time / duration
                            return -(proxy.size.width-30) * relativeTime
                        }
                }
            }
        }
        .frame(height: 50)
    }
}

14:31 We also add a rectangle that's one point high to show the timeline's horizontal axis:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                Rectangle()
                    .fill(Color.secondary)
                    .frame(height: 1)

                ForEach(0..<Int(duration.rounded(.up))) { tick in
                    Rectangle()
                        .frame(width: 1)
                        .foregroundColor(.secondary)
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = CGFloat(tick) / duration
                            return -(proxy.size.width-30) * relativeTime
                        }
                }
                .offset(x: 15)

                ForEach(events) { event in
                    event.value
                        .frame(width: 30, height: 30)
                        .background {
                            Circle().fill(event.color)
                        }
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = event.time / duration
                            return -(proxy.size.width-30) * relativeTime
                        }
                }
            }
        }
        .frame(height: 50)
    }
}

14:56 We add some padding around the VStack containing all timelines:

struct ContentView: View {
    var duration: TimeInterval {
        max(sampleInt.last!.time, sampleString.last!.time)
    }

    var body: some View {
        VStack {
            TimelineView(events: sampleInt, duration: duration)
            TimelineView(events: sampleString, duration: duration)
        }
        .padding(20)
    }
}

15:17 As a final tweak, we adjust the timeline's fixed height so that the flexible tick marks are the same height as the circles:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            // ...
        }
        .frame(height: 30)
    }
}

15:57 For the next part, it'll be useful to add a tooltip showing the time of an event. We can use the help modifier for this:

struct TimelineView: View {
    var events: [Event]
    var duration: TimeInterval

    var body: some View {
        GeometryReader { proxy in
            ZStack(alignment: .leading) {
                // ...

                ForEach(events) { event in
                    event.value
                        .frame(width: 30, height: 30)
                        .background {
                            Circle().fill(event.color)
                        }
                        .alignmentGuide(.leading) { dim in
                            let relativeTime = event.time / duration
                            return -(proxy.size.width-30) * relativeTime
                        }
                        .help("\(event.time)")
                }
            }
        }
        .frame(height: 30)
    }
}

Next Steps

16:51 In the next episode, we'll convert our sample arrays into streams so that we can apply one of the async operators from the Swift Async Algorithms package. One of the simplest operators is merge, which takes all values of two streams and combines them into a single stream. We'll then take the result of this operation and visualize it in a timeline.

17:40 We also want to make the timeline interactive. By moving values up and down the timeline, we can see how that influences the result of merge and other operators.

Resources

  • Sample Code

    Written in Swift 5.6

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

135 Episodes · 48h28min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes