I can't manage to get this type of layout:
I can only achieve this when I set size of cells in 'sizeForItemAt' method:
I tried solutions from Apple like UICollectionViewCompositionalLayout and subclassing of UICollectionViewLayout. But the first one don't give the flexibility needed for the device rotation because you have to set exact count of subitems in group. Another issue with UICollectionViewCompositionalLayout is scroll time calculations - it doesn't give the full layout after the screen is displayed. Subclassing of UICollectionViewLayout (https://developer.apple.com/documentation/uikit/views_and_controls/collection_views/layouts/customizing_collection_view_layouts) has terrible performance.
But even with all the shortcomings of the above approaches, I did not get exactly the layout that I need. I can imagine that we can use an additional type of cell that contains a grid of four cells, but it's also not flexible.
I will appreciate any help.
This layout can be done with a custom UICollectionViewLayout
and is probably much more straight-forward than it might seem.
First, think about the layout as a grid for each section... 4-columns x n rows:
Because we're using squares, the first item will take up 2-columns and 2-rows.
To avoid width/height confusion and replication, we'll call the 2x2 item the "Primary" item, and the 1x1 items "Secondary" items.
So, when we calculate the layout rectangles, we can say:
numCols = 4
secondarySize = collectionView.width / numCols
y = 0
row = 0
col = 0
for i in 0..<numItems {
if i == 0 {
itemRect = .init(x: 0.0, y: y, width: secondarySize * 2.0, height: secondarySize * 2.0)
// skip a column
col = 2
} else {
// if we're at the last column
if col == numCols {
// increment the row
row += 1
// if we're on row 1, next column is 2
// else it's 0
col = row < 2 ? 2 : 0
}
itemRect = .init(x: col * secondarySize, y: y + row * secondarySize, width: secondarySize, height: secondarySize)
// increment the column
col += 1
}
}
That works fine, giving us this on an iPhone 14 Pro Max:
It's not quite that simple though, because when we rotate the phone, we don't want this:
and if we're on an iPad, we definitely don't want this:
So, we need to decide how wide we can go for that layout.
Current phones range from 275 to 430 points wide (in Portrait orientation), so we might say:
If we decide we want the Primary item to be 200x200, that changes the initial part of our layout code to:
primaryItemSize = 200.0
if contentWidth < 450.0 {
secondarySize = contentWidth / 4.0
numCols = 4
} else {
secondarySize = primaryItemSize / 2.0
numCols = Int(contentWidth / secondarySize)
}
Now if our layout looks like this (again, iPhone 14 Pro Max):
rotating the phone gives us this:
and the iPad looks like this:
We may still want some conditional calculations... that same code on an iPhone SE looks like this:
So, a Primary size of 200x200 might be too big for that device.
Additionally, as you can see, setting an explicit Primary item size won't fill the width exactly. An iPhone SE in Landscape orientation has a view width of 667. If the secondary size (the column width) is 100, 6 columns gets us 600-points, leaving 667-points of empty space on the end.
If that's acceptable, great, less work :) Otherwise, we can do a "best fit" calculation which would either "grow" the size a bit to fill it out, or "shrink" the size a bit and expand to 7 columns.
And... if you want section spacing and/or headers, that would need to be factored in as well.
Here, though, is some sample code to get to this point:
class SampleViewController: UIViewController {
var collectionView: UICollectionView!
var myData: [[UIImage]] = []
// a view with a "spinner" to show that we are
// generating the images to use as the data
// (if the data needs to be created in this controller)
lazy var spinnerView: UIView = {
let v = UIView()
let label = UILabel()
label.text = "Generating Images Data..."
let spinner = UIActivityIndicatorView(style: .large)
spinner.startAnimating()
[label, spinner].forEach { sv in
sv.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(sv)
}
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: v.topAnchor, constant: 20.0),
label.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 20.0),
label.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -20.0),
spinner.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 20.0),
spinner.centerXAnchor.constraint(equalTo: v.centerXAnchor),
spinner.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -20.0),
])
v.layer.cornerRadius = 8
v.layer.borderWidth = 1
v.layer.borderColor = UIColor.black.cgColor
v.backgroundColor = .white
return v
}()
// for development purposes
var showCellFrame: Bool = false
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
let gl = SampleGridLayout()
gl.primaryItemSize = 200.0
collectionView = UICollectionView(frame: .zero, collectionViewLayout: gl)
// the imageView in our SimpleImageCell is inset by 4-points, which results in
// 8-points between adjacent cells
// so, if we inset the content 4-points on each side, it will look "balanced"
// with a total of 8-points on each side
collectionView.contentInset = .init(top: 0.0, left: 4.0, bottom: 0.0, right: 4.0)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: g.topAnchor, constant: 0.0),
collectionView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
collectionView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
collectionView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
])
collectionView.register(SimpleImageCell.self, forCellWithReuseIdentifier: SimpleImageCell.identifier)
collectionView.dataSource = self
collectionView.delegate = self
// for use during development
let dt = UITapGestureRecognizer(target: self, action: #selector(toggleFraming(_:)))
dt.numberOfTapsRequired = 2
view.addGestureRecognizer(dt)
if myData.isEmpty {
spinnerView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(spinnerView)
NSLayoutConstraint.activate([
spinnerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 40.0),
spinnerView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
])
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// data may already be created by a data manager class
// so only create images if needed
if myData.isEmpty {
DispatchQueue.global(qos: .userInitiated).async {
let sectionCounts: [Int] = [
8, 2, 3, 4, 5, 10, 13, 16, 24
]
self.myData = SampleData().generateData(sectionCounts)
DispatchQueue.main.async {
self.spinnerView.removeFromSuperview()
self.collectionView.reloadData()
}
}
}
}
// for use during development
@objc func toggleFraming(_ sender: Any?) {
self.showCellFrame.toggle()
self.collectionView.reloadData()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(
alongsideTransition: { [unowned self] _ in
self.collectionView.collectionViewLayout.invalidateLayout()
self.collectionView.reloadData()
},
completion: { [unowned self] _ in
// if we want to do something after the size transition
}
)
}
}
// "standard" collection view DataSource funcs
extension SampleViewController: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> Int {
return myData.count
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return myData[section].count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let c = collectionView.dequeueReusableCell(withReuseIdentifier: SimpleImageCell.identifier, for: indexPath) as! SimpleImageCell
c.theImageView.image = myData[indexPath.section][indexPath.item]
// any other cell data configuration
// this is here only during development
c.showCellFrame = self.showCellFrame
return c
}
}
// "standard" collection view Delegate funcs
extension SampleViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
print("Selected item at:", indexPath)
}
}
// MARK: image data generation
class SampleData: NSObject {
func generateData(_ sectionCounts: [Int]) -> [[UIImage]] {
// let's generate some sample data...
// we'll create numbered 200x200 UIImages,
// cycling through some background colors
// to make it easy to see the sections
let sectionColors: [UIColor] = [
.systemRed, .systemGreen, .systemBlue,
.cyan, .green, .yellow,
]
var returnArray: [[UIImage]] = []
for i in 0..<sectionCounts.count {
var sectionImages: [UIImage] = []
let c = sectionColors[i % sectionColors.count]
for n in 0..<sectionCounts[i] {
if let img = createLabel(text: "\(n)", bkgColor: c) {
sectionImages.append(img)
}
}
returnArray.append(sectionImages)
}
return returnArray
}
func createLabel(text: String, bkgColor: UIColor) -> UIImage? {
let label = CATextLayer()
let uiFont = UIFont.boldSystemFont(ofSize: 140)
label.font = CGFont(uiFont.fontName as CFString)
label.fontSize = 140
label.alignmentMode = .center
label.foregroundColor = UIColor.white.cgColor
label.string = text
label.shadowColor = UIColor.black.cgColor
label.shadowOffset = .init(width: 0.0, height: 3.0)
label.shadowRadius = 6
label.shadowOpacity = 0.9
let sz = label.preferredFrameSize()
label.frame = .init(x: 0.0, y: 0.0, width: 200.0, height: sz.height)
let r: CGRect = .init(x: 0.0, y: 0.0, width: 200.0, height: 200.0)
let renderer = UIGraphicsImageRenderer(size: r.size)
return renderer.image { context in
bkgColor.setFill()
context.fill(r)
context.cgContext.translateBy(x: 0.0, y: (200.0 - sz.height) / 2.0)
label.render(in: context.cgContext)
}
}
}
// basic collection view cell with a
// rounded-corners image view, 4-points "padding" on all sides
class SimpleImageCell: UICollectionViewCell {
static let identifier: String = "simpleImageCell"
let theImageView: UIImageView = {
let v = UIImageView()
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {
contentView.addSubview(theImageView)
let g = contentView
NSLayoutConstraint.activate([
theImageView.topAnchor.constraint(equalTo: g.topAnchor, constant: 4.0),
theImageView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 4.0),
theImageView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -4.0),
theImageView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -4.0),
])
theImageView.layer.cornerRadius = 12
theImageView.clipsToBounds = true
}
override var isSelected: Bool {
didSet {
theImageView.layer.borderWidth = isSelected ? 2.0 : 0.0
}
}
// for development, so we can see the framing
var showCellFrame: Bool = false {
didSet {
//contentView.backgroundColor = showCellFrame ? .systemYellow : .clear
contentView.layer.borderColor = showCellFrame ? UIColor.blue.cgColor : UIColor.clear.cgColor
contentView.layer.borderWidth = showCellFrame ? 1 : 0
}
}
}
class SampleGridLayout: UICollectionViewLayout {
public var primaryItemSize: CGFloat = 200.0
private var itemCache: [UICollectionViewLayoutAttributes] = []
private var nextY: CGFloat = 0.0
private var contentHeight: CGFloat = 0
private var contentWidth: CGFloat {
guard let collectionView = collectionView else {
return 0
}
let insets = collectionView.contentInset
return collectionView.bounds.width - (insets.left + insets.right)
}
override var collectionViewContentSize: CGSize {
return CGSize(width: contentWidth, height: contentHeight)
}
override func prepare() {
guard let collectionView = collectionView else { return }
var numCols: Int = 0
var secondarySize: CGFloat = 0
if contentWidth < 450.0 {
secondarySize = contentWidth / 4.0
numCols = 4
} else {
secondarySize = primaryItemSize / 2.0
numCols = Int(contentWidth / secondarySize)
}
var primaryFrame: CGRect = .zero
var secondaryFrame: CGRect = .zero
itemCache = []
nextY = 0.0
for section in 0..<collectionView.numberOfSections {
let y: CGFloat = nextY
var curCol: Int = 0
var curRow: Int = 0
for item in 0..<collectionView.numberOfItems(inSection: section) {
let indexPath = IndexPath(item: item, section: section)
if item == 0 {
primaryFrame = .init(x: 0.0, y: y, width: secondarySize * 2.0, height: secondarySize * 2.0)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = primaryFrame
itemCache.append(attributes)
// item 0 takes up 2 columns
curCol = 2
} else {
// if we're at the last column
if curCol == numCols {
// increment the row
curRow += 1
// if we're on row 1, next column is 2
// else it's 0
curCol = curRow < 2 ? 2 : 0
}
secondaryFrame = .init(x: CGFloat(curCol) * secondarySize, y: y + CGFloat(curRow) * secondarySize, width: secondarySize, height: secondarySize)
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = secondaryFrame
itemCache.append(attributes)
// increment the column
curCol += 1
}
}
nextY = max(primaryFrame.maxY, secondaryFrame.maxY)
}
contentHeight = nextY
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
super.layoutAttributesForElements(in: rect)
var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = []
for attributes in itemCache {
if attributes.frame.intersects(rect) {
visibleLayoutAttributes.append(attributes)
}
}
return visibleLayoutAttributes
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
super.layoutAttributesForItem(at: indexPath)
return itemCache.count > indexPath.row ? itemCache[indexPath.row] : nil
}
}