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.
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.
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 displayspacing
— defines the space between items both horizontally and verticallysingleItemHeight
— specifies the height for each itemcontent
— 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.