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 building a scrolling graph view that can display large amounts of data smoothly.

00:06 Today we're starting a new series of episodes in which we build a large graph. We've prepared a model object that provides a vast amount of data points between 0 and 1, with each point being associated with a point in time.

00:33 Our first task is displaying the extensive data using SwiftUI. Plotting all values in a ZStack or HStack takes way too long. We'll see that even a LazyHStack can't handle this much data on its own.

01:18 Later in this series, we'll face more SwiftUI challenges, such as figuring out which part of the data is visible in the scroll view, and syncing this up with a date picker view.

Setting Up

01:43 We start with a LazyHStack inside a horizontal scroll view. In this stack view, we iterate over the model's days collection. A Day value defines a startOfDay and a values array containing the day's data points. As a first step, we display a formatted text for each day's startOfDay date:

struct ContentView: View {
    var model = Model.shared
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(model.days) { day in
                    Text(day.startOfDay, style: .date)
                }
            }
        }
    }
}

02:41 Rendering this view works thanks to the combination of the scroll view and the lazy stack view. If we try doing the same without the scroll view or with a non-lazy HStack, it takes a very long time to launch the app because all days need to be rendered at once.

03:35 Next, we give the day views a fixed width of 300 points so that we have some room to plot the data points within each day. We also remove the stack view's spacing, because we eventually want to draw a line between the data points that connect across the days:

struct ContentView: View {
    var model = Model.shared
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(model.days) { day in
                    Text(day.startOfDay, style: .date)
                        .frame(width: 300)
                        .border(Color.blue)
                }
            }
        }
    }
}

04:50 We pull out a view for a single day, leaving the frame and the border in ContentView so that DayView itself doesn't know about its size and stays completely flexible:

struct DayView: View {
    var day: Day
    var body: some View {
        Text(day.startOfDay, style: .date)
    }
}

struct ContentView: View {
    var model = Model.shared
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 0) {
                ForEach(model.days) { day in
                    DayView(day: day)
                        .frame(width: 300)
                        .border(Color.blue)
                }
            }
        }
    }
}

Drawing Points

05:43 We're now ready to draw the points. We first wrap the date label in a VStack so that we can draw the points above it.

The model's data points are values between 0 and 1, and we can calculate each point's position within the day by comparing its date value to the start of the day. To draw the point, we need to know how large the day view is, so we add a GeometryReader to the VStack:

struct DayView: View {
    var day: Day
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in

            }
            Text(day.startOfDay, style: .date)
        }
    }
}

06:27 The reason for not creating a Shape for both the points and the lines (instead of using the GeometryReader) is that we might want to style the points differently than the lines. Later, we might also want to make the points interactive, and for this, they have to be their own views.

07:29 We then loop over the day's points — stored in its values array — drawing a circle for each point. The geometry reader makes sure that the day view takes up the available space, and we can use offsets to position each point within that space. We get the vertical offset by inverting the point's value (the higher the value, the higher the y position) and multiplying it by the view's height:

struct DayView: View {
    var day: Day
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in
                ForEach(day.values) { dataPoint in
                    Circle()
                        .frame(width: 5, height: 5)
                        .offset(x: 0, y: (1 - dataPoint.value) * proxy.size.height)
                }

            }
            Text(day.startOfDay, style: .date)
        }
    }
}

09:02 We aren't applying a horizontal offset yet, so the circles are all aligned to the leading edge of the view. But for a point at this "zero" time, it makes more sense to be centered on the day view's leading edge, so we add another offset modifier to move the circles back with half their size:

struct DayView: View {
    var day: Day
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in
                ForEach(day.values) { dataPoint in
                    Circle()
                        .frame(width: 5, height: 5)
                        .offset(x: -2.5, y: -2.5)
                        .offset(x: 0, y: (1 - dataPoint.value) * proxy.size.height)
                }

            }
            Text(day.startOfDay, style: .date)
        }
    }
}

09:37 To get each point's horizontal position, we calculate the number of seconds between the point's date and the day's startOfDay, divide that by the total number of seconds in one day, and multiply by the view's width:

struct DayView: View {
    var day: Day
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in
                ForEach(day.values) { dataPoint in
                    let relativeX = dataPoint.date.timeIntervalSince(day.startOfDay) / (24 * 60 * 60)
                    Circle()
                        .frame(width: 5, height: 5)
                        .offset(x: -2.5, y: -2.5)
                        .offset(x: relativeX * proxy.size.width, y: (1 - dataPoint.value) * proxy.size.height)
                }

            }
            Text(day.startOfDay, style: .date)
        }
    }
}

10:53 That looks good, and scrolling is smooth. In a different version of this app, we added a view for each data point, causing the day views to have varying widths. For that setup, SwiftUI has a hard time getting the scroll bar's position and size right. Having fixed-width day views works much better.

Drawing Lines

12:01 Next, let's add lines between the points. We zip the points array with a copy of the array that drops the first element, which gives us pairs of adjacent points. We'll loop over these pairs with another ForEach, so we have to wrap both ForEach views in a ZStack:

struct DayView: View {
    var day: Day
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in
                ZStack {
                    let zipped = zip(day.values, day.values.dropFirst())
                    ForEach(zipped, id: \.0.id) { (value, next) in
                        Line(from: value.point(in: day), to: next.point(in: day))
                    }
                    ForEach(day.values) { dataPoint in
                        // ...
                    }
                }
            }
            Text(day.startOfDay, style: .date)
        }
    }
}

14:13 To draw the line between two points, we again need to calculate a point's relative position within its day view. So we pull out a method that returns a UnitPoint from a data point. We could let the method calculate the start of day from the data point's date value, but since the model already provides this information, we add it as an argument:

struct DataPoint: Identifiable {
    var id = UUID()
    var date: Date
    var value: Double // between 0...1

    func point(in day: Day) -> UnitPoint {
        let x = date.timeIntervalSince(day.startOfDay) / (24 * 60 * 60)
        let y = 1 - value
        return UnitPoint(x: x, y: y)
    }
}

14:59 And we use this method where we draw the points:

struct DayView: View {
    var day: Day
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in
                ZStack {
                    let zipped = zip(day.values, day.values.dropFirst())
                    ForEach(zipped, id: \.0.id) { (value, next) in
                        Line(from: value.point(in: day), to: next.point(in: day))
                    }
                    ForEach(day.values) { dataPoint in
                        let point = dataPoint.point(in: day)
                        Circle()
                            .frame(width: 5, height: 5)
                            .offset(x: -2.5, y: -2.5)
                            .offset(x: point.x * proxy.size.width, y: point.y * proxy.size.height)
                    }
                }
            }
            Text(day.startOfDay, style: .date)
        }
    }
}

15:30 Then we create the Line shape that draws a line between two unit points. To get the from position in the given rect, we need to multiply the point's x with the rect's width and the point's y with the rect's height:

struct Line: Shape {
    var from: UnitPoint
    var to: UnitPoint

    func path(in rect: CGRect) -> Path {
        Path { p in
            p.move(to: CGPoint(x: from.x * rect.size,width, y: from.y * rect.size.height))

        }
    }
}

16:59 We can clean up our code by defining a helper function that multiplies a UnitPoint with a CGPoint, and also one that adds up two CGPoints:

func *(lhs: UnitPoint, rhs: CGSize) -> CGPoint {
    CGPoint(x: lhs.x * rhs.width, y: lhs.y * rhs.height)
}

func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
    CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}

18:10 Now we multiply both unit points with the rectangle's size, and — even though we've never seen the path(in:) being called with a non-zero origin — we offset the points by the rectangle's origin point:

struct Line: Shape {
    var from: UnitPoint
    var to: UnitPoint

    func path(in rect: CGRect) -> Path {
        Path { p in
            p.move(to: rect.origin + from * rect.size)
            p.addLine(to: rect.origin + to * rect.size)
        }
    }
}

18:34 Finally, we stroke the line:

struct DayView: View {
    var day: Day
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in
                ZStack {
                    let zipped = zip(day.values, day.values.dropFirst())
                    ForEach(zipped, id: \.0.id) { (value, next) in
                        Line(from: value.point(in: day), to: next.point(in: day))
                            .stroke(lineWidth: 1)
                    }
                    ForEach(day.values) { dataPoint in
                        // ...
                    }
                }
            }
            Text(day.startOfDay, style: .date)
        }
    }
}

18:44 ForEach wants a RandomAccessCollection, so we have to convert the zipped sequence into an array:

struct DayView: View {
    var day: Day
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in
                ZStack {
                    let zipped = Array(zip(day.values, day.values.dropFirst()))
                    ForEach(zipped, id: \.0.id) { (value, next) in
                        Line(from: value.point(in: day), to: next.point(in: day))
                            .stroke(lineWidth: 1)
                    }
                    ForEach(day.values) { dataPoint in
                        // ...
                    }
                }
            }
            Text(day.startOfDay, style: .date)
        }
    }
}

19:32 We now see the lines, but they don't line up with the points. The problem is that the ZStack around the two ForEach views centers its child views by default, and we're offsetting our points with the assumption that the origin lies in the top-leading corner. We fix this by setting the alignment of the ZStack to .topLeading:

struct DayView: View {
    var day: Day
    var body: some View {
        VStack(alignment: .leading) {
            GeometryReader { proxy in
                ZStack(alignment: .topLeading) {
                    let zipped = Array(zip(day.values, day.values.dropFirst()))
                    ForEach(zipped, id: \.0.id) { (value, next) in
                        Line(from: value.point(in: day), to: next.point(in: day))
                            .stroke(lineWidth: 1)
                    }
                    ForEach(day.values) { dataPoint in
                        let point = dataPoint.point(in: day)
                        Circle()
                            .frame(width: 5, height: 5)
                            .offset(x: -2.5, y: -2.5)
                            .offset(x: point.x * proxy.size.width, y: point.y * proxy.size.height)
                    }
                }
            }
            Text(day.startOfDay, style: .date)
        }
    }
}

To Do

20:42 Things are looking good, but there's a lot more we can do. We still have to connect the lines between days. We also want to observe the day currently in view and be able to select a different day. Let's look at these things next time.

Resources

  • Sample Code

    Written in Swift 5.5

  • 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