iosuicollectionviewuicollectionviewlayoutuicollectionviewflowlayoutuicollectionviewcompositionallayout

UICollectionView complex grid again


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.


Solution

  • 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 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
        }
        
    }