こんにちは!
21年新卒のTAROです!
今回ブログを書く機会をいただき、iOSアプリでは必ず使うと言ってもいいUICollectionView
について書こうと思います。
ある案件で縦横スクロール可能なコレクションビューを実装する機会があったので、自分がどのように実装したか紹介します。
ちなみに動作は動画のような感じになります。
ボタンをタップすることで該当のセルに移動する感じです。
実装方法の概要
今回の実装は大まかに以下の順番で行いました。
UICollectionViewFlowLayout
を継承したクラスを定義する- 定義したクラスを使って
UICollectionView
を作成する - 作成したコレクションビューとボタンを画面に追加する
- ボタンをタップしたらコレクションビューの該当の場所に遷移するようにする
以下で詳しく紹介します。
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の任意の場所に遷移できます。
まとめ
今回は縦横スクロールできるコレクションビューの実装例を紹介してみました。
縦スクロールできるコレクションビューはデフォルトの挙動で実現できるのですが、
横スクロールが入った途端に難しくなるのが新たな発見でした。
また機会があれば他の要件について、どのようなアプローチがあるのか調べてみようと思います。
最後まで読んでいただきありがとうございます!