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.