こんにちは!

21年新卒のTAROです!

 

今回ブログを書く機会をいただき、iOSアプリでは必ず使うと言ってもいいUICollectionViewについて書こうと思います。

ある案件で縦横スクロール可能なコレクションビューを実装する機会があったので、自分がどのように実装したか紹介します。

ちなみに動作は動画のような感じになります。

ボタンをタップすることで該当のセルに移動する感じです。

 

 

実装方法の概要

今回の実装は大まかに以下の順番で行いました。

  1. UICollectionViewFlowLayoutを継承したクラスを定義する
  2. 定義したクラスを使ってUICollectionViewを作成する
  3. 作成したコレクションビューとボタンを画面に追加する
  4. ボタンをタップしたらコレクションビューの該当の場所に遷移するようにする

以下で詳しく紹介します。

 

UICollectionViewFlowLayout を継承したクラスを定義する

今回は縦横のスクロールを可能にするため、UICollectionViewFlowLayoutを継承したカスタムのクラスを定義します。

ちなみに、UICollectionViewFlowLayout はコレクションビューのレイアウトを管理するクラスです。

今回は以下のような実装を行いました。

詳しい実装内容についてはぜひ調べてみてください。

import UIKit

class CrossScrollLayout: UICollectionViewFlowLayout {
    weak var delegate: UICollectionViewDelegateFlowLayout?

    private var layoutInfo: [IndexPath: UICollectionViewLayoutAttributes] = [:]
    private var itemSpacing: CGFloat = .zero
    private var lineSpacing: CGFloat = .zero

    private lazy var cellHeight: CGFloat = {
        guard let collectionView = collectionView else { return .zero }
        return collectionView.bounds.size.height
    }()
    private lazy var cellWidth: CGFloat = {
        guard let collectionView = collectionView else { return .zero }
        return collectionView.bounds.size.width
    }()

    private lazy var numberOfColumns: CGFloat = {
        guard let collectionView = collectionView else { return .zero }
        return CGFloat(collectionView.numberOfSections)
    }()

    private lazy var numberOfRows: CGFloat = {
        guard let collectionView = collectionView else { return .zero }
        return CGFloat(collectionView.numberOfItems(inSection: .zero))
    }()

    override func prepare() {
        guard let collectionView = collectionView else { return }
        delegate = collectionView.delegate as? UICollectionViewDelegateFlowLayout
        setupLayoutInfo()
    }

    override var collectionViewContentSize: CGSize {
        let contentWidth: CGFloat = itemSpacing * (numberOfColumns - 1) + cellWidth * numberOfColumns
        let contentHeight: CGFloat = lineSpacing * (numberOfRows - 1) + (cellHeight * numberOfRows)
        return CGSize(width: contentWidth, height: contentHeight)
    }

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        var allAttributes: [UICollectionViewLayoutAttributes] = []

        for attributes in layoutInfo.values {
            if rect.intersects(attributes.frame) {
                allAttributes.append(attributes)
            }
        }

        return allAttributes
    }

    override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return layoutInfo[indexPath]
    }

    private func setupLayoutInfo() {
        guard
            let collectionView = collectionView,
            let delegate = delegate
        else { return }

        var cellLayoutInfo: [IndexPath: UICollectionViewLayoutAttributes] = [:]
        var originY: CGFloat = .zero

        for section in 0..<collectionView.numberOfSections {
            var height: CGFloat = .zero
            var originX: CGFloat = .zero

            for item in 0..<collectionView.numberOfItems(inSection: section) {
                let indexPath = IndexPath(item: item, section: section)
                let itemAttributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)

                let itemSize = delegate.collectionView?(
                    collectionView,
                    layout: self,
                    sizeForItemAt: indexPath
                ) ?? .zero

                itemSpacing = delegate.collectionView?(
                    collectionView,
                    layout: self,
                    minimumInteritemSpacingForSectionAt: section
                ) ?? .zero

                itemAttributes.frame = CGRect(
                    x: originX,
                    y: originY,
                    width: itemSize.width,
                    height: itemSize.height
                )

                cellLayoutInfo[indexPath] = itemAttributes

                originX += (itemSize.width + itemSpacing)
                height = height > itemSize.height ? height : itemSize.height
            }

            lineSpacing = delegate.collectionView?(
                collectionView,
                layout: self,
                minimumLineSpacingForSectionAt: section
            ) ?? .zero
            originY += (height + lineSpacing)
        }

        self.layoutInfo = cellLayoutInfo
    }
}

 

コレクションビューとボタンを画面に追加する

先程作成したレイアウトクラスを使いコレクションビューを作成します。

また、コレクションビューとボタンを画面に追加します。

今回は、storyboardは使わずにSnapkitを使ってコードのみでUIを作成しました。

import UIKit

class HomeViewController: UIViewController {
    private lazy var topButton: UIButton = {
        let button = UIButton(type: .custom)
        button.setImage(UIImage(systemName: "arrow.up"), for: .normal)
        button.layer.borderColor = UIColor.link.cgColor
        button.layer.borderWidth = 2
        button.layer.cornerRadius = 10
        button.addTarget(self, action: #selector(onTapTopButton(_:)), for: .touchUpInside)
        return button
    }()

    private lazy var leftButton: UIButton = {
        let button = UIButton(type: .custom)
        button.setImage(UIImage(systemName: "arrow.left"), for: .normal)
        button.layer.borderColor = UIColor.link.cgColor
        button.layer.borderWidth = 2
        button.layer.cornerRadius = 10
        button.addTarget(self, action: #selector(onTapLeftButton(_:)), for: .touchUpInside)
        return button
    }()

    private lazy var bottomButton: UIButton = {
        let button = UIButton(type: .custom)
        button.setImage(UIImage(systemName: "arrow.down"), for: .normal)
        button.layer.borderColor = UIColor.link.cgColor
        button.layer.borderWidth = 2
        button.layer.cornerRadius = 10
        button.addTarget(self, action: #selector(onTapBottomButton(_:)), for: .touchUpInside)
        return button
    }()

    private lazy var rightButton: UIButton = {
        let button = UIButton(type: .custom)
        button.setImage(UIImage(systemName: "arrow.right"), for: .normal)
        button.layer.borderColor = UIColor.link.cgColor
        button.layer.borderWidth = 2
        button.layer.cornerRadius = 10
        button.addTarget(self, action: #selector(onTapRightButton(_:)), for: .touchUpInside)
        return button
    }()

    private lazy var collectionView: UICollectionView = {
        let layout = CrossScrollLayout()
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.backgroundColor = .clear
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.register(
            ItemCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ItemCollectionViewCell.self)
        )
        return collectionView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        initUI()
    }

    // MARK: - Private Methods
    private func initUI() {
        title = "HOME"
        view.backgroundColor = .white

        view.addSubview(collectionView)
        view.addSubview(topButton)
        view.addSubview(leftButton)
        view.addSubview(bottomButton)
        view.addSubview(rightButton)

        collectionView.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide.snp.top)
            make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
            make.left.equalTo(view.safeAreaLayoutGuide.snp.left)
            make.right.equalTo(view.safeAreaLayoutGuide.snp.right)
        }

        topButton.snp.makeConstraints { make in
            make.centerX.equalToSuperview()
            make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(20)
            make.width.height.equalTo(40)
        }

        leftButton.snp.makeConstraints { make in
            make.centerY.equalToSuperview()
            make.left.equalTo(view.safeAreaLayoutGuide.snp.left).offset(20)
            make.width.height.equalTo(40)
        }

        bottomButton.snp.makeConstraints { make in
            make.centerX.equalToSuperview()
            make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).offset(-20)
            make.width.height.equalTo(40)
        }

        rightButton.snp.makeConstraints { make in
            make.centerY.equalToSuperview()
            make.right.equalTo(view.safeAreaLayoutGuide.snp.right).offset(-20)
            make.width.height.equalTo(40)
        }
    }

    @objc private func onTapTopButton(_ sender: UIButton) {
        collectionView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
    }

    @objc private func onTapBottomButton(_ sender: UIButton) {
        let targetY = (collectionView.bounds.height + 10) * 2
        collectionView.setContentOffset(CGPoint(x: 0, y: targetY), animated: true)
    }

    @objc private func onTapRightButton(_ sender: UIButton) {
        let targetX = (collectionView.bounds.width + 10) * 2
        collectionView.setContentOffset(CGPoint(x: targetX, y: 0), animated: true)
    }

    @objc private func onTapLeftButton(_ sender: UIButton) {
        collectionView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
    }

    @objc private func orientationDidChange() {
        collectionView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
    }
}

// MARK: - UICollectionViewDataSource
extension HomeViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        10
    }

    func collectionView(
        _ collectionView: UICollectionView,
        numberOfItemsInSection section: Int
    ) -> Int {
        6
    }

    func collectionView(
        _ collectionView: UICollectionView,
        cellForItemAt indexPath: IndexPath
    ) -> UICollectionViewCell {
        guard
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: ItemCollectionViewCell.self), for: indexPath) as? ItemCollectionViewCell
        else { return UICollectionViewCell() }

        let number = indexPath.section * 10 + indexPath.item
        cell.setup(String(number))
        return cell
    }
}

// MARK: - UICollectionViewDelegate
extension HomeViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        collectionView.deselectItem(at: indexPath, animated: true)
    }
}

// MARK: - UICollectionViewDelegateFlowLayout
extension HomeViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath
    ) -> CGSize {
        return view.safeAreaLayoutGuide.layoutFrame.size
    }

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

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

 

 

ボタンをタップした時の処理を記述する

ボタンをタップした時にコレクションビューの任意の場所に遷移するようにします。

setContentOffset(_:animated:) を使います。

@objc private func onTapTopButton(_ sender: UIButton) {
    collectionView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
}

@objc private func onTapBottomButton(_ sender: UIButton) {
    let targetY = (collectionView.bounds.height + 10) * 2
    collectionView.setContentOffset(CGPoint(x: 0, y: targetY), animated: true)
}

@objc private func onTapRightButton(_ sender: UIButton) {
    let targetX = (collectionView.bounds.width + 10) * 2
    collectionView.setContentOffset(CGPoint(x: targetX, y: 0), animated: true)
}

@objc private func onTapLeftButton(_ sender: UIButton) {
    collectionView.setContentOffset(CGPoint(x: 0, y: 0), animated: true)
}

上記のコードでボタンタップ時にCollectionViewの任意の場所に遷移できます。

 

まとめ

今回は縦横スクロールできるコレクションビューの実装例を紹介してみました。

縦スクロールできるコレクションビューはデフォルトの挙動で実現できるのですが、

横スクロールが入った途端に難しくなるのが新たな発見でした。

また機会があれば他の要件について、どのようなアプローチがあるのか調べてみようと思います。

最後まで読んでいただきありがとうございます!