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

# はじめに
この記事は、[ZOZOテクノロジーズ #4AdventCalendar2019](https://qiita.com/advent-calendar/2019/zozo_tech4)の記事です。
昨日は[@zukkey](https://twitter.com/zukkey59)さんの[「Epoxy + PagingLibrarySupportで追加読み込み機能を実装してみる」](https://qiita.com/iiizukkeyiii/items/4eb34698d2fc4a987ca7)の記事でした。

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

[ZOZOテクノロジーズ #1AdventCalendar2019](https://qiita.com/advent-calendar/2019/zozo_tech)
[ZOZOテクノロジーズ #2AdventCalendar2019](https://qiita.com/advent-calendar/2019/zozo_tech2)
[ZOZOテクノロジーズ #3AdventCalendar2019](https://qiita.com/advent-calendar/2019/zozo_tech3)
[ZOZOテクノロジーズ #5AdventCalendar2019](https://qiita.com/advent-calendar/2019/zozo_tech5)

ZOZOテクノロジーズ iOSエンジニアの[@ahiru](https://twitter.com/ahiru_starrr)です。

ZOZOTOWNでは少し前に[類似画像検索機能](https://corp.zozo.com/news/20190826-8586/)がリリースされました。

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

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

**【完成例】**
![スクロール.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122157/a70fcd78-b6c9-d344-2d82-f418185502c4.gif)

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122157/bb57fca7-e1aa-553f-da50-1a2cde112eb8.png" width="400">

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122157/63c72b1f-aa07-5124-e915-c571322930d5.png" width="400">

# AutoLayoutの制約

### HeaderViewの制約

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122157/fcf3073d-6e49-eaed-7fbd-d9aff37b50b5.png" width="400">

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

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

<img width="259" alt="a.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122157/848913d2-576b-99ca-7c91-37cc7b03f6c9.png">
ex) HeaderViewの高さが44の場合、constant=-44としてTopAlignmentの制約を設定します。

### CollectionViewの制約
<img width="256" alt="b.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122157/6b628033-bd2a-0cf5-5f0e-9ff2d91c38ff.png">
CollectionViewのTopSpaceの制約にはヘッダーが固定する位置を設定します。
画像の例ではCollectionViewのTopSpaceはViewControllerのViewに対して100に設定しており、HeaderViewの高さは44なのでHeaderViewは画面上部から56の位置で固定になります。

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

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

// collectionViewにcontentInset.topをセット
collectionView.contentInset.top = 500(プロダクトに応じて好きな値を設定)
```
これで一通りの準備はOKなのですが、このままだとHeaderViewとCollectionViewのコンテンツ領域の間にcontentInset.top分だけ余白が生まれてしまいます。

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

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


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

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

ここまで実装すると下記のような画面が出来上がります。
<img width="375" alt="類似画像TOP.jpeg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122157/f307d7d2-e499-a09f-93e9-e6fce67be99b.jpeg">


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

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

```swift
// HeaderViewをCollectionViewのコンテンツ領域に追随するように移動
headerViewTopConstraint.constant = min(max(-scrollView.contentOffset.y, 0) - headerView.height, 500)
```
headerViewが移動しても良い領域はcollectionViewに設定した余白部分だけなのでそれよりも上・下には移動しないように制限した上でHeaderViewをCollectionViewのスクロール量に応じて移動してあげます。

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

![スクロール.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/122157/9bd8d3de-2832-6dbd-3ea4-4745edfb8664.gif)


# ジェスチャのケアをする

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

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

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

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