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
:
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])
})
}
}
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
.