Search code examples
swiftuicollectionviewuicollectionviewcelluicollectionviewlayoutuicollectionviewflowlayout

How to make square cells with collection view layout in swift


I'm programming a game with a collection view with 121 buttons (11X11), how can I fix my Collection View cells to be a square? I want to increase or decrease the number of cells so the layout has to be dynamic

This is the code:

import UIKit

class GameViewController: UIViewController, UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

    let reuseIdentifier="cell"
    @IBOutlet var collectionView: UICollectionView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.delegate = self
        collectionView.dataSource = self
        collectionView.collectionViewLayout = generateLayout()
    }
    
    @objc
    func animate(for sender:UIButton){
        UIView.animate(withDuration: 0.5, delay: 0, animations: {
            let rotate=CGAffineTransform(rotationAngle: .pi/2)
            let scale=CGAffineTransform(scaleX: 0.5, y: 0.5)
            sender.transform=rotate.concatenating(scale)
        },completion: {_ in
            UIView.animate(withDuration: 0.5, animations: {
                sender.transform=CGAffineTransform.identity
            })
        })
    }
    
    func generateLayout()->UICollectionViewCompositionalLayout{
        let padding:CGFloat=2
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1),
            heightDimension: .fractionalHeight(1)
        )
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        item.contentInsets=NSDirectionalEdgeInsets(
            top: 0, leading: padding, bottom: 0, trailing: padding
        )
        
        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1),
            heightDimension: .absolute(40)
        )
        
        let group = NSCollectionLayoutGroup.horizontal(
            layoutSize: groupSize,
            subitem: item,
            count: 11
        )
        group.interItemSpacing = .fixed(padding)
        group.contentInsets=NSDirectionalEdgeInsets(top: 0, leading: padding, bottom: 0, trailing: padding)
        
        let section = NSCollectionLayoutSection(group: group)
        section.interGroupSpacing=padding
        section.contentInsets=NSDirectionalEdgeInsets(
            top: padding,
            leading: 0,
            bottom: padding,
            trailing: 0
        )
        
        let layout = UICollectionViewCompositionalLayout(section: section)
        
        return layout
    }
    
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        // #warning Incomplete implementation, return the number of sections
        return 11
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        // #warning Incomplete implementation, return the number of items
        return 11
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath) as! ButtonCollectionViewCell
        cell.layoutGridCells(at:indexPath)
        cell.delegate=self
    
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

        let size = collectionView.bounds.size.height

        return CGSize(width: size, height: size)
    }

}

Solution

  • It can be cumbersome to create exact grids with a collection view.

    And, as I mentioned in my comments, if you're not utilizing the built-in advantages of a UICollectionView -- scrolling, memory management via cell reuse, etc -- a collection view may not be the ideal approach.

    Without knowing exactly what you need to do, buttons may not be the best to use either...

    Here's a quick example using buttons in stack views:

    class ButtonGridVC: UIViewController {
        
        // vertical axis stack view to hold the "row" stack views
        let outerStack: UIStackView = {
            let v = UIStackView()
            v.axis = .vertical
            v.distribution = .fillEqually
            v.translatesAutoresizingMaskIntoConstraints = false
            return v
        }()
    
        let promptLabel = UILabel()
    
        // spacing between buttons
        let gridSpacing: CGFloat = 2.0
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            // let's add a prompt label and a stepper
            //  for changing the grid size
            let stepperStack = UIStackView()
            stepperStack.spacing = 8
            stepperStack.translatesAutoresizingMaskIntoConstraints = false
            
            let stepper = UIStepper()
            stepper.minimumValue = 2
            stepper.maximumValue = 20
            stepper.addTarget(self, action: #selector(stepperChanged(_:)), for: .valueChanged)
            stepper.setContentCompressionResistancePriority(.required, for: .vertical)
            
            stepperStack.addArrangedSubview(promptLabel)
            stepperStack.addArrangedSubview(stepper)
            
            view.addSubview(stepperStack)
            view.addSubview(outerStack)
            
            let g = view.safeAreaLayoutGuide
    
            // these constraints at less-than-required priority
            //  will make teh outer stack view as large as will fit
            let cw = outerStack.widthAnchor.constraint(equalTo: g.widthAnchor)
            cw.priority = .required - 1
            let ch = outerStack.heightAnchor.constraint(equalTo: g.heightAnchor)
            ch.priority = .required - 1
    
            NSLayoutConstraint.activate([
                
                // prompt label and stepper at the top
                stepperStack.topAnchor.constraint(greaterThanOrEqualTo: g.topAnchor, constant: 8.0),
                stepperStack.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                
                // constrain outerStack
                //  square (1:1 ratio)
                outerStack.widthAnchor.constraint(equalTo: outerStack.heightAnchor),
    
                // don't make it larger than availble space
                outerStack.topAnchor.constraint(greaterThanOrEqualTo: stepperStack.bottomAnchor, constant: gridSpacing),
                outerStack.leadingAnchor.constraint(greaterThanOrEqualTo: g.leadingAnchor, constant: gridSpacing),
                outerStack.trailingAnchor.constraint(lessThanOrEqualTo: g.trailingAnchor, constant: -gridSpacing),
                outerStack.bottomAnchor.constraint(lessThanOrEqualTo: g.bottomAnchor, constant: -gridSpacing),
    
                // center horizontally and vertically
                outerStack.centerXAnchor.constraint(equalTo: g.centerXAnchor),
                outerStack.centerYAnchor.constraint(equalTo: g.centerYAnchor),
                
                // active width/height constraints created above
                cw, ch,
                
            ])
    
            // spacing between buttons
            outerStack.spacing = gridSpacing
            
            // we'll start with an 11x11 grid
            stepper.value = 11
            makeGrid(11)
        }
        
        @objc func stepperChanged(_ stpr: UIStepper) {
            // stepper changed, so generate new grid
            makeGrid(Int(stpr.value))
        }
        
        func makeGrid(_ n: Int) {
            // grid must be between 2x2 and 20x20
            guard n < 21, n > 1 else {
                print("Invalid grid size: \(n)")
                return
            }
            
            // clear the existing buttons
            outerStack.arrangedSubviews.forEach {
                $0.removeFromSuperview()
            }
            
            // update the prompt label
            promptLabel.text = "Grid Size: \(n)"
            
            // for this example, we'll use a font size of 8 for a 20x20 grid
            //  adjusting it 1-pt larger for each smaller grid size
            let font: UIFont = .systemFont(ofSize: CGFloat(8 + (20 - n)), weight: .light)
            
            // generate grid of buttons
            for _ in 0..<n {
                // create a horizontal "row" stack view
                let rowStack = UIStackView()
                rowStack.spacing = gridSpacing
                rowStack.distribution = .fillEqually
                // add it to the outer stack view
                outerStack.addArrangedSubview(rowStack)
                // create buttons and add them to the row stack view
                for _ in 0..<n {
                    let b = UIButton()
                    b.backgroundColor = .systemBlue
                    b.setTitleColor(.white, for: .normal)
                    b.setTitleColor(.lightGray, for: .highlighted)
                    b.setTitle("X", for: [])
                    b.titleLabel?.font = font
                    b.addTarget(self, action: #selector(gotTap(_:)), for: .touchUpInside)
                    rowStack.addArrangedSubview(b)
                }
            }
        }
        
        @objc func gotTap(_ btn: UIButton) {
            // if we want a "row, column" reference to the tapped button
            if let rowStack = btn.superview as? UIStackView {
                if let colIdx = rowStack.arrangedSubviews.firstIndex(of: btn),
                   let rowIdx = outerStack.arrangedSubviews.firstIndex(of: rowStack)
                {
                    print("Tapped on row: \(rowIdx) column: \(colIdx)")
                }
            }
            
            // animate the tapped button
            UIView.animate(withDuration: 0.5, delay: 0, animations: {
                let rotate = CGAffineTransform(rotationAngle: .pi/2)
                let scale = CGAffineTransform(scaleX: 0.5, y: 0.5)
                btn.transform = rotate.concatenating(scale)
            }, completion: {_ in
                UIView.animate(withDuration: 0.5, animations: {
                    btn.transform = CGAffineTransform.identity
                })
            })
    
        }
        
    }
    

    The output:

    enter image description here enter image description here

    enter image description here enter image description here

    Tapping on any button will animate it (using the rotation/scale code from your post), and will print the "Row" and "Column" of the tapped button in the debug console.