xyk blog

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

UICollectionView でタグクラウド風のレイアウトを実現する

検証環境:
Xcode 12.4
Swift 5.3.2

UICollectionView を使ってタグクラウド風にセルが並ぶレイアウトを実現したい。
UICollectionView のデフォルトのレイアウトである UICollectionViewFlowLayout をそのまま使うと以下のようにセル間にスペースが入ってしまう。

f:id:xyk:20210217001614p:plain

これをスペースを開けずに左寄せに配置されるカスタムレイアウトを作成する。

f:id:xyk:20210217001631p:plain

コードサンプル

StoryBoard 側で UICollectionView を配置して collectionView と flowLayout を IBOutlet で接続しておく。

import UIKit

class ViewController: UIViewController {
    
    var items: [String] = [
        "ビアガーデン",
        "うなぎ",
        "韓国料理",
        "焼肉",
        "焼き鳥",
        "ステーキ",
        "そば",
        "バイキング・ビュッフェ",
        "しゃぶしゃぶ",
        "鉄板焼き",
        "ピザ",
        "食堂・定食",
        "パン・サンドイッチ",
        "カフェ",
        "弁当",
    ]
    
    override func viewDidLoad() {
        super.viewDidLoad()   
    }
    
    @IBOutlet weak var collectionView: UICollectionView! {
        didSet {
            collectionView.dataSource = self
            collectionView.delegate = self
            collectionView.alwaysBounceVertical = true
            collectionView.allowsMultipleSelection = false
        }
    }
    
    @IBOutlet weak var flowLayout: UICollectionViewFlowLayout! {
        didSet {
            flowLayout.minimumLineSpacing = 8
            flowLayout.minimumInteritemSpacing = 8
            flowLayout.estimatedItemSize = UICollectionViewFlowLayout.automaticSize
            flowLayout.scrollDirection = .vertical
            flowLayout.sectionInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
        }
    }
}

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) as! CollectionViewCell
        cell.textLabel.text = items[indexPath.item]
        return cell
    }
}

extension ViewController: UICollectionViewDelegate {
    
}

extension ViewController: UICollectionViewDelegateFlowLayout {
    
}

class CollectionViewCell: UICollectionViewCell {
    
    override func awakeFromNib() {
        super.awakeFromNib()
        
        textLabel.textColor = UIColor.systemBlue
        textLabel.font = .systemFont(ofSize: 14)
        
        layer.backgroundColor = UIColor.white.cgColor
        layer.borderWidth = 2
        layer.borderColor = UIColor.systemBlue.cgColor
        layer.cornerRadius = 16
        layer.masksToBounds = true
        
        // iOS12のみ以下制約をつけないとAutoLayoutが効かない
        contentView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            contentView.leftAnchor.constraint(equalTo: leftAnchor),
            contentView.rightAnchor.constraint(equalTo: rightAnchor),
            contentView.topAnchor.constraint(equalTo: topAnchor),
            contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
        ])
    }
    
    // セル選択で色を反転させる
    override var isSelected: Bool {
        didSet {
            if isSelected {
                backgroundColor = UIColor.systemBlue
                textLabel.textColor = .white
            } else {
                backgroundColor = .white
                textLabel.textColor = UIColor.systemBlue
            }
        }
    }
    
    @IBOutlet weak var textLabel: UILabel!
}

// セルを左寄せにするカスタムレイアウトクラス
class CollectionViewLeftAlignedLayout: UICollectionViewFlowLayout {
    
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let attributes = super.layoutAttributesForElements(in: rect) else { return nil }
        var currentRowY: CGFloat = -1.0
        var currentRowX: CGFloat = 0
        for attribute in attributes where attribute.representedElementCategory == .cell {
            if currentRowY != attribute.frame.origin.y {
                currentRowY = attribute.frame.origin.y
                currentRowX = sectionInset.left
            }
            attribute.frame.origin.x = currentRowX
            currentRowX += attribute.frame.width + minimumInteritemSpacing
        }
        return attributes
    }
}

セルのサイズ

今回はセル内に1行指定の UILabel があり、テキストの長さによってセルの横幅は可変長となる。

セルのサイズは固定値を指定するのではなく、AutoLayout の制約に従って動的に計算される Self Sizing 機能を利用する。
セルの Self Sizing 機能を有効にするには、UICollectionViewFlowLayout.estimatedItemSizeプロパティを 0 以外にすればよい。
estimatedItemSizeプロパティにはデフォルトでUICollectionViewFlowLayout.automaticSizeが設定されているので既に有効になっている。
なのでUICollectionViewFlowLayout.itemSizeプロパティやcollectionView(layout:sizeForItemAt:)デリゲートメソッドによるサイズ指定はしないようにする。

StoryBoard で UICollectionViewCell に UILabel を配置し、ContentView の四辺のエッジに合うように制約を追加する。

f:id:xyk:20210217003629p:plain

レイアウトクラス

UICollectionViewFlowLayout を継承したサブクラスCollectionViewLeftAlignedLayoutを作成する。
そしてlayoutAttributesForElements(in:)メソッドをオーバーライドし、各セルのframe.origin.xを上書きして左寄せになるように調整する。

f:id:xyk:20210217114146p:plain