The Layout Protocol
The Layout
protocol is very useful when you want to switch between different layouts without recreating views. It allows the views to maintain their identity as you switch layouts, and it lets you animate between different layouts.
AnyLayout(HStackLayout()) { Color.orange Capsule() .fill(Color.yellow) Text("Hello")}.padding()
It's also possible to create your own layouts. The Layout protocol lets you implement your own layout algorithm. The two essential methods to implement are sizeThatFits(proposal:subviews:cache:)
and placeSubviews(in:proposal:subviews:cache:)
. The first method is used to compute the size of the entire layout, and the second method let you set the position of the individual subviews.
Building a Flow Layout
One of the things missing in vanilla SwiftUI is a default flow layout. Here is a very basic implementation with configurable spacing. Note that, as with any other layout, the flow layout only becomes as large as needed (it doesn't necessarily fill up the available space).
ScrollView { AnyLayout(FlowLayout(spacing: )) { /* All of the names */ } /* .border(Color.red) */ .padding()}
To build this layout, we start by creating a standalone method that lays out views. This layout
method does not need to know anything about the actual views except for their sizes. It iterates over the views and places them on the current line. If the view does not fit on the current line, it starts a new line. The function returns both the positions of the views and the size of the entire layout.
func layout(sizes: [CGSize], spacing: CGFloat = 8, containerWidth: CGFloat) -> (offsets: [CGPoint], size: CGSize) { var result: [CGPoint] = []var currentPosition: CGPoint = .zerovar lineHeight: CGFloat = 0var maxX: CGFloat = 0 for size in sizes {if currentPosition.x + size.width > containerWidth { currentPosition.x = 0 currentPosition.y += lineHeight + spacing lineHeight = 0 } result.append(currentPosition) currentPosition.x += size.widthmaxX = max(maxX, currentPosition.x) currentPosition.x += spacing lineHeight = max(lineHeight, size.height) } return (result, .init(width: maxX, height: currentPosition.y + lineHeight)) }
The FlowLayout
struct can conform to the layout protocol by implementing the two required methods. To do so, it first gathers the ideal sizes of all the subviews. The layout
function directly returns the size needed.
struct FlowLayout: Layout { var spacing: CGFloat = 8 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {let containerWidth = proposal.width ?? .infinity let sizes = subviews.map { $0.sizeThatFits(.unspecified) } return layout(sizes: sizes, spacing: spacing, containerWidth: containerWidth).size } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { } }
When placing the subviews, we can use the offsets returned from the layout function to place each subview. We make sure to place it within bounds by adding the minX
and minY
values, respectively.
struct FlowLayout: Layout { var spacing: CGFloat = 8 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let sizes = subviews.map { $0.sizeThatFits(.unspecified) } let offsets = layout(sizes: sizes, spacing: spacing, containerWidth: bounds.width).offsets for (offset, subview) in zip(offsets, subviews) { subview.place(at: .init(x: offset.x + bounds.minX, y: offset.y + bounds.minY), proposal: .unspecified) } }}
There are a number of ways in which we can improve the layout above. For example, we could have separate values for horizontal and vertical spacing. We could control the vertical alignment within each line, as well as the horizontal alignment of multiple lines.
We recorded a number of Swift Talk episodes about flow layouts, episode 308 and episode 350 are the most relevant.