Swift Talk # 167

Building a Collection View (Part 1)

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

As a first step toward a collection view, we enable child views to communicate their sizes to a parent view.

00:06 Today we'll start building a collection view in SwiftUI. The collection view should support various layouts, such as a grid layout or a flow layout.

00:28 The flow layout is especially tricky, because before we can compute this layout, we need to know the sizes of all the elements in it. And calculating those sizes in SwiftUI isn't as straightforward as it is in UIKit.

00:50 It's worth mentioning that we won't be working on the laziness that we know from UICollectionView; we're focusing only on the collection view's capability to lay out elements in various ways.

Setup

01:27 We start with an empty SwiftUI project, and we set up some sample data in the form of an array of strings of random lengths:

let strings: [String] = (1...10).map {
    "Item \($0) " + String(repeating: "x", count: Int.random(in: 0...10)) 
}

03:02 We first display the sample strings in labels, laid out horizontally in an HStack, with some padding and a gray background so that we can clearly see the sizes of the views:

struct ContentView: View {
    let strings: [String] = (1...10).map { "Item \($0) " + String(repeating: "x", count: Int.random(in: 0...10)) }
    
    var body: some View {
        HStack {
            ForEach(strings, id: \.self) {
                Text($0)
                    .padding(10)
                    .background(Color.gray)
            }
        }
    }
}

03:57 SwiftUI tries to make these labels fit onscreen by aggressively wrapping the texts over multiple lines until the labels are narrow enough to all fit next to each other. In writing a flow layout, we want the labels to keep their natural widths and to be laid out over multiple rows.

04:16 We write CollectionView with an API similar to ForEach: it takes a generic collection of elements that need to be displayed. As a second parameter, we take a function that can produce a view for any element — we can think of this function as our version of cellForItem(at:):

struct CollectionView<Elements, Content>: View where Elements: RandomAccessCollection, Content: View {
    var data: Elements
    var content: (Elements.Element) -> Content
    var body: some View {
        // ...
    }
}

06:17 For the body view, we start in the same way as we did with our initial setup: we use a ForEach in an HStack to add all subviews. In order to pass the elements into ForEach, we have to specify that the elements conform to Identifiable:

struct CollectionView<Elements, Content>: View where Elements: RandomAccessCollection, Content: View, Elements.Element: Identifiable {
    var data: Elements
    var content: (Elements.Element) -> Content
    var body: some View {
        HStack {
            ForEach(data) {
                self.content($0)
            }
        }
    }
}

07:16 Now we can use CollectionView in the ContentView, and we should get the same end result as before:

struct ContentView: View {
    let strings: [String] = (1...10).map { "Item \($0) " + String(repeating: "x", count: Int.random(in: 0...10)) }
    
    var body: some View {
        CollectionView(data: strings) {
            Text($0)
                .padding(10)
                .background(Color.gray)
        }
    }
}

07:54 The compiler now reminds us that String needs to be Identifiable in order to use the sample strings as the elements of the collection view. As a shortcut, we simply add the conformance. This is a bad idea, but in an actual codebase, we wouldn't be using strings as elements; instead, we'd have our own data structure that could conform to Identifiable:

// todo hack
extension String: Identifiable {
    public var id: String { self }
}

We run the app, and the layout looks the same as before, but at least our basic setup works.

Getting the Label Sizes

08:21 The next step is finding out the sizes of the labels. We need a way to send this information from each rendered label up the view hierarchy and back to the collection view.

09:19 We do this by wrapping each element's content view in a helper view that can read its own size and tell the collection view about it. By using a GeometryReader, we receive a proxy value with size information:

struct PropagateSize<V: View>: View {
    var content: V
    var body: some View {
        GeometryReader { proxy in
            self.content
        }
    }
}

10:36 In order to send the size information up the view hierarchy, we can use the preference mechanism. We could think of this as the opposite of the environment object, which sends context down the hierarchy.

11:03 The preference modifier can set a value for a certain key, and it uses types as keys. So we need to write a new type that we can use as our preference key:

struct CollectionViewSizeKey: PreferenceKey {

}

12:11 Then we have to decide the type of the value that belongs to the key. Because the preference value comprises all sizes of the collection view's elements, we choose an array of CGSize:

struct CollectionViewSizeKey: PreferenceKey {
    typealias Value = [CGSize]
}

13:00 Having defined the type, we can further implement the key by supplying a default value, along with a reduce function that combines values. In this case, we will receive an array with a single size element, which we'll join together with the array that is the preference's value:

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

14:25 Now PropagateSize can use this key to pass its size (wrapped in an array) to the preference value:

struct PropagateSize<V: View>: View {
    var content: V
    var body: some View {
        GeometryReader { proxy in
            self.content
                .preference(key: CollectionViewSizeKey.self, value: [proxy.size])
        }
    }
}

14:46 In CollectionView, we wrap each content view in a PropagateSize, and we listen for changes to the size preference:

struct CollectionView<Elements, Content>: View where Elements: RandomAccessCollection, Content: View, Elements.Element: Identifiable {
    var data: Elements
    var content: (Elements.Element) -> Content
    var body: some View {
        HStack {
            ForEach(data) {
                PropagateSize(content: self.content($0))
            }
        }.onPreferenceChange(CollectionViewSizeKey.self) {
            print($0)
        }
    }
}

15:40 Running the app, we see an array of sizes printed to the console, but they're all the same size, which can't be correct.

16:08 Let's look at the PropagateSize view again. Since we wrapped the GeometryReader around the content view, we're not getting the size of the content, but rather the size of the wrapping view. We have to turn this around somehow.

16:33 We start with the content view and we add a background, which gets the same size as the content, to it. Then we use Color.clear to create an empty view upon which we can set the size preference:

struct PropagateSize<V: View>: View {
    var content: V
    var body: some View {
        content
            .background(GeometryReader { proxy in
                Color.clear.preference(key: CollectionViewSizeKey.self, value: [proxy.size])
            })
    }
}

/*
[(34.0, 283.5), (34.0, 327.5), (34.5, 107.5), (34.0, 305.5), ... ]
*/

17:42 Now we receive an array of different sizes in the collection view. When we change the HStack into a ZStack, the labels are no longer squished to fit on the screen, but they're all placed over each other with their intrinsic sizes.

18:38 Up next: we'll store the sizes and start computing layouts from them, and we should be able to replicate the flow layout of UICollectionView.

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