Flexible layouts in SwiftUI
Article inspired by Mauricio Meirelles's GridView.
This year SwiftUI has learned grid layouts thanks to the new LazyVGrid
and LazyHGrid
.
While these new components unlock very powerful layouts, SwiftUI doesn't offer the same flexibility as UICollectionView
just yet.
I'm referring to the possibility of having multiple views of different sizes in the same container, and make the container automatically wrap to the next row when there's no more space available.
Let's wait for another year? In this article, let's explore how we can build our FlexibleView
. Here's a sneak peek of the final result:
Introduction
From the preview above, it should be clear what we're aiming for. Let's see what our view needs to know to achieve that:
- the total horizontal space available
- the size of each element
- a way to distribute each element into the right place
Time to get started!
Getting the size of a view
The first two points come down to getting the size of a view: coincidentally, last week's article, Sharing layout information in SwiftUI, covers how to do so. If you haven't already, please have a read.
This article will use the extension from that article:
extension View {
func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
private struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
For an in-depth explanation, refer to Sharing layout information in SwiftUI.
1. Getting the available horizontal space
The first piece of information FlexibleView
needs is the total horizontal available space:
to get it, we will use one view that fills all the space offered while ensuring that its height doesn't exceed what our FlexibleView
needs.
A few examples of such views are Color
and Rectangle
:
var body: some View {
Color.clear
.frame(height: 1)
.readSize { size in
// the horizontal available space is size.width
}
}
Since this first component is used only to get layout information, we use Color.clear
as, effectively, it's an invisible layer that doesn't obstruct the rest of the view.
We also set a .frame
modifier to limit the Color
height to 1
point, ensuring that our view will take as much height as the rest of the view components need.
This Color
is not really part of our view hierarchy, we can hide it with a ZStack
:
var body: some View {
ZStack {
Color.clear
.frame(height: 1)
.readSize { size in
// the horizontal available space is size.width
}
// Rest of our implementation
}
}
Lastly, let's take advantage of the callback from readSize
to store our available horizontal space in FlexibleView
:
struct FlexibleView: View {
@State private var availableWidth: CGFloat = 0
var body: some View {
ZStack {
Color.clear
.frame(height: 1)
.readSize { size in
availableWidth = size.width
}
// Rest of our implementation
}
}
}
Great! We have a view that fills all the available horizontal space and only takes one point in height. We can move to the second step.
2. Getting each element size
Before talking about how to get each element size, let's set up our view to accept elements.
For simplicity's sake, and for reasons that will become clear later, we will ask for:
- a
Collection
ofHashable
elements - a method that, given an element of that collection, returns a
View
.
struct FlexibleView<Data: Collection, Content: View>: View
where Data.Element: Hashable {
let data: Data
let content: (Data.Element) -> Content
// ...
var body: some View {
// ...
}
}
Let's forget about the final layout and focus only on getting each element size:
- since our elements conform to Hashable
, we can define a new dictionary that holds the size of each element view - then we can use a ForEach
to layout all elements and read their view sizes via the .readSize
extension
struct FlexibleView<...>: View where ... {
let data: Data
let content: (Data.Element) -> Content
@State private var elementsSize: [Data.Element: CGSize] = [:]
// ...
var body: some View {
ZStack {
// ...
ForEach(data, id: \.self) { element in
content(element)
.fixedSize()
.readSize { size in
elementsSize[element] = size
}
}
}
}
}
Note how we use the .fixedSize
modifier on the element view, to let it take as much space as needed, regardless of how much space is available.
And with this, we now have each element size! Time to face the last step.
3. A way to distribute each element into the right place
Before proceeding, let's see what we have so far:
- a collection of elements
- the total available width of our view
- the size of each element view
This is all FlexibleView
needs to distribute the elements views into multiple lines:
struct FlexibleView<...>: View where ... {
// ...
func computeRows() -> [[Data.Element]] {
var rows: [[Data.Element]] = [[]]
var currentRow = 0
var remainingWidth = availableWidth
for element in data {
let elementSize = elementSizes[element, default: CGSize(width: availableWidth, height: 1)]
if remainingWidth - elementSize.width >= 0 {
rows[currentRow].append(element)
} else {
// start a new row
currentRow = currentRow + 1
rows.append([element])
remainingWidth = availableWidth
}
remainingWidth = remainingWidth - elementSize.width
}
return rows
}
}
computeRows
distributes all elements in multiple rows, while keeping the order of the elements and ensuring that each row width doesn't exceed the availableWidth
obtained earlier.
In other words, the function returns an array of rows, where each row contains the array of elements for that row.
We can then combine this new function with HStack
s and VStack
s to obtain our final layout:
struct FlexibleView<...>: View where ... {
// ...
var body: some View {
ZStack {
// ...
VStack {
ForEach(computeRows(), id: \.self) { rowElements in
HStack {
ForEach(rowElements, id: \.self) { element in
content(element)
.fixedSize()
.readSize { size in
elementsSize[element] = size
}
}
}
}
}
}
}
// ...
}
At this point,
FlexibleView
will only take as much height as thisVStack
.
And with this, we're done! The final project also addresses spacing between elements and different alignments: adding these features is trivial once the fundamentals above are understood.
Conclusions
This year SwiftUI has gained powerful and welcome new components that let us create new interfaces that were tricky to build before. SwiftUI still lacks certain important features/aspects: this should not discourage us and try and find a solution on our own!
FlexibleView
is just an example. What other views have you built yourself? Please let me know!
As always, thank you for reading and stay tuned for more SwiftUI articles.