SwiftUI Layout Protocol — iOS 16.0+

Valentin Jahanmanesh
12 min readDec 26, 2022

--

LazyVGrid and LazyHGrid are good enough for most use cases, but UI/UX designers’ creativity has no end so we always will be challenged by their new ideas and designs. Today I will show you how we can use SwiftUI Layout protocol to create a new fully custom layout view, which from now on we will call it HorizontalTileGrid.

About What We Are Going To Build: HorizontalTileGrid

our HorizontalTileGrid layouts its child views based on an array of block types. This feature is super useful when your UI is dynamic and would be changed on run time like when you call an API to retrieve the information of how you need to display, arrange, and position views on the screen. but before everything, let me show you what we are going to develop

and here is the code (don’t worry if you don’t know how this code works, but I want you to have a birds-eye view of what we are going to achieve)

/// Defines a list of display types
public let restaurantsLayouts: [HorizontalTileGrid.BlockType] = [
.full,
.double,
.fullCustom(width:300)]

/// Creates a HorizontalTileGrid
HorizontalTileGrid(templates: self.restaurantsLayouts) {
ForEach(restaurants) { food in
RestaurantItemView(food: food)
.padding(1)
}
}

What is Layout Protocol

The Layout protocol in SwiftUI defines a set of methods and properties that can be used to describe the layout of a view and its subviews. These methods and properties allow us to specify the position, size, and arrangement of views within a parent view, as well as the alignment, spacing, and other layout-related attributes of those views.

SwiftUI allows us to use the Layout protocol to build entirely custom layouts for our views, and our custom layouts can be utilized in the same way as HStack, VStack, or any of the other built-in layout types. In most cases, we will use SwiftUIs' built-in layout containers, such as HStackLayout and VStackLayout, LazyVGrid, and LazyHGrid, to build complex UIs. but there will be some times when we need to customize and build a complex layout.

We are now, for the first time since SwiftUIs inception, directly able to ask about the minimum, ideal, or maximum view sizes, or can even get each views layout priority, among other cool values by using Proxies (which we will get to know them further in this article)

so, let’s start developing our HorizontalTileGrid by creating a simple struct and conforming to the Layout protocol

struct HorizontalTileGrid: Layout {
}

So simple, isn’t it? but it won’t do anything because we haven’t customized it yet. then let’s go and implement the two most important functions of the Layout protocol: sizeThatFits and placeSubviews

struct HorizontalTileGrid: Layout {
public func HorizontalTileGrid(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
// Not implemented Yet
}


public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
// Not implemented Yet
}
}

I am going to elaborate on those two functions (and then I will talk about all the parameters of those two):

sizeThatFits:

in this function, We calculate and return the final dimensions of our Layout by utilizing all of the knowledge that the layout object will provide us. The sizeThatFits function has some interesting parameters which we will use to compute the final size of our HorizontalTileGrid.

in SwiftUI, views choose their own size.

so our HorizontalTileGrid needs to tell to its parent what its ideal size would be. We will use this function to run over all of the child views and ask them about their ideal size based on the results, We can calculate the ideal size of our HorizontalTileGrid which can layout and arrange all the child views ideally

placeSubviews:

The second method that We have to implement is placeSubviews. We’ll use this function to calculate and set the size and exact position of the child views. This method takes the same parameters as the sizeThatFits function, and it also takes an extra parameter: bounds that contain the region coordinates where our HorizontalTileGrid is on the screen. Remember, views pick their own size in SwiftUI, so our layout container will get the size that it asks for via the Proposal parameter.

Before hopping to the code, I need to talk about the type of blocks in HorizontalTileGrid. our HorizontalTileGrid view displays three types of blocks. We use these blocks to generate our template and calculate the sizes, and positions to be able to put the child views in the correct position with the correct size.

 
/// Display types supported by HorizontalTileGrid
///
///
/// ------------- HorizontalTileGrid ---------------------
/// | | D | | D | D |
/// | | 01 | | 01 | 01 |
/// | Block |-----| BlockWithCustomWidth |-----|-----|
/// | | D | | D | D |
/// | | 02 | | 02 | 02 |
/// ------------- HorizontalTileGrid ---------------------
enum BlockType {
/// a block is a square that fills the height of the HorizontalTileGrid (width = height = HorizontalTileGrid.height)
case block

/// a double contains two small slots to hold two views, it divides the height of the HorizontalTileGrid in half and arranges the views horizontally from top to bottom inside those two slots. each slot size would be (width = height = HorizontalTileGrid.height / 4).
case double

/// this is a block but with a custom width. Its height is equal to the HorizontalTileGrid but its width can be various based on the width value.
case blockCustom(width: CGFloat)
}


public struct HorizontalTileGrid: Layout {
....

private let blocks: [BlockType]


/// Initialize the layout with an optional array of block types.
/// - Parameter blocks: an array of block types, in case of the presence of a display type array, the subviews of the layout can be any view, and the number of visible items would be equal to the number of items in the BlockType array. It means that the layout would only display the views that have one representation display type inside the BlockType array.
public init(blocks: [BlockType]) {
self.blocks = blocks
}
...
}
  1. block: a block is a square that fills the height of the HorizontalTileGrid (width = height = HorizontalTileGrid.height)
  2. double: a double contains two small slots to hold two views, it divides the height of the HorizontalTileGrid in half and arranges the views horizontally from top to bottom inside those two slots. each slot size would be (width = height = HorizontalTileGrid.height / 4).
  3. blockCustom(width): this is a block but with a custom width. Its height is equal to the HorizontalTileGrid but its width can be various based on the width value.

Back to the Layout, The first thing that we need to take care of, is the sizeThatFits function. in this function, we need to calculate the size of the HorizontalTileGrid based on the size of its child views. in the UIKit we do have access to all properties of a view like its frame, bounds, and …, whereas in SwiftUI it is not possible to get those pieces of information and the only way to manipulate the attributes of a view is by using modifiers. Thanks to the Layout protocol, in sizeThatFits we have access to the Proxy instances that represent the views that our HorizontalTileGrid is arranging.

so let's take a look at the sizeThatFits function’s parameters

 public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {

1. ProposedViewSize

In SwiftUI, the parent view will propose a size to its child views which normally is the whole space that it has. So basically the available size will be offered from the top of the hierarchy to down, and each child view can say how much space they need to be able to show its content.

— — — — How much space do you need? — — — — →
Parent - - - → Child 1 - - - → Child 2 - - - → Child 3
Parent ← - - - Child 1 ← - - - Child 2 ← - - - Child 3
← - - - - - - - - - - CGSize - - - - - - - - - - - -

2. Subviews — What is a Proxy Instance?

Each view in SwiftUI has a private proxy object, the view and its proxy instance have the same identifier that connects these two together. This proxy instance is like a safe way to access the view attributes. We can use the proxies to get information about the subviews to determine how much space we need to lay out all the views in our HorizontalTileGrid. We can not directly access the view’s proxy instance or create a new one, in some rare cases like here in the Layout, SwiftUI let us access a collection of proxy instances and we will be using those to calculate the size or set the position of the child views.

3. Cache

I bet you know why we need a cache, Yeah correct we can store some of our heavy calculation results, and when we need those, instead of recalculating, we simply can access the stored value. The type of our cache can be anything, an image, a string, a class, or a tuple, and …

Let’s write some code.

The first thing that we need is to know the size of our smallest block, which is the size of one of these 4 blocks.

// 1
func sizes(of subviews: Subviews) -> [CGSize] {
subviews
.map({$0.sizeThatFits(.unspecified)})
}

func minimumHeight(of sizes: [CGSize]) -> CGFloat {
return sizes.map({item in max(item.width, item.height)})
.max(by: {$0 < $1}) ?? 1
}

// 3
private func minimumSquareSize(toFit subviews: Subviews) -> CGSize {
let sizes = sizes(of: subviews)
let minHeight = minimumHeight(of: sizes)
return .init(width: minHeight, height: minHeight)
}

1. I wrote a function to collect the size of all the child views by iterating over the Subviews (Proxies). Each proxy has a sizeThatFits function. As I mentioned, In SwiftUI, views choose their own size but we can propose a size to our child views, so they may take it into account. I didn’t propose any particular size for the child view so I can get the ideal size for each child view.

Layout containers typically measure their subviews by proposing several sizes and looking at the responses. The container can use this information to decide how to allocate space among its subviews. A layout might try the following special proposals:

zero proposal: the view responds with its minimum size.

infinity proposal: the view responds with its maximum size.

unspecified proposal: the view responds with its ideal size.

2. Now that we have all the child view sizes, we can find the smallest size, which will be the size of one of the slots in our .double block type. I added the minimumSquareSize function which calculates and returns that slot size.

Now that we know the size of our minimum displayable block, it is good to calculate the size of the other block types. let’s do it and create a function to calculate this one 👇

func standardSquareSize(from minimumSquareSize: CGSize) {
return CGSize(width: minimumSquareSize.width * 2, height: minimumSquareSize.height * 2)
}

the function above receives the minimum block size and calculates our block size based on that.

now that we have the minimum block size and full block size, we can cache them to improve the performance by not running all those calculations every time we need these two sizes.

public func makeCache(subviews: Subviews) -> Cache {
let minimumSquareSize = minimumSquareSize(toFit: subviews)
let standardSquareSize = standardSquareSize(from: minimumSquareSize)

return (standardSquareSize, minimumSquareSize)
}

the makeCache function is one of the Layout protocol functions. the cache function would be called immediately when the layout is initialized and in sizeThatFits or placeSubviews we have access to it to read or modify it.

now we have all the necessary tools to start writing the code for sizeThatFit

/// This is the original sizeThatFits function, but as I mentioned above, we can't create Proxy instances directly and as we want to be able to test this code, I have added my own custom sizeThatFits function 
public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize {
let blocks = self.blocks
return sizeThatFitsBlocks(proposal: proposal, blocks: blocks, cache: &cache)
}

/// Calculates the final size of the HorizontalTileGrid. the height of the layout would be equal to the height of a `fullBlock` square which will be calculated here **func standardSquareSize(from minimumSquareSize: CGSize) -> CGSize** and the minimum required width for showing the child views would be the sum of all block types
func sizeThatFitsBlocks(proposal: ProposedViewSize, blocks: [BlockType], cache: inout Cache) -> CGSize {
let (standardSquareSize, minimumSquareSize) = cache
var isNextABlock: Bool = false
let widthNeeded = blocks.map { tmp in
switch tmp {
case .fullCustom(let width):
isNextABlock = false
return width
case .block:
if isNextABlock {
isNextABlock = false
return 0
}
isNextABlock = true
return minimumSquareSize.height
case .full:
isNextABlock = false
return standardSquareSize.width
}
}
.reduce(0.0, +)

return CGSize(width: widthNeeded, height: standardSquareSize.height)
}

based on the blocks, I calculated the size of the HorizontalTileGrid, one important thing that I want to mention is that I created two functions, one is the original Layout function and the other is my own function. as You can see I did all the calculations in my own function and the reason is that we need to be able to test our code. as I pointed out, Proxy Object’s initializer is private and we can not create a Proxy directly, therefore, I created an overload of the sizeThatFits function so I can UnitTest my own function.

Now We know how much space we need to be able to show all the child views, It is time to calculate the size and position of each child view and place each of them in the correct position based on their represented block type.

PlaceSubviews

Layout Protocol’s placeSubviews function is where we calculate the position and size of each child view and use its proxy instance to set its position and frame size. the parameters of placeSubviews function are similar to the sizeThatFits except for one, the bounds. I will skip the explanation of all the other parameters.

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
///
}

Bounds: the position of the Layout itself. This variable tells us about the HorizontalTileGrid positioning on the screen. We will use the properties like minX, minY, or … to know what is the topLeft point of our layout and will place and position our child views based on that point.

func calculatePlaceSubviews(in bounds: CGRect, proposal: ProposedViewSize, blocks: [BlockType], cache: inout Cache) -> [CGRect] {
var calculatedPlaces: [CGRect] = []
let (standardSquareSize, minimumSquareSize) = cache
var traversedX = bounds.minX
var nextCellInDoubledColumnPosition: CGPoint? = nil
blocks.indices.forEach { blockIndex in
let point: CGPoint
let size: CGSize
switch blocks[blockIndex] {
case .block:
size = CGSize(width: minimumSquareSize.width, height: minimumSquareSize.height)
if let nextSlotPoint = nextCellInDoubledColumnPosition {
point = nextSlotPoint
nextCellInDoubledColumnPosition = nil
} else {
point = CGPoint(x: traversedX + minimumSquareSize.width.half, y: bounds.minY + minimumSquareSize.height.half)
nextCellInDoubledColumnPosition = point
nextCellInDoubledColumnPosition!.y = point.y + minimumSquareSize.height
traversedX += minimumSquareSize.width
}

case .fullCustom(let width):
nextCellInDoubledColumnPosition = nil
size = CGSize(width: width, height: standardSquareSize.height)
point = CGPoint(x: traversedX + width.half, y: bounds.midY)
traversedX += width
case .full:
nextCellInDoubledColumnPosition = nil
size = CGSize(width: standardSquareSize.width, height: standardSquareSize.height)
point = CGPoint(x: traversedX + standardSquareSize.width.half, y: bounds.minY + standardSquareSize.height.half)
traversedX += standardSquareSize.width
}
calculatedPlaces.append(CGRect(origin: point, size: size))
}
return calculatedPlaces
}

/// Calculates and manages the position of each subview inside the layout view
/// - Parameters:
/// - bounds: the bounds of the layout view
/// - proposal: the proposed size of the layout view which is the one that we returns from this function ** public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) -> CGSize**
/// - subviews: list of all subviews
/// - cache: cache
/// - Returns: returns a list of the positions. Each item in this list represents the position of one subview in the layout view
public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Cache) {
let blocks = self.blocks
let calculated = calculatePlaceSubviews(in: bounds, proposal: proposal, blocks: blocks, cache: &cache)
zip(subviews, calculated).forEach { view, proposedSizePosition in
view.place(at: proposedSizePosition.origin, anchor: .center, proposal: ProposedViewSize(width: proposedSizePosition.size.width, height: proposedSizePosition.size.height))
}
}

as You can see, I created a calculatePlaceSubviews function, and in that function, I iterated over the block types and calculated the exact position and size of each child view and at the end, I returned all those Rects (origin, size). then I grabbed all those Rects in the placeSubviews function, I iterated over all child views and set the center of that view (anchor: .center) and its size by using the place function.

view.place(at: proposedSizePosition.origin, 
anchor: .center,
proposal: ProposedViewSize(width: proposedSizePosition.size.width,
height: proposedSizePosition.size.height))

now everything is ready for using our HorizontalTileGrid, let’s use it. I upload the full implementation here in this repository

ScrollView(.horizontal) {
HorizontalTileGrid(blocks: self.restaurantsLayout) {
ForEach(restaurants) { food in
RestaurantItemView(food: food)
.padding(1)
}
}
}
.scrollIndicators(.hidden)

Thank you for reading this article, It was a demonstration of how we can use the new Layout Protocol in SwiftUI to develop a custom grid view.

references:

https://developer.apple.com/documentation/swiftui/layout

--

--