SwiftUI: Custom CollectionView
What I want to achieve is super simple (at least that was what I thought before trying to make it in SwiftUI). A CollectionView with dynamic number of columns (may differ by row) depending on the item width.
That’s it! Something look like below!
This is probably the default behavior of UICollectionView and I thought all I have to do is to use LazyVGrid in SwiftUI with adaptive columns and everything should be done in 3 seconds!
NOPE!!!
LazyVGrid Pitfall — Adaptive GridItem Not Being Adaptive
Before we start making our own CollectionView, let me show you really quick what will happen if you are trying to make it using LazyVGrid in case you are running into similar problems and trying and find a way out!
Try the following snippet out!
By setting the GridItem to be .adaptive, we will not have to actually provide an array of GridItem to our columns based on the number of column we want. The number of column will be automatically calculated for us!
struct LazyVGridDemo: View {
var body: some View {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 80, maximum: 200))],
alignment: .center) {
ForEach(0..<7) {index in
Text("item \(index)")
.foregroundStyle(Color.white)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.lineLimit(1)
.background(RoundedRectangle(cornerRadius: 16))
}
}
.padding(.all, 50)
.background(Color(UIColor.lightGray))
}
}Let’s give it a run!
Everything looks great!
You might be wondering what is the pitfall then? Let’s change our GridItem minimum really quick.
columns: [GridItem(.adaptive(minimum: 10, maximum: 200))],What do you think we will get?
I don’t know if you can, but at least I cannot read what is in our Text! Our GridItem width is not even close to the maximum 200 we set! It is obviously staying at 10!
This is because adaptive means inserting as many items of the minimum size as possible at least in SwiftUI world! (I guess the conclusion here is don’t try to learn English from SwiftUI?)
By that means, all rows will still have the same number of items with the same width (the minimum we gave) which is not even close to what I want to achieve!
CollectionView From Scratch
Okay, now we know that our goal cannot be achieved by LazyVGrid, let’s make our own!
Let’s get started with a list of Strings that we want to show in our CollectionView.
let items = ["item 1", "item 2 - long long long long long long", "item 3", "item 4", "item 5", "item 6", "item 7 - medium", "item 8"]And here is the basic Approach!
- Find out the width for each item String
- Create a new
itemsArranged: [[String]]being the items above arranged into rows of columns. - ScrollView + LazyVStack + HStack to display the
itemsArrangedabove!
Let’s take a detail look at each step!
Width of a String
Let’s add the following extension to our String. This function will calculate the width of a String based on the font we specified.
import UIKit
extension String {
func width(font: UIFont) -> CGFloat {
let constraintRectangle = CGSize(width: .greatestFiniteMagnitude, height: 0.0)
let boundingBox = self.boundingRect(with: constraintRectangle, options: .usesLineFragmentOrigin, attributes: [.font: font], context: nil)
return ceil(boundingBox.width)
}
}Yes, I am sorry, we have to import UIKit here. boundingRect and font attribute need it! I tried to specify it using SwiftUI font, and it crashes…
Get Itsuki’s stories in your inbox
Join Medium for free to get updates from this writer.
You might be wondering why the we set the height of our constraintRectangle to be 0.0? This value does not have any actual effect! Both for strings with \n or not! You can try to play around with it to check it out!
Create a itemsArranged
Now that we have our extension for calculating String width, let’s write out our algorithm for creating itemsArranged.
In addition to the item width, there are also couple parameters that will affect how our items fit into one row.
- Obviously, the row width (or the
containerWidthof our CollectionView) horizontalSpacing: This is the spacing of the HStack for each rowhorizontalPadding: the horizontal padding for the Text
func arrangeItems(_ items: [String], containerWidth: CGFloat, font: UIFont, horizontalSpacing: CGFloat, horizontalPadding: CGFloat) -> [[String]] {
var arrangeItems: [[String]] = []
var currentRowWidth: CGFloat = 0
for i in 0..<items.count {
let item = items[i]
let itemWidth = item.width(font: font) + horizontalPadding * 2
// first item
if i == 0 {
arrangeItems.append([item])
currentRowWidth = itemWidth
continue
}
if currentRowWidth + horizontalSpacing + itemWidth > containerWidth {
// start new row
arrangeItems.append([item])
currentRowWidth = itemWidth
} else {
// add to current row
arrangeItems[arrangeItems.count - 1].append(item)
currentRowWidth = currentRowWidth + horizontalSpacing + itemWidth
}
}
return arrangeItems
}Display itemsArranged
We will be displaying using ScrollView + LazyVStack + HStack. Also, in order for us to have a dynamic containerWidth(ie: based on the context the CollectionView will be used in), instead of passing it in as a parameter or hard coding it, we will be reading from GeometryReader.
GeometryReader { geometry in
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(0..<itemsArranged.count, id: \.self) { row in
let itemsInRow = itemsArranged[row]
HStack(spacing: horizontalSpacing) {
ForEach(0..<itemsInRow.count, id: \.self) { column in
Text(itemsInRow[column])
.lineLimit(1)
.font(Font(font))
.padding(.vertical, 8)
.padding(.horizontal, horizontalPadding)
.foregroundStyle(Color.white)
.background(RoundedRectangle(cornerRadius: 16))
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
itemsArranged = arrangeItems(items, containerWidth: geometry.size.width, font: font, horizontalSpacing: horizontalSpacing, horizontalPadding: horizontalPadding)
}
}
}
.padding(.all, 20)
.background(Color(UIColor.lightGray))
.padding(.all, 20)If you want to keep your scroll position, you can also add the .scrollPosition(id: <#T##Binding<(Hashable)?>#>) modifier to your scrollView but I will leave that out for now!
Final CollectionView
Here is the entire code for the CollectionView for your reference!
import SwiftUI
struct CollectionView: View {
let items: [String]
@State private var itemsArranged: [[String]] = []
var horizontalSpacing: CGFloat = 10
var horizontalPadding: CGFloat = 16
var font: UIFont = UIFont.systemFont(ofSize: 18)
var body: some View {
GeometryReader { geometry in
ScrollView {
LazyVStack(alignment: .leading) {
ForEach(0..<itemsArranged.count, id: \.self) { row in
let itemsInRow = itemsArranged[row]
HStack(spacing: horizontalSpacing) {
ForEach(0..<itemsInRow.count, id: \.self) { column in
Text(itemsInRow[column])
.lineLimit(1)
.font(Font(font))
.padding(.vertical, 8)
.padding(.horizontal, horizontalPadding)
.foregroundStyle(Color.white)
.background(RoundedRectangle(cornerRadius: 16))
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear {
itemsArranged = arrangeItems(items, containerWidth: geometry.size.width, font: font, horizontalSpacing: horizontalSpacing, horizontalPadding: horizontalPadding)
}
}
}
}
func arrangeItems(_ items: [String], containerWidth: CGFloat, font: UIFont, horizontalSpacing: CGFloat, horizontalPadding: CGFloat) -> [[String]] {
var arrangeItems: [[String]] = []
var currentRowWidth: CGFloat = 0
for i in 0..<items.count {
let item = items[i]
let itemWidth = item.width(font: font) + horizontalPadding * 2
print(itemWidth, " ", currentRowWidth)
// first item
if i == 0 {
arrangeItems.append([item])
currentRowWidth = itemWidth
continue
}
if currentRowWidth + horizontalSpacing + itemWidth > containerWidth {
// start new row
arrangeItems.append([item])
currentRowWidth = itemWidth
} else {
// add to current row
arrangeItems[arrangeItems.count - 1].append(item)
currentRowWidth = currentRowWidth + horizontalSpacing + itemWidth
}
}
return arrangeItems
}
}And to use it
#Preview {
let items: [String] = ["item 1", "item 2 - long long long long long long", "item 3", "item 4", "item 5", "item 6", "item 7 - medium", "item 8"]
return CollectionView(items: items)
.padding(.all, 20)
.background(Color(UIColor.lightGray))
.padding(.all, 20)
}And here we go! Exactly what I have showed you in the beginning!
That’s all I have for this article!
Thank you for reading!
Happing Collecting! (I will fill mine with Pikachu!)
Stackademic 🎓
Thank you for reading until the end. Before you go:
- Please consider clapping and following the writer! 👏
- Follow us X | LinkedIn | YouTube | Discord
- Visit our other platforms: In Plain English | CoFeed | Venture | Cubed
- More content at Stackademic.com









