1

I can't manage to get this type of layout:

enter image description here

I can only achieve this when I set size of cells in 'sizeForItemAt' method:

enter image description here

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.

Progman
  • 16,827
  • 6
  • 33
  • 48
Maxim
  • 33
  • 6
  • Couple questions... Do you **require** it to be a ***collection view***? You mention *"flexibility needed for the device rotation "* ... does that mean you want smaller "cells" with more "mini cells", like this: https://i.stack.imgur.com/HfYEr.png – DonMag Mar 26 '23 at 21:15
  • It's not required to be a collection view, but I think it can't be something else, because it contains rows and columns, but I may be wrong. Your screenshot looks ok if we recon that it's one of the sections. upd. By flexibility, I meant a more or less fixed size of the cells with a change in their places when the device is rotated. – Maxim Mar 28 '23 at 11:08
  • have you solved this? Or are you still looking for an answer? – DonMag Mar 31 '23 at 13:10
  • I put off it for a while, but I'll return to it near future. I suppose the way is to calculate the whole layout of a section – Maxim Mar 31 '23 at 17:23

1 Answers1

1

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:

enter image description here

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:

enter image description here

It's not quite that simple though, because when we rotate the phone, we don't want this:

enter image description here

and if we're on an iPad, we definitely don't want this:

enter image description here

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 the collectionView width is less than 450, use this default layout
  • else
  • let's use a specific size for the Primary item, and "fill in" the remaining space

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):

enter image description here

rotating the phone gives us this:

enter image description here

and the iPad looks like this:

enter image description here

We may still want some conditional calculations... that same code on an iPhone SE looks like this:

enter image description here

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
    }
    
}
DonMag
  • 69,424
  • 5
  • 50
  • 86
  • Wow! Looks impressive! Thanks a lot! I will try soon :) – Maxim Apr 01 '23 at 12:33
  • It took me almost a month to come back to this question and... this is really great explanation, incredibly comprehensive, and it works. Thank you so much for this great work! :) – Maxim Apr 26 '23 at 13:57