SwiftUI: HStack but with wrapping

Creating a shopping app or an Instagram alternative? Looking for a stylish way to display Tags, Categories, Selections, Items lists or Keywords? You’re in the right place! In this article, I’ll present you a solution for implementing a beautiful, easy-to-use solution without relying on third-party dependencies or complex SwiftUI hacks. That’s right — no extra dependencies, just clean, straightforward SwiftUI.

Liudas Baronas
8 min readSep 3, 2024

Our goal

Our goal is to to create a fully fluid, dynamic, flexible, and reusable component that seamlessly integrates any SwiftUI view and can be integrated into any SwiftUI view as well. In other words, a layout solution that adapts to varying content sizes without disrupting the overall layout.

Our WrappingCollectionView solves a simple problem — SwiftUI not providing a straightforward ability to incorporate flexible collection views based on dynamic sizes of child views. Using a traditional grid or list view may not provide the desired flexibility, as these items can get truncated or require excessive scrolling.

WrappingCollectionView would be designed to ensure that all items are visible by automatically wrapping items to new lines when necessary, ensuring that no matter how many items are displayed or how wide they are, the layout remains clean, organized, and visually appealing.

As an example, user-chosen Lithuanian cities listed as interactive buttons

There will be zero worrying about layout constraints, extensive customizations. Zero approximations and estimations whatsoever.

Our end result

We will strictly work on wrappable collection view and no other parts will be discussed.

In the following GIF, you can find the view behaviour on adding items:

Solution

Defining a struct

We begin by defining the WrappingCollectionView as a struct that conforms to the View protocol. This struct is generic over two types: Data, which represents the collection of items we want to display, and Content, which is the view that will render each item.

public struct WrappingCollectionView<Data: RandomAccessCollection, Content: View>: View where Data.Element: Identifiable {
private let data: Data
private let spacing: CGFloat
private let singleItemHeight: CGFloat
private let content: (Data.Element) -> Content

@State private var totalHeight: CGFloat = .zero
}

Here, our data must conform to RandomAccessCollection, and its elements must be Identifiable.

We also declare several properties:

  • data — a collection of items to display
  • spacing — defines the space between items both horizontally and vertically
  • singleItemHeight — specifies the height for each item
  • content — a closure that generates a view for each item

We also include a state variable, totalHeight, to store the total height of the wrapping view. This ensures that the view maintains a consistent height when integrated with other views, such as in a VStack.

Without this, the height could become unreliable, leading to layout inconsistencies.

Initializer

Next, we define an initializer to set up these properties:

public init(
data: Data,
spacing: CGFloat,
singleItemHeight: CGFloat,
@ViewBuilder content: @escaping (Data.Element) -> Content
) {
self.data = data
self.spacing = spacing
self.singleItemHeight = singleItemHeight
self.content = content
}

This initializer is quite straightforward, taking in the collection of identifiable data, a spacing value, the height of a single item, and the content closure that defines how each item should be rendered.

Body

The main body of the view is where the magic happens. We use a ZStack with the top-leading alignment to hold the content towards the top-leading corner.

Inside the ZStack, a GeometryReader captures the available width, which is crucial for calculating the wrapping behavior:

public var body: some View {
ZStack(alignment: .topLeading) {
GeometryReader { geometry in
generateContent(in: geometry)
.background(GeometryReader { geo in
Color.clear.preference(key: HeightPreferenceKey.self, value: geo.size.height)
})
}
}
.onPreferenceChange(HeightPreferenceKey.self) { height in
totalHeight = height
}
.frame(height: totalHeight)
}

The GeometryReader gives us the size of the available space, which we use to determine when items should wrap to the next line.

The content is generated by calling the generateContent(in:) method, which we’ll dive into shortly. Its background with GeometryReader helps us capture the height of the content, which is passed up to the parent view using a preference key. This height is then used to adjust the overall height of the WrappingCollectionView.

Calculations

The core of the wrapping behavior is implemented in the generateContent(in:) method where it calculates the position of each item based on its width and the available space.

private func generateContent(in geometry: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero

return ZStack(alignment: .topLeading) {
ForEach(data) { item in
content(item)
.alignmentGuide(
.leading,
computeValue: { dimension in
if abs(width - dimension.width) > geometry.size.width {
width = 0
height -= dimension.height + spacing
}

let result = width
if item.id == data.last?.id {
width = 0
} else {
width -= dimension.width + spacing
}
return result
}
)
.alignmentGuide(
.top,
computeValue: { _ in
let result = height
if item.id == data.last?.id {
height = 0
}
return result
}
)
}
}
}

This method starts by initializing width and height to zero.

These variables track the current position in the view where the next item will be placed. As items are added, width increases to move the next item to the right. If adding the item would exceed the available width, width is reset to zero, and height is incremented to move the item to the next line.

The key to placing the items correctly lies in the custom alignment guides.

The alignmentGuide for .leading edge controls the horizontal position of each item. It calculates where the item should be placed, resetting the width to zero and increasing the height whenever the item exceeds the available width.

The alignmentGuide for .top edge manages the vertical position, ensuring that items are placed on the correct row.

Total height

Finally, to make this wrapping layout fully dynamic, we capture the total height using a custom aforementioned HeightPreferenceKey, allowing the WrappingCollectionView to adjust its height based on its content:

struct HeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0

static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}

This preference key aggregates the height values, ensuring that the view resizes correctly based on the content it contains.

Usage

After analysing different parts of the solution, we can move on to the implementation of this custom view which is capable of handling items with dynamic measurements.

private var locationsView: some View {
WrappingCollectionView(
data: viewModel.locations,
spacing: 10, // Spacing between items both horizontally and vertically
singleItemHeight: 40 // Height for each item
) { location in
ZStack {
Color.gray.opacity(0.2)
.cornerRadius(12)

Button(action: {
viewModel.removeLocation(location)
}) {
HStack(spacing: 8) {
Text(location.location.name)
.font(.system(size: 16, weight: .regular))
.foregroundColor(.black)

Image(systemName: "minus.circle.fill")
.foregroundColor(.red)
.font(.system(size: 16, weight: .bold))
}
}
.padding(.horizontal, 12)
.frame(height: 40) // Match the height of the item
}
.fixedSize(horizontal: true, vertical: true) // This is necessary for each item to be as tight as possible
}
}

The WrappingCollectionView automatically handles the arrangement of items, ensuring they wrap to the next line when they extend beyond the available horizontal space. You just need to feed it the data, configuration and cell view.

The data for this view is drawn from the view model, with the set custom spacing between the items. This spacing applies both horizontally and vertically, ensuring a consistent gap between each item. Additionally, the singleItemHeight is defined too, giving each item a uniform height, which contributes to a cohesive appearance.

⚠️ An important detail in this setup is the use of .fixedSize(horizontal: true, vertical: true) on each item. This modifier ensures that each item takes up only as much space as it needs, without expanding unnecessarily. This is essential for maintaining a compact and visually tidy layout, where the items fit snugly without leaving excess space.

By matching the height of each item to the defined singleItemHeight, we ensure consistency across all items, making the layout more structured and aligned.

Everything else defined in content closure (remember let content: (Data.Element) -> Content ?) will be displayed as an individual cell in our WrappingCollectionView.

By managing the spacing, item height, and using fixedSize, we create a view that’s both organized and adaptable. This ensures the view integrates smoothly with other UI components, maintaining consistency and functionality across different screen sizes without disrupting the overall layout.

Animation

To make items adding and removal fluid and animated, the data needs to be updated with animation by using SwiftUI’s native .withAnimation function in SwiftUI.

When you wrap state changes inside .withAnimation, SwiftUI automatically animates the resulting changes in the user interface, making transitions more visually appealing and less abrupt.

I recommend going with .easeInOut animation as it uses a specific timing curve which provides a smooth acceleration and deceleration at the start and end of the animation.

Here I attach an example of how items removal would look like with the animation applied:

Full code

For your convenience, WrappingCollectionView in a single code snippet:

import SwiftUI

public struct WrappingCollectionView<Data: RandomAccessCollection, Content: View>: View where Data.Element: Identifiable {
private let data: Data
private let spacing: CGFloat
private let singleItemHeight: CGFloat
private let content: (Data.Element) -> Content
@State private var totalHeight: CGFloat = .zero

public init(
data: Data,
spacing: CGFloat = 8,
singleItemHeight: CGFloat,
@ViewBuilder content: @escaping (Data.Element) -> Content
) {
self.data = data
self.spacing = spacing
self.singleItemHeight = singleItemHeight
self.content = content
}

public var body: some View {
ZStack(alignment: .topLeading) {
GeometryReader { geometry in
generateContent(in: geometry)
.background(GeometryReader { geo in
Color.clear.preference(key: HeightPreferenceKey.self, value: geo.size.height)
})
}
}
.onPreferenceChange(HeightPreferenceKey.self) { height in
totalHeight = height
}
.frame(height: totalHeight)
}

private func generateContent(in geometry: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero

return ZStack(alignment: .topLeading) {
ForEach(data) { item in
content(item)
.alignmentGuide(
.leading,
computeValue: { dimension in
if abs(width - dimension.width) > geometry.size.width {
width = 0
height -= dimension.height + spacing
}

let result = width
if item.id == data.last?.id {
width = 0
} else {
width -= dimension.width + spacing
}
return result
}
)
.alignmentGuide(
.top,
computeValue: { _ in
let result = height
if item.id == data.last?.id {
height = 0
}
return result
}
)
}
}
}
}

struct HeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0

static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}

Thank you for reading and good luck creating great SwiftUI animations in your application! :)

More about me and my experience here.

--

--

Liudas Baronas
Liudas Baronas

No responses yet