こんにちは。WEAR部iOSチームの小野寺です。
先日CollectionViewで実装しているトップページを改修しました。
改修はトップページに並べていたコンテンツを1つにまとめて、横スクロール(手動 / 自動)によってコンテンツを切り替え可能にしました。
横スクロールによってコンテンツを切り替える仕様なので、CompositionalLayoutで実装しました。
上記の方針で進めていく中で、困難な実装に直面したので紹介します。
セクション全体への装飾
最初に直面した問題が、セクション全体に対するViewの装飾です。今回画像の赤枠部分について、次のように改修が必要になりました。
トップページ改修の要件
- 横スクロールで、コンテンツを切り替えられるレイアウトに変更
- その上に固定で表示され続けるViewを被せる(画像の赤枠)
- 固定で表示するViewの高さはタグ名の長さによって変わる
改修前
改修後
レイアウト調整にコストがかかる
固定で表示され続けるViewは、UICollectionReusableView, NSCollectionLayoutBoundarySupplementaryItemを使用しました。
func createLayout() -> UICollectionViewCompositionalLayout {
~~ 省略:横スクロール用のレイアウト設定 ~~
let decorationViewHeight = 150
let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
elementKind: “tag-container-element-kind”,
alignment: .bottom,
absoluteOffset: .init(x: 0, y: -decorationViewHeight))
section.boundarySupplementaryItems = [decorationView]
~~ 省略 ~~
}
追加したViewのy座標を、セクションの乗せたい位置(decorationViewHeight)までずらすことで、セクション全体へかかるようにしています。
ここではまだ、高さが変わることを考慮していないので、内容によってはレイアウトが崩れてしまいます。
Viewの高さを計算する対応を追加し、コンテンツの表示に必要な高さを確保します。
class DecorationView: UICollectionReusableView {
~~ 省略 ~~
static func calcViewHeight(title: String, containerSizeOfWidth: CGFloat) -> CGFloat {
let decorationView = calculationBaseView
decorationView.label.text = title
decorationView.setNeedsDisplay()
decorationView.layoutIfNeeded()
let layoutViewSize = layoutView.systemLayoutSizeFitting(CGSize(width: containerSizeOfWidth, height: 0),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel)
return layoutViewSize.height
}
~~ 省略 ~~
}
追加したcalcViewHeight()をdecorationViewHeightへ反映させます。
func createLayout() -> UICollectionViewCompositionalLayout {
~~ 省略:横スクロール用のレイアウト設定 ~~
// let decorationViewHeight = 150
let title = "冬がはじまるよ"
let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))!
let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width)
let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
elementKind: “tag-container-element-kind”,
alignment: .bottom,
absoluteOffset: .init(x: 0, y: -decorationViewHeight))
section.boundarySupplementaryItems = [decorationView]
~~ 省略 ~~
}
追加したcalcViewHeight()をdecorationViewHeightへ反映させます。
func createLayout() -> UICollectionViewCompositionalLayout {
~~ 省略:横スクロール用のレイアウト設定 ~~
// let decorationViewHeight = 150
let title = "冬がはじまるよ"
let layout = collectionView.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))!
let decorationViewHeight = DecorationView.calcViewHeight(title: title, containerSizeOfWidth: layout.frame.width)
let decorationViewSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(decorationViewHeight))
let decorationView = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: tagContainerViewSize,
elementKind: “tag-container-element-kind”,
alignment: .bottom,
absoluteOffset: .init(x: 0, y: -decorationViewHeight))
section.boundarySupplementaryItems = [decorationView]
~~ 省略 ~~
}
コンテンツの内容によって高さを取得する必要があるのでレイアウト調整にコストがかかっています。
OSによってはフッターが非表示になる
さらに、iOS14.5未満のOSバージョンで、フッターがセルの裏に隠れてしまう問題もありました。
この事象に対しては、UICollectionReusableViewを使った対応が取れませんでした。
今回は後にターゲットOSを上げる予定があった為、暫定対応としてUICollectionReusableViewに直接実装したコンテンツをカスタムUIViewとして切り出しました。
事象が発生するバージョンではUICollectionReusableViewは使用せず、このカスタムUIViewをCollectionViewのSubViewとして扱う改修で対応しました。
コンテンツの自動スクロール
レイアウトの改修の次は、自動でコンテンツをスクロールさせる機能の追加です。
自動スクロール機能の要件
- 一定時間に画面の操作がない場合に、次のセルを表示
- 最後のセルまで表示させたら、先頭に戻る
自動スクロール中に意図しないスクロールの発生
自動スクロールの対応は、はじめに以下の方針で検討しました。
- 一定時間でスクロールできるように、Timerを追加して定周期でスクロール処理を呼び出す。
- スクロール処理は、scrollToItem(at:at:animated:)をデフォルトのアニメーションを有効にして、コンテンツをスクロールさせる。
Timer側からの呼び出し
Timer.scheduledTimer(
withTimeInterval: 3.0,
repeats: true,
block: { [weak self] _ in
guard let self = self else { return }
let toItem = displayContentIndexPathItem + 1
self.scrollToItemForCarousel(at: .init(item: toItem, section: 0))
}
)
定周期で呼び出すスクロール処理
func scrollToItemForCarousel(at indexPath: IndexPath) {
collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}
実際動作させてみたところcollectionView.contentOffset.yが、アニメーションの度に改修したセクションのデフォルトの位置へと、引き戻される問題が発生しました。
原因は、scrollToItem(at:at:animated:) へ第1引数で渡しているindexPathにありました。スクロール先の指定にindexPathが使用されることで、x座標、y座標それぞれに対してscrollToItemが作用してしまいます。
これによりCollectionViewの垂直方向が、少しでもスクロールされた状況下の場合に、事象が発生してしまいました。
自作の自動スクロールのアニメーションを追加
問題を解消するために、今回はscrollToItem(at:at:animated:)のデフォルトのアニメーションは使わず、次のような自作アニメーションを実装しました。
- 自動スクロール前のcontentOffsetの位置を保持する
- scrollToItem(at:at:animated:)でスクロール位置を更新
- y座標を調整前の値で更新する
- 2と3を1つのアニメーションとして扱う
func scrollToItemForCarousel(at indexPath: IndexPath) {
let currentOffset = collectionView.contentOffset
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut) { [weak self] in
guard let collectionView = self?.collectionView else { return }
collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
collectionView.contentOffset.y = currentOffset.y
}
}
scrollToItemでのindexPathの更新とy座標をもとに戻す対応を1つのアニメーションとして実装することで、引き戻される問題を回避しています。
先頭へ戻るアニメーションにコストがかかる
ここまでの対応で、自動で次のコンテンツへ切り替える対応ができました。次に最後のセルまで表示させたら、先頭に戻るアニメーションが必要です。
戻る処理についてもscrollToItem(at:at:animated:)のアニメーションをそのまま使用できれば、簡潔に対応可能です。
(第1引数へ先頭のIndexPathを指定することで、コンテンツが最終位置にいる状態から、アニメーション付きで一気に先頭へ戻す挙動を実現できます)
しかし自作アニメーションを使用する場合は、次のような変更が必要になりました。
続きはこちら