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 investigate SwiftUI's new group APIs for building custom lazy container views.

00:06 Today we're going to explore SwiftUI's new Group and ForEach APIs and use them to build lazy container views.

It's pretty cool we get to do this now, because so far, we haven't been able to reliably implement laziness in SwiftUI. The containers we've built, e.g. the flow layout view, have always come with the disclaimer that they may not be usable with larger datasets. Thanks to these new APIs, it looks like we can finally do something about that.

00:46 We'll first build a page view container that displays just one or two of the many subviews we pass into it. From there, we can explore building a container that responds to scrolling as well.

Lazy Page View

01:08 Let's start writing the LazyPageView container. We give it a view builder parameter for its content view. In the body view, we create a Group view, passing in content, as well as a closure that receives the subviews of content. In the closure, we output just the first subview from that collection:

struct LazyPageView<Content: View>: View {
    @ViewBuilder var content: Content

    var body: some View {
        Group(subviews: content) { coll in
            coll.first
        }
    }
}

01:54 We use our new container view in ContentView, creating 100 page views in the view builder, each displaying its own index in a text view:

struct ContentView: View {
    var body: some View {
        LazyPageView {
            ForEach(0..<100) { ix in
                Text("Subview \(ix)")
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color(hue: .init(ix)/100, saturation: 0.8, brightness: 0.9), in: .rect(cornerRadius: 8))
            }
        }
        .padding()
    }
}

03:17 There are a few ways to test whether or not the LazyPageView only creates the first subview. We can let each subview print its index in an onAppear action. We can also propagate preference values from the page views and see which values show up. It'd also be interesting to try adding some kind of state to the page views and seeing if this state gets destroyed when we switch between pages — this will be a good sign that SwiftUI actually removes a page view from the attribute graph when it's offscreen.

Print on Appear

04:05 Starting with the simplest approach — printing a message in onAppear — we see only the first page’s index printed to the console when we run the app:

struct ContentView: View {
    var body: some View {
        LazyPageView {
            ForEach(0..<100) { ix in
                Text("Subview \(ix)")
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color(hue: .init(ix)/100, saturation: 0.8, brightness: 0.9), in: .rect(cornerRadius: 8))
                    .onAppear {
                        print("onAppear", ix)
                    }
            }
        }
        .padding()
    }
}

04:41 To see what happens when we switch between pages, we wrap the page view in a VStack, along with buttons to modify the index of the subview we want to display. Since we need access to the number of elements in the subviews collection, we have to define these buttons inside the Group view:

struct LazyPageView<Content: View>: View {
    @ViewBuilder var content: Content
    
    var body: some View {
        Group(subviews: content) { coll in
            VStack {
                HStack {
                    Button("Previous") { }
                    Button("Next") { }
                }
            }
            coll.first
        }
    }
}

06:32 We need to store the current index as a state property of the view. We initialize the property with index 0, and we increment and decrement the index in the action closures of the buttons:

struct LazyPageView<Content: View>: View {
    @ViewBuilder var content: Content
    @State private var idx = 0
    
    var body: some View {
        Group(subviews: content) { coll in
            VStack {
                HStack {
                    Button("Previous") { idx -= 1 }
                    Button("Next") { idx += 1 }
                }
            }
            coll[idx]
        }
    }
}

07:07 Based on the current index and the end index of the subviews collection, we can disable the previous or next button when we're at either end of the range so that we prevent the index from going out of bounds:

struct LazyPageView<Content: View>: View {
    @ViewBuilder var content: Content
    @State private var idx = 0
    
    var body: some View {
        Group(subviews: content) { coll in
            VStack {
                HStack {
                    Button("Previous") { idx -= 1 }
                        .disabled(idx < 1)
                    Button("Next") { idx += 1 }
                        .disabled(idx >= coll.endIndex-1)
                }
            }
            coll[ix]
        }
    }
}

07:45 Tapping the buttons, we can now step through the page views, and we see the label and hue of the current page changing with each step. In the console, we get one print statement with each change of the page.

View Identity and State

08:18 Next, we want to see if the state of each page view is preserved or destroyed when we switch between pages. For this, we can write a simple Counter view, which has a button that displays the number of times it was tapped:

struct Counter: View {
    @State private var value = 0

    var body: some View {
        Button("\(value)") { value += 1 }
    }
}

08:54 We add a counter to each page view:

struct ContentView: View {
    var body: some View {
        LazyPageView {
            ForEach(0..<100) { ix in
                VStack {
                    Text("Subview \(ix)")
                    Counter()
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .background(Color(hue: .init(ix)/100, saturation: 0.8, brightness: 0.9), in: .rect(cornerRadius: 8))
                .onAppear {
                    print("onAppear", ix)
                }
            }
        }
        .padding()
    }
}

09:03 When we increment the counter a few times on one page, switch to the next page, and then go back, we see that the counter on the original page is reset to zero. This means that the page view, including its entire subtree, is destroyed when it goes offscreen and reinitialized when it comes back.

Showing Multiple Pages

09:25 It'd also be interesting to see what happens when we show multiple pages at once. We might, for example, want to show the previous page near the edge of the screen. In an if statement, we display a scaled-down version of the previous page behind the current one:

struct LazyPageView<Content: View>: View {
    @ViewBuilder var content: Content
    @State private var idx = 0
    
    var body: some View {
        Group(subviews: content) { coll in
            VStack {
                HStack {
                    Button("Previous") { idx -= 1 }
                        .disabled(idx < 1)
                    Button("Next") { idx += 1 }
                        .disabled(idx >= coll.endIndex-1)
                }
            }
            ZStack {
                if idx > 0 {
                    coll[idx-1]
                        .scaleEffect(0.95)
                        .offset(x: -20)
                }
                coll[idx]
            }
        }
    }
}

10:35 After tapping the next button, we see the first page sticking out behind the second page. When we tap the counter of the first page, go to the second page, and then go back, the counter is reset, which isn't what we want. With a setup like this, we want each page to maintain its state as long as it's onscreen.

11:02 However, the view losing its state actually makes sense, considering how SwiftUI works; the lifetime of a view is tied to the view's identity, which in turn is coupled to its position in the view tree. When we're on the second page, the larger page view is the one created by the coll[idx] line, and the smaller, previous page is created by the coll[idx-1] line inside the if statement. When we switch back to the first page, the view in the if statement is removed, and the coll[idx] view now holds the first page. In other words, each time we switch pages, their views change positions in the view tree, and they get destroyed and recreated.

11:56 We can work around this by rendering a computed range of subviews using a ForEach instead of writing an if statement. This range would be 0...0 for the first page, 0...1 for the second page, 1...2 for the third page, etc:

let range = idx == 0 ? idx...idx : idx-1...idx

13:12 To make things easier, we replace the ZStack and the scale effect with an HStack that displays the computed range of subviews:

struct LazyPageView<Content: View>: View {
    @ViewBuilder var content: Content
    @State private var idx = 0
    
    var body: some View {
        Group(subviews: content) { coll in
            VStack {
                HStack {
                    Button("Previous") { idx -= 1 }
                        .disabled(idx < 1)
                    Button("Next") { idx += 1 }
                        .disabled(idx >= coll.endIndex-1)
                }
            }
            HStack {
                let range = idx == 0 ? idx...idx : idx-1...idx
                ForEach(range, id: \.self) { ix in
                    coll[ix]
                }
            }
        }
    }
}

14:31 Running the app, we first see a single page. When we increase the counter to 4 and then tap the next button, we see two pages side by side, and the counter of the first page is still set to 4.

15:07 So, our views have a stable identity now, and their state is preserved for as long as they're onscreen. Only when a page goes away completely and then comes back do we see its state reset as we expect to happen.

Next

15:33 Next time, we want to take things further and try building a LazyVStack. This will be more difficult, because we'll need to figure out which part of the container is visible in the scroll view to know how many items we need to render.

Resources

  • Sample Code

    Written in Swift 6.0

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

162 Episodes · 56h09min

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