概要
基本的なレイアウトはReadable Content Guide(以下RCG)に従いつつ、アプリ内のコンテンツをセクション別にページングを効かせながら横スクロールできるようなインタフェースのサンプルです。

アプリの全体的なサンプルコードはこちらです。
https://github.com/idomazine/RCGCollectionView
動作環境
- macOS Catalina 10.15.6
- XCode 12.2
- Swift 5.3.1
実装手順
実装するにあたって概ね3つの課題がありますので順に説明します。
- UITableView内にUICollectionViewを保持する
- UICollectionViewのセル配置にRCGを適用する
- UICollectionViewのスクロールにページング補正をかける
UITableView内にUICollectionViewを保持する
final class PaletteTableViewCell: UITableViewCell {
private var collectionView: UICollectionView!
private var layout: UICollectionViewFlowLayout!
private var colors: [UIColor] = []
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
// コレクションビューのレイアウト
layout = PagingForReadableContentGuideLayout()
layout.scrollDirection = .horizontal
layout.itemSize = ColorCollectionViewCell.defaultSize
layout.minimumLineSpacing = 16
// コンテンツ表示用コレクションビュー
collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
collectionView.showsHorizontalScrollIndicator = false
collectionView.backgroundColor = UIColor.clear
collectionView.decelerationRate = UIScrollView.DecelerationRate.fast
collectionView.dataSource = self
collectionView.register(ColorCollectionViewCell.self,
forCellWithReuseIdentifier: "ColorCollectionViewCell")
contentView.addSubview(collectionView)
collectionView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: contentView.topAnchor),
collectionView.leftAnchor.constraint(equalTo: contentView.leftAnchor),
collectionView.rightAnchor.constraint(equalTo: contentView.rightAnchor),
collectionView.heightAnchor.constraint(equalToConstant: ColorCollectionViewCell.defaultSize.height)
])
}
override func prepareForReuse() {
super.prepareForReuse()
//スクロール位置のリセット
collectionView.contentOffset = .init(x: -collectionView.contentInset.left,
y: -collectionView.contentInset.top)
}
func configure(colors: [UIColor]) {
self.colors = colors
collectionView.reloadData()
}
}
extension PaletteTableViewCell: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
colors.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ColorCollectionViewCell",
for: indexPath) as! ColorCollectionViewCell
cell.configure(color: colors[indexPath.row])
return cell
}
}
ところどころ省略していますが、これでUITableView内にUICollectionViewを保持することができます。
そのままだとセルが再利用された際に中途半端なスクロール位置から始まってしまうのでprepareForReuse
でスクロール位置のリセットを行っているのがポイントです。(同じ行の再表示の際にスクロール位置を保存するとより自然に見えますが、今回は割愛)
UICollectionViewのセル配置にRCGを適用する
final class PaletteTableViewCell: UITableViewCell {
override func layoutSubviews() {
let leftInset = readableContentGuide.layoutFrame.minX
let rightInset = frame.width - leftInset - layout.itemSize.width
collectionView.contentInset = .init(top: 0,
left: leftInset,
bottom: 0,
right: rightInset)
super.layoutSubviews()
}
}
初期表示時や端末の回転などのタイミングで呼び出されるため、layoutSubviews
内でRCGの実際の幅を取得してcontentInsetに指定することで自動的にRCGに追従する仕組みを作れます。
今回はUITableViewが保持していますが、UIViewControllerがUICollectionViewを管理する場合はviewWillLayoutSubviews
のタイミングで同じことを行うと良いです。
UICollectionViewのスクロールにページング補正をかける
final class PagingForReadableContentGuideLayout: UICollectionViewFlowLayout {
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint,
withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let contentInsetLeft = collectionView?.contentInset.left else { return proposedContentOffset }
// アイテム1つあたりの表示幅
let widthPerItem: CGFloat = itemSize.width + minimumLineSpacing
// 止まるべきページ数の算出
let adjustedPage: CGFloat = {
// contentOffsetはcontentInsetを引いた位置から始まるため、加算して実質的なスクロール位置を算出する
let relativeOffsetX = proposedContentOffset.x + contentInsetLeft
// スクロール位置をアイテムの表示幅で割ることでページ数を算出する
let page = relativeOffsetX / widthPerItem
if velocity.x > 0 {
// 右向きにスワイプされたなら右側のアイテムの位置に合わせる
return ceil(page)
} else if velocity.x < 0 {
// 左向きにスワイプされたなら左側のアイテムの位置に合わせる
return floor(page)
} else {
// 静止した状態で指が放されていたなら近いアイテムの位置に移動するため四捨五入する
return round(page)
}
}()
// ページング補正後のスクロール位置
let adjustedContentOffsetX = adjustedPage * widthPerItem - contentInsetLeft
return CGPoint(x: adjustedContentOffsetX,
y: proposedContentOffset.y)
}
}
UICollectionViewFlowLayoutのtargetContentOffset
をoverrideすると、スワイプ終了時にスクロールが止まる位置を制御できます。
contentInsetを加味したスクロール位置をアイテム幅で割り、ページ数を算出することでスクロールが止まる位置を決定しています。
これによっていい感じの位置でアイテムが表示される実装ができます。
ランダム色の生成
本題と関係ないですが一応。
extension UIColor {
static func random() -> UIColor {
UIColor(red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1),
alpha: 1)
}
UIColorは以上のように書けばランダムな色を生成できます。これを利用してViewController側では10X20の色を生成して表示しています。
今回のUIColor関連の操作はこちらを参考にさせていただきつつ少しアレンジしています。
https://qiita.com/Kyome/items/a6337bea6420ec3a8991
https://qiita.com/Kyome/items/eae6216b13c651254f64