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 show how to detect which views in a lazy stack are currently onscreen.

00:06 We're doing this episode remotely, since the work in our studio due to the water damage is ongoing. Hopefully, we'll be back next week; we'll see. We received some concerned messages after last week, but everything should turn out OK.

00:26 Today we'll talk about how we can track visible items — for example when we're displaying a list of things and we want to know which items are currently onscreen. Perhaps we need this information for analytics, or we might want to know when an ad is within the viewport. We have a solution for this and, although it isn't extensively tested in a production environment, it can be a good starting point.

LazyVStack

01:05 Let's first define an Item struct. We conform it to Identifiable, so that we can easily iterate over the values in a ForEach view. Then, we create 100 sample items by mapping over a range from 0 to 100:

struct Item: Identifiable, Hashable {
    var id = UUID()
    var number: Int

    var name: String {
        "Item \(number)"
    }
}

let sampleItems = (0..<100).map { Item(number: $0) }

01:59 In ContentView, we wrap a LazyVStack in a scroll view. Inside the stack view, we loop over the sample items. For each one, we add a text view with some padding, a background color, and a frame that can grow as wide as the scroll view:

struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(sampleItems) { item in
                    Text(item.name)
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(Color.blue)
                        .padding(.horizontal)
                }
            }
        }
    }
}

02:48 One approach to track which of the item views are onscreen is by using onAppear and onDisappear, but that doesn't seem to be the most reliable API, and it might not provide all the information we need. In a container like LazyVStack, items may be created before they're visible to the user, so onAppear can be called early. Therefore, if we really need to know exactly which views are onscreen, this might not be the right tool.

Tracking Items with a Preference

03:31 Another way to go is by using a preference. We can first propagate up all the items that are there, as the LazyVStack takes care of removing and inserting the views into the attribute graph.

03:51 We create a preference key to store an array of visible items. The default value of this key is an empty array. In the key's reduce method, we combine two arrays into one. Then, we let each item view propagate its own item:

struct VisibleItemsPreference: PreferenceKey {
    static var defaultValue: [Item] = []
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value.append(contentsOf: nextValue())
    }
}

struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(sampleItems) { item in
                    Text(item.name)
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(Color.blue)
                        .padding(.horizontal)
                        .preference(key: VisibleItemsPreference.self, value: [item])
                }
            }
        }
    }
}

04:23 Using overlayPreferenceValue, we can display the number values from the propagated items by mapping over the items, sorting them by their numbers, mapping each number to a string, and joining the strings together with commas. Then, we output a Text view with the combined string as an overlay:

struct ContentView: View {
    var body: some View {
        ScrollView {
            // ...
        }
        .overlayPreferenceValue(VisibleItemsPreference.self, { value in
            let str = value
                .sorted(by: { $0.number < $1.number })
                .map { "\($0.number)" }
                .joined(separator: ", ")
            Text(str)
                .foregroundStyle(.white)
                .background(.black)
            }
        })
    }
}

05:31 Now we can see that views are created for items 0 to 25. The item at the bottom of the screen has the number 12 or 13, so the LazyVStack seems to prepare a whole extra page of items.

Filtering Items by their Bounds

06:19 To further zero in on the items that are actually visible to the user, we can propagate up the bounds of each cell and check if they intersect with the scroll view's bounds.

06:47 We change the preference key to propagate a payload consisting of an item and an Anchor<CGRect>:

struct Payload {
    var item: Item
    var bounds: Anchor<CGRect>
}

struct VisibleItemsPreference: PreferenceKey {
    static var defaultValue: [Payload] = []
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value.append(contentsOf: nextValue())
    }
}

07:33 We can construct a bounds anchor by calling anchorPreference. In the transform closure we provide, we can wrap the anchor, together with an item, in a payload value:

struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(sampleItems) { item in
                    Text(item.name)
                        .padding()
                        .frame(maxWidth: .infinity)
                        .background(Color.blue)
                        .padding(.horizontal)
                        .anchorPreference(key: VisibleItemsPreference.self, value: .bounds, transform: { anchor in
                            [.init(item: item, bounds: anchor)]
                        })
                }
            }
        }
        // ...
    }
}

08:28 By placing a geometry reader in the overlay of the scroll view, we can read out the bounds of the scroll view and also resolve each item's anchor, and then see if the two rects intersect.

09:26 Mapping over the sorted items, we return a tuple of each item and a Boolean indicating whether the item is in the scroll view's bounds. To find out if the item is in bounds, we first get the scroll view's frame in the local coordinate space. Then we let the geometry proxy resolve the item's anchor in the same coordinate space, and finally, we check if the frames intersect:

struct ContentView: View {
    var body: some View {
        ScrollView {
            // ...
        }
        .overlayPreferenceValue(VisibleItemsPreference.self, { value in
            GeometryReader { proxy in
                let myFrame = proxy.frame(in: .local)
                let arr = value.sorted(by: { $0.item.number < $1.item.number }).map { item in
                    let inBounds = myFrame.intersects(proxy[item.bounds])
                    return (inBounds: inBounds, item: item.item)
                }
                

            }
        })
    }
}

11:13 For each of these tuples, we construct a Text view, and we set its foreground color based on the inBounds Boolean:

struct ContentView: View {
    var body: some View {
        ScrollView {
            // ...
        }
        .overlayPreferenceValue(VisibleItemsPreference.self, { value in
            GeometryReader { proxy in
                let myFrame = proxy.frame(in: .local)
                let arr = value.sorted(by: { $0.item.number < $1.item.number }).map { item in
                    let inBounds = myFrame.intersects(proxy[item.bounds])
                    return (inBounds: inBounds, item: item.item)
                }
                let texts: [Text] = arr.map { (inBounds: Bool, item: Item) in
                    Text("\(item.number)")
                        .foregroundStyle(inBounds ? .primary : .secondary)
                }


            }
        })
    }
}

12:27 It'd be nice if we could combine the text views into a single one like this:

struct ContentView: View {
    var body: some View {
        ScrollView {
            // ...
        }
        .overlayPreferenceValue(VisibleItemsPreference.self, { value in
            GeometryReader { proxy in
                let myFrame = proxy.frame(in: .local)
                let arr = value.sorted(by: { $0.item.number < $1.item.number }).map { item in
                    let inBounds = myFrame.intersects(proxy[item.bounds])
                    return (inBounds: inBounds, item: item.item)
                }
                let texts: [Text] = arr.map { (inBounds: Bool, item: Item) in
                    Text("\(item.number)")
                        .foregroundStyle(inBounds ? .primary : .secondary)
                }
                texts.joined(separator: Text(","))
                    .foregroundStyle(.white)
                    .background(.black)
                    .frame(maxHeight: .infinity)
            }
        })
    }
}

13:47 We can implement this joined method in an extension of [Text]. We first check if the array has an element at all. If it doesn't, we return a Text view with an empty string. Otherwise, we take the first element, and we reduce the rest of the collection into it by repeatedly appending a separator and a next element:

extension [Text] {
    func joined(separator: Text) -> Text {
        guard let f = first else { return Text("") }
        return dropFirst().reduce(f, { $0 + separator + $1 })
    }
}

14:55 Now we get to see exactly which of the items are visible, because their numbers are highlighted in our text view. We can also see that the scroll view's bounds are identical to the safe area — as soon as an item scrolls up out of the safe area, we no longer consider it to be visible, even though we can still see it in the top of the screen, but that's just how the scroll view works. If we add a border to the scroll view, we'll see that it draws around the safe area, and it doesn't include the insets containing, for example, the iPhone's notch and the home indicator.

15:56 It's also interesting to see how LazyVStack doesn't initialize every subview we add to it, but only the visible views, plus some extra ones that are positioned above and below the visible part of the scroll view's content view. So, even if we'd add 100,000 items to the LazyVStack, the container keeps a constant number of subviews around to fill the screen, and when we scroll down, the number of values in our preference doesn't increase. We aren't experts on SwiftUI's performance when there are a million items in a scroll view, but we think this approach could be made to work even for very large lists.

17:33 That's it for today. Hopefully, the next episode will be filmed back in the studio; that'd be nice.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

165 Episodes · 57h18min

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