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:

  1. the total horizontal space available
  2. the size of each element
  3. 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:

  1. a Collection of Hashable elements
  2. 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 HStacks and VStacks 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 this VStack.

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.

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all