xyk blog

最近は iOS 開発の記事が多めです。

UICollectionViewCell の横幅を計算する

検証環境:
Xcode 12.4
Swift 5.3.2

UICollectionView のセルの横幅をいい感じに調整する方法について。

前提条件として、セルの並び方向はデフォルトのflowLayout.scrollDirection = .vertical、セルのサイズは正方形ですべてのセルが同じサイズであること。

セルの横幅に固定値を指定した場合、セル間の間隔はシステム側で自動に調整してくれるが、セル間の間隔が広くなってしまうと見た目がよくない。
また端末によって画面サイズが違うので1行に配置されるセル数や間隔も変わってくる。

固定値でなく例えば1行にセルが3列入るように横幅を計算する方法もあるが、その場合 iPhone では問題ないが iPad では1行3列だとセルがでかすぎで、せっかくの大画面が生かされない。

実現したいことは大体のセルの最小横幅を指定すると、セル間の隙間は広げず、セル幅を広げて画面に詰まった状態でセルが並ぶように自動調整してくれる機能。

以下サンプルコード。
Constantsのパラメータで調整できる。
StoryBoard も使っているがここでは省略。

import UIKit

class ViewController: UIViewController {
    
    var items: [String] = []
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        for i in 0..<20 {
            items.append("\(i)")
        }
        
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.alwaysBounceVertical = true

        let flowLayout = collectionView.collectionViewLayout as! UICollectionViewFlowLayout
        flowLayout.estimatedItemSize = .zero
    }
    
    @IBOutlet weak var collectionView: UICollectionView!
    
    private struct Constants {
        static let minimumCellWidth: CGFloat = 100 // セルの最小横幅
        static let interItemSpacing: CGFloat = 5 // セル間のマージン
        static let leftRightInset: CGFloat = 5 // 左右の各マージン
        static let lineSpacing: CGFloat = 5 // 行間のマージン
    }
    
    var cachedCellSize: CGSize?
    
    private func calculateCellWidth() -> CGFloat {
        // 左右のInsetを除いた横幅
        let widthWithoutMargin = collectionView.frame.width - (Constants.leftRightInset * 2)
        // 1行のセル数
        let numPerRow = (widthWithoutMargin / Constants.minimumCellWidth).rounded(.down)
        // セルに割り当てる余り幅の算出
        var calculator: ((CGFloat) -> CGFloat)!
        calculator = { (num: CGFloat) -> CGFloat in
            let remainingWidth = widthWithoutMargin - (Constants.minimumCellWidth * num) - (Constants.interItemSpacing * (num - 1))
            if remainingWidth < 0 {
                return calculator(num - 1)
            } else {
                return (remainingWidth / num).rounded(.down)
            }
        }
        let additionalWidth = calculator(numPerRow)
        // セル幅
        let calculatedCellWidth = Constants.minimumCellWidth + additionalWidth
        
        print("collectionViewWidth: \(collectionView.frame.width), widthWithoutMargin: \(widthWithoutMargin), numPerRow: \(numPerRow), additionalWidth: \(additionalWidth), calculatedCellWidth: \(calculatedCellWidth)")
        
        return calculatedCellWidth
    }
}

extension ViewController: UICollectionViewDataSource {
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return items.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CollectionViewCell", for: indexPath)
        cell.contentView.backgroundColor = (indexPath.item % 2 == 0) ? .systemTeal : .systemPurple
        return cell
    }
}

extension ViewController: UICollectionViewDelegate {
    
}

extension ViewController: UICollectionViewDelegateFlowLayout {
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        // 今回はすべてのセルが同じサイズという仕様なので初回のみ計算し、それ以降はキャッシュしたサイズを返す
        if let cachedCellSize = cachedCellSize {
            return cachedCellSize
        } else {
            let width = calculateCellWidth()
            cachedCellSize = CGSize(width: width, height: width)
            return cachedCellSize!
        }
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return .init(top: 0, left: Constants.leftRightInset, bottom: 0, right: Constants.leftRightInset)
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return Constants.lineSpacing
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return Constants.interItemSpacing
    }
}

class CollectionViewCell: UICollectionViewCell {
}

例えばセルの最小横幅を100ptを指定した場合の各端末の表示

iPod touch 7th generation(4インチ)

f:id:xyk:20210215142610p:plain

collectionViewWidth: 320.0, additionalWidth: 0.0, calculatedCellWidth: 100.0

iPhone SE 2nd gereration(4.7インチ)

f:id:xyk:20210215143710p:plain

collectionViewWidth: 375.0, additionalWidth: 18.0, calculatedCellWidth: 118.0

iPhone 12 Pro Max(6.7インチ)

f:id:xyk:20210215143146p:plain

collectionViewWidth: 428.0, additionalWidth: 0.0, calculatedCellWidth: 100.0

iPad Pro 4th gereration (12.9インチ)

f:id:xyk:20210215144111p:plain

collectionViewWidth: 1024.0, additionalWidth: 8.0, calculatedCellWidth: 108.0