一定以上スクロールしたらヘッダーが固定するScrollViewをたったの1行で実装する方法


はじめに

この記事は、ZOZOテクノロジーズ #4AdventCalendar2019の記事です。
昨日は@zukkeyさんの「Epoxy + PagingLibrarySupportで追加読み込み機能を実装してみる」の記事でした。

ZOZOテクノロジーズでは、他にもAdventCalenderを書いている方がいらっしゃるので、よかったらみていってください!

ZOZOテクノロジーズ #1AdventCalendar2019
ZOZOテクノロジーズ #2AdventCalendar2019
ZOZOテクノロジーズ #3AdventCalendar2019
ZOZOテクノロジーズ #5AdventCalendar2019

ZOZOテクノロジーズ iOSエンジニアの@ahiruです。

ZOZOTOWNでは少し前に類似画像検索機能がリリースされました。

その際に「ある位置までスクロールしたらヘッダーは固定して中のコンテンツだけがスクロールするような画面」を実装したのでその方法をご紹介します。

Viewの制約をうまくつけてあげると座標計算はなんとたったの1行で記述することができます。

【完成例】

いろんなアプリでよく見かけるUIで様々な実装方法が存在し、4年前のzozoテクノロジーズ(当時はVasily)のテックブログでも似た内容の実装方法が紹介されています。

よくある実装方法は、CollectionViewのフレームをスクロール量に応じてずらしていく方法だと思いますが今回はそれをせずにとにかくシンプルで簡単な方法をご紹介します。

概要

あらかじめCollectionViewの上部に余白を設けます(contentInset.topを設定する)
あとはHeaderViewの位置をCollectionViewのコンテンツのスクロール量に応じて移動させてあげればOKです。

UIの構造

HeaderView(UIView)とCollectionView(UICollectionView)の2つが存在します。
(CollectionViewの部分はTableView/ScrollViewでももちろん問題ありません)

AutoLayoutの制約

HeaderViewの制約

HeaderViewの高さの制約は固定値にしています。

ここでポイントなのはHeaderViewのTopとCollectionViewのTopをconstant=-HeaderViewの高さで制約をつけることです。


ex) HeaderViewの高さが44の場合、constant=-44としてTopAlignmentの制約を設定します。

CollectionViewの制約


CollectionViewのTopSpaceの制約にはヘッダーが固定する位置を設定します。
画像の例ではCollectionViewのTopSpaceはViewControllerのViewに対して100に設定しており、HeaderViewの高さは44なのでHeaderViewは画面上部から56の位置で固定になります。

CollectionViewのcontentInset.topを設定

contentInset.topを使ってCollectionViewの上部に余白を設けます(CollectionViewのコンテンツ領域を下に押し下げます)

viewDidLayoutSubviewsなどでレイアウトが完了した時に一度だけ

// collectionViewにcontentInset.topをセット
collectionView.contentInset.top = 500(プロダクトに応じて好きな値を設定)

これで一通りの準備はOKなのですが、このままだとHeaderViewとCollectionViewのコンテンツ領域の間にcontentInset.top分だけ余白が生まれてしまいます。

そのためHeaderViewとViewControllerのTopAlignmentの制約を変数にもちconstantの設定を一緒に行います。
つまり、collectionViewに設けた余白分だけHeaderViewも移動させてあげます。

/// ヘッダーViewとViewControllerのTopの制約
@IBOutlet private var headerViewTopConstraint: NSLayoutConstraint!


viewDidLayoutSubviewsなどでレイアウトが完了した時に一度だけ値をセット

headerViewTopConstraint.constant = 500
collectionView.contentInset.top = 500 + headerView.height

ここまで実装すると下記のような画面が出来上がります。

座標計算はたったの1行でOK

座標計算に関してはCollectionViewのスクロール量(offset値)に応じてうまくHeaderViewを移動させてあげるだけです。

scrollViewDidScrollの中で下記の1行を実装します。

// HeaderViewをCollectionViewのコンテンツ領域に追随するように移動
headerViewTopConstraint.constant = min(max(-scrollView.contentOffset.y, 0) - headerView.height, 500)

headerViewが移動しても良い領域はcollectionViewに設定した余白部分だけなのでそれよりも上・下には移動しないように制限した上でHeaderViewをCollectionViewのスクロール量に応じて移動してあげます。

これでヘッダー固定のスクロールUIの完成です。

ジェスチャのケアをする

これでUIは完成なのですが、CollectionViewのcontentInset.topをいじっているのでこのままだとコンテンツがない領域(contentInset.topの余白部分)もジェスチャが反応しています。

その回避策としてCollectionViewのサブクラスを作成し、下記の実装をする方法があります。(下記の実装を行ったUICollectionViewのサブクラスを利用する)

override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        let locationInView = gestureRecognizer.location(in: self)
        return locationInView.y > 0
}

まとめ

何事も最小限のコストで最大限のパフォーマンスを発揮する方法を模索することが大切だと思います。
誰かのお役に立てれば幸いです。