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
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
CGPoint
s:
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.