iOS
UICollectionView
Swift

【Swift 4対応】UICollectionView で Cell の高さ計算が複雑な画面を作成する

Qiita の仕様上コード表示の横幅に限度があるため、改行を多用しております。ご了承ください。

仕様

qiita.001.png
上記のような画面構成が要求されたとして説明していきます。重要な制約をまとめると以下のとおりです。

  • UILabel は改行可能
  • UIImageView は正方形で、端末サイズによって可変(Cell 間隔を 16px として一行に3つ配置)
  • CollectionViewCell の height は UIImageView と UILabel の height によって決定される
  • CollectionViewCell の width は UIImageView の width によって決定される
  • Cell の height が一行3つの中で同じでない場合、一番高いものに合わせる

方針

UIImageView の width (正方形なので自動的に height も決まる) と UILabel の height を求めて CollectionViewCell の width, height を求める。

UILabel の高さを算出

Extension

import Foundation

public extension String {
    public func labelHeight(width: CGFloat,
                            font: UIFont,
                            lineBreakMode: NSLineBreakMode = .byWordWrapping) -> CGSize {
        let size = CGSize(width: width, height: .greatestFiniteMagnitude)
        let style = NSMutableParagraphStyle()
        style.lineBreakMode = lineBreakMode
        return (self as NSString).boundingRect(with: size,
                                               options: [.usesLineFragmentOrigin,
                                                         .usesFontLeading],
                                               attributes: [.font: font,
                                                            .paragraphStyle: style],
                                               context: nil).size
    }
}

Usage

let labelHeight = titleText.labelHeight(width: width,
                                        font: style.title.font,
                                        lineBreakMode: .byCharWrapping).height +
                  otherText.labelHeight(width: width,
                                        font: style.other.font,
                                        lineBreakMode: .byCharWrapping).height

取得したデータから UILabel の height を取得することができます。なお、引数には UILabel の width とフォントの指定が必須です。任意で改行方法を指定してください。今回は UILabel の width は UIImageView の width と同じになります。

UIImageView のサイズを算出

let screenWidth = UIScreen.main.bounds.size.width
let width = (screenWidth - Const.cellMargin * 4) / CGFloat(3)

余白を除いた width を計算し、それを一行に表示する個数で割れば求められます。

実装

enum Const {
    static let cellMargin: CGFloat = 16.0
    static let rowCount = 3
    static let imageToLabelMargin: CGFloat = 8.0
    static let labelToLabelMargin: CGFloat = 4.0
}

func collectionView(_ collectionView: UICollectionView,
                    layout collectionViewLayout: UICollectionViewLayout,
                    sizeForItemAt indexPath: IndexPath) -> CGSize {
    let screenWidth = UIScreen.main.bounds.size.width

    switch indexPath.section {
    case Section.content.rawValue:
        var width = (screenWidth - Const.cellMargin * 4) / CGFloat(Const.rowCount)
        let style = CollectionViewCell.LabelStyle.self
        var diffFromLeft = 0
        if indexPath.row % 3 == 0 {
            // 左端のセル
            diffFromLeft = 0
        } else if (indexPath.row - 1) % 3 == 0 {
            // 中央のセル
            diffFromLeft = -1
        } else if (indexPath.row + 1) % 3 == 0 {
            // 右端のセル
            diffFromLeft = -2
        }
        var rowLabelHeight: CGFloat = 0.0
        for value in 0...2 {
            let searchIndex = indexPath.row + value + diffFromLeft
            guard viewModel.list.count > searchIndex else {
                let height = width +
                             rowLabelHeight +
                             Const.imageToLabelMargin +
                             Const.labelToLabelMargin
                return CGSize(width: width, height: height)
            }
            let content = viewModel.content(at: searchIndex)
            let titleText = content.title
            let otherText = content.other
            let labelHeight = titleText.labelHeight(width: width,
                                                    font: style.title.font,
                                                    lineBreakMode: .byCharWrapping).height +
                otherText.labelHeight(width: width,
                                      font: style.other.font,
                                      lineBreakMode: .byCharWrapping).height
            if rowLabelHeight < labelHeight {
                rowLabelHeight = labelHeight
            }
        }

        let height = width +
                     rowLabelHeight +
                     Const.imageToLabelMargin +
                     Const.labelToLabelMargin
        return CGSize(width: width, height: height)
    default:
        return .zero
    }
}

func collectionView(_ collectionView: UICollectionView,
                    layout collectionViewLayout: UICollectionViewLayout,
                    minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
    switch section {
    case Section.content.rawValue:
        return Const.cellMargin
    default:
        return 0
    }
}

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

func collectionView(_ collectionView: UICollectionView,
                    layout collectionViewLayout: UICollectionViewLayout,
                    insetForSectionAt section: Int) -> UIEdgeInsets {
    switch section {
    case Section.content.rawValue:
        return UIEdgeInsets(top: Const.cellMargin,
                            left: Const.cellMargin,
                            bottom: Const.cellMargin,
                            right: Const.cellMargin)
    default:
        return .zero
    }
}

一行すべての Cell の height を一番大きなものに統一する処理に無駄があるため、改善の余地がありそうですが、このような処理で一旦実現が可能です。