[Swift5]ニコニコ動画やLINEにあるようなスクロールによって閉じたり開いたりするヘッダー

# はじめに
**TableViewのスライドに同期して出てきたり閉じたりするヘッダーみたいなバー**は、主に検索やビューの切り替えなどに使われ**非常に便利なツール**です。

簡単に実装できないかなぁとネットを探しまわったんですが、思い通りの情報が得られず結局自分で実装したので共有します。

少し複雑な内容になっていますが、初心者の人にも出来るだけ分かりやすく解説したいと思います。

意外と重宝しそうなツールほどネットに転がっていなかったりしますよね。
この記事が皆さんの助けになれば幸いです。

**修正20/2/16：**コードの見直しにより記事に大幅な修正を入れました。しかし修正前のコードでも問題なく実行することはできます。

# 目次
- 環境
- 実行例
- 考え方
    - 必要な値を取得
    - 適切なヘッダーの状態を判断する
    - ヘッダーの状態を元にヘッダーに対してスクロール処理をする
    - 必要な値を更新する
- ソースコード
- おわりに

# 環境
- Xcode 11.2.1
- Swift5

# 実行例

![testheader.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/cb238549-b40a-6e26-0a57-b74bfed31999.gif)


# 考え方
はじめにTableViewのスクロールに連動して動くツールバーみたいなものをこの記事では**ヘッダー**として呼びます。

基本的な考え方としては、TableViewにおけるスクロールの**向き**と**量**に基づいてヘッダーの座標を動かすことになります。

実際に行われる処理は以下の表のような流れになります。

|順番|処理内容|
|:--|:--|
|1|必要な値を取得する|
|2|1で得た値をもとに適切なヘッダーの状態を判断する|
|3|2で得たヘッダーの状態をもとにヘッダーに対してスクロール処理を実行する|
|4|必要な値を更新する|

この流れに沿って説明したいと思います。

また、今回説明するビュー(初期状態)を示した図を下に載せておきます。
ヘッダーはヘッダービューの一部であることが分かりますね。
余談ですが、ヘッダーの上に余白を大きく持たせているのはローディングの際にアニメーションを見せるためです。
(要望があればこちらも記事にします。)

**追記20/2/12：**記事にしました[[Swift5]ビリビリ動画にあるようなリフレッシュする際にGIFアニメーションを見せるビュー](https://qiita.com/Hajime-Ito/items/172f50a43dc67e75ff2a)

ScrollViewの上の余白をヘッダーの高さ分設けていることに注意してください。

![ThirdDiagram (11).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/d04553d5-504f-f198-3cf5-0bc604b8b60f.png)

## 1.必要な値を取得する

以下が実際に呼び出されるイベント関数となっています。
ここで私たちがこれから定義していく関数を呼び出すことになります。

~~~Swift
func scrollViewDidScroll(_ scrollView: UIScrollView) {
        //MARK: --headerView
        var sub: CGFloat = 0
        // MARK: -- 以下のinitialHeaderFrameに適切な値を入力するだけで、このプログラムは動作します。
        let initialHeaderFrame: [String:CGFloat] = ["Y": -230, "height": 30]
        let headerFrame: [String:CGFloat] = ["minY": initialHeaderFrame["Y"]! , "maxY": initialHeaderFrame["Y"]! + initialHeaderFrame["height"]!, "height": initialHeaderFrame["height"]!]
        
        sub += (lastContentOffset - scrollView.contentOffset.y)*0.1
        
        let status = self.getHeaderViewStatus(sub, scrollView.contentOffset.y, myHeaderView.frame, lastContentOffset, headerFrame)
        self.scrolling(status: status)
        lastContentOffset = scrollView.contentOffset.y
}
~~~

必要な値について述べたいと思います。

- スクロールビューにおける現在のy座標
(`scrollView.contentOffset.y`)
- スクロールビューにおける過去(更新前)のy座標
(`lastContentOffset`)
- ヘッダービューのx, y, width, height情報
(`myHeaderView.frame`)
- 過去と現在のスクロールビューにおけるy座標の差(スクロール量)
(`sub: CGFloat`)
- ヘッダービューの初期座標と高さ
(`initialHeaderFrame: [String:CGFloat]`)

注意点としては、**Swiftは左上が原点となっている座標系**であることです。
コード上では**過去と現在のスクロールビューにおけるy座標の差**は
(`lastContentOffset - scrollView.contentOffset.y`)
のように計算されます。

また、よく使う値として**ヘッダービューのデフォルトy座標をヘッダーの高さ分下げたy座標**があります。
そちらも、この関数内であらかじめ用意しておくことになります。

## 2.得た値をもとに適切なヘッダーの状態を判断する
まずヘッダーの状態について説明します。
今回はプログラムを分かりやすくするために5つの状態を用意しました。

|状態|説明|
|:--|:--|
|start|初期状態、ビューのトップにヘッダーが存在している状態|
|move_up|下向きにスクロールしていて、ヘッダーが全て見えきっていない状態|
|stop_up|下向きにスクロールしていて、ヘッダーが全て見えきっている状態|
|move_down|上向きにスクロールしていて、ヘッダーが全て隠れきっていない状態|
|stop_down|上向きにスクロールしていて、ヘッダーが全て隠れきっている状態|
これらは全て(関数)列挙型で表現されています。

~~~Swift
    enum headerViewStatus {
        case start(_ initHeaderFrameMinY: CGFloat)
        case move_up(_ sub: CGFloat, _ headerViewFrame: CGRect)
        case stop_up(_ scrollViewY: CGFloat, _ initHeaderFrameMaxY: CGFloat)
        case move_down(_ sub: CGFloat, _ headerViewFrame: CGRect)
        case stop_down(_ scrollViewY: CGFloat, _ initHeaderFrameMinY: CGFloat)
    }
~~~

さて、次にヘッダーの状態を判断する関数(`getHeaderViewStatus`)を見ていきましょう。
上で述べたとおり5つの状態を判断する式を持っています。

5つの状態を判断する条件式をA, B, C, Dとおくと以下のフローチャートのように整理できます。

|条件式|簡易的な意味|
|:--|:--|
|A|初期状態かどうか|
|B|下向きにスクロールをしたかどうか|
|C|ヘッダーが全て表示される位置に移動しているかどうか|
|D|ヘッダーを全て隠れる位置に移動しているかどうか|

![Untitled Diagram (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/771260c4-0e12-6078-62a1-b0a3d73251b9.png)

以下はヘッダーの状態を判断する関数のコードです。

~~~Swift
    private func getHeaderViewStatus(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ lastScrollViewY: CGFloat, _ initHeaderFrame: [String:CGFloat]) -> headerViewStatus {
        if (scrollViewY <= (0 - initHeaderFrame["height"]!)) {
            return headerViewStatus.start(initHeaderFrame["minY"]!)
        } else if (lastScrollViewY > scrollViewY) {
            if(headerViewFrame.origin.y >= scrollViewY + initHeaderFrame["maxY"]!) { return headerViewStatus.stop_up(scrollViewY, initHeaderFrame["maxY"]!)}
            else { return headerViewStatus.move_up(sub, headerViewFrame)}
        } else {
            if(headerViewFrame.origin.y <= scrollViewY + initHeaderFrame["minY"]!) { return headerViewStatus.stop_down(scrollViewY, initHeaderFrame["minY"]!)}
            else { return headerViewStatus.move_down(sub, headerViewFrame)}
        }
    }
~~~

---

**条件A**は以下のコードで表現されています。

~~~Swift
if (scrollViewY <= (0 - initHeaderFrame["height"]!)) 
~~~

スクロールビューのy座標が、ヘッダーの高さ分だけ小さい位置に配置されているかについての条件式です。
初期状態ではスクロールビューは**ヘッダーの高さ分だけ上**に配置されていますよね。
なので初期状態、もしくはそれより上に画面を進めた(下向きへスクロールした)場合では***True***となり、
それ以外では***False***となり次の条件式に進んでいきます。

参考にビューの初期状態を再掲します。

![ThirdDiagram (11).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/d04553d5-504f-f198-3cf5-0bc604b8b60f.png)

---

**条件B**は以下のコードで表現されています。

~~~Swift
if(lastScrollViewY > scrollViewY)
~~~

前回スクロールした際のスクロールビューのy座標の方が現在のy座標よりも下にあるか(大きいか)どうかについての条件式ですね。
この条件式が***True***になるのは、ビューを下にスクロールして画面を上に進めた場合でしょう。

つまりこの条件式は**ビューを下にスクロールしたかどうか**についての条件式となります。

例として上にスクロールした場合のビューの様子を以下の図で示しました。

![Untitled Diagram (7).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/a7113370-4c5e-65a8-b339-d96dfabcb6be.png)

---

**条件C**は以下のコードで表現されています。

~~~Swift
if(headerViewFrame.origin.y >= scrollViewY + initHeaderFrame["maxY"]!)
~~~

ビューを下にスクロールした場合この条件Cを実行することになります。

ビューを下にスクロールした場合はヘッダーを出す必要があります。
しかし、出し過ぎる(ヘッダーを下げ過ぎる)のも問題ですよね。
画面の一番上にくっついているように表示したいです。

**条件Cは画面の一番上にくっついているかどうかについて判断します。**
画面のトップの位置、それより下にあれば***True***で、
まだ完全に表示されきっていないなら***False***となります。

以下の図は条件式が***True***(等しくなる)場合のビューの状態を示しています。

![Untitled Diagram (8).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/15c22a74-531b-ff5d-31ec-afa0f657ea28.png)

まずは変数の説明をしましょう。
`headerViewFrame.origin.y`は**ヘッダービューの現在の座標**を示しています。
`scrollViewY`は**スクロールビューの現在の座標**を示しています。
`initHeaderFrame["maxY"]`は**ヘッダービューのデフォルト座標をヘッダーの高さ分下にずらした座標**を示しています。

ここで、図における
`header_height`を`30`、
`HeaderView_height`を`230`、
画面の座標を`(0, 100)`とすると

<font color="Red">赤点の座標(`initHeaderFrame["maxY"]`)</font>＝(0, -200)
<font color="Blue">青点の座標(`scrollViewY`)</font>＝(0, 100)
<font color="Orange">橙点の座標(`headerViewFrame.origin.y`)</font>＝(0, -100)
となります。

そして条件式は、`-100 >= 100 + (-200)`より***True***となります。

これは、**(青点＋赤点)のy座標**が**橙点のy座標**と**同じ位置、もしくはそれ以上**ならば**ヘッダーは全て表示されていること**が保証されていることに由来します。
(皆さんも頭の中で青点を-200分動かしてみてください)

以下の図は上記の考え方を分かりやすく示したものです。(左の状態で***True***、右の状態で***False***)

![Untitled Diagram (12).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/4d1c542b-814e-287f-eb13-341f5d15d104.png)

青点が二つありますが、上の方にある青点は`-200`された位置にあるものです。(**仮想青点**と呼びましょうか)
画面をスクロールしてヘッダーを仕舞う(図の右の状態に遷移)と橙点が移動したのが分かりますね。

この**橙点**が**仮想青点よりも上**にあると***False***で、**橙点が仮想青点と重なる(もしくは下に位置する)**と***True***となります。

この式によって、ヘッダーが画面に全て表示されているかどうか判断できるのです。

では最後に条件Dについても見ていきましょう。ロジックは全く同じです。

---

**条件D**は以下のコードで表現されています。

~~~Swift
if(headerViewFrame.origin.y <= scrollViewY + initHeaderFrame["minY"]!)
~~~

こちらも先ほど説明した条件Cにとても似ていますね。

不等号が逆になったのと、スクロールビューのy座標に足す値が**ヘッダービューのデフォルトy座標**に変わりました。
(条件式が等しい時に***True***になるよう調節するためです)

こちらもビューがどのような状態の時に条件式が等しくなるのか示しておきましょう。
左の図は***True***の場合で、右の図は***False***の場合です。

それぞれの点は、
<font color="Red">赤点の座標(`initHeaderFrame["minY"]`)</font>
<font color="Blue">青点の座標(`scrollViewY`)</font>
<font color="Orange">橙点の座標(`headerViewFrame.origin.y`)</font>
を示しています。

![Untitled Diagram (13).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/42a71057-cb43-0e7d-3de3-90a8f32f077c.png)

この**橙点**が**仮想青点よりも下**にあると***False***で、**橙点が仮想青点と重なる(もしくは上に位置する)**と***True***となります。

つまり、ヘッダーが全て画面の上に隠れたかについての条件式です。

---
以上で全ての適切なヘッダーの状態を判断する条件式の説明を終えました。
こうして見ると、意外と複雑に考える必要があったんだという事が分かりますね。

さて、次はヘッダーの状態において適切な処理をしていくことになります。

## 3.ヘッダーの状態をもとにヘッダーに対してスクロール処理を実行する
まずは、ヘッダーの状態と、その際の適切なスクロール処理について表にまとめました。

|状態|適切な処理|
|:--|:--|
|start|画面の一番上にくっついているように固定する|
|move_up|ヘッダーを下げるように動かす|
|stop_up|ヘッダーを画面の一番上にくっついているように固定する|
|move_down|ヘッダーを上げるように動かす|
|stop_down|ヘッダーが画面から見えないギリギリの位置に固定する|

つまりヘッダーは以下の図の矢印の間を行ったり来たりするように動きます。

![ThirdDiagram (12).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/5b228fbd-fc97-50cb-1d0a-37edd49cc5b2.png)

**注意点としては、ヘッダーではなくヘッダービューに対しての操作を行なっているという事です。**

ヘッダーはヘッダービューの一部ですからね。

---

**状態がstart**の場合は処理は以下のように記述されています。

~~~Swift
        func start(_ initHeaderFrameMaxY: CGFloat) {
            print("Start")
            myHeaderView.frame.origin.y = initHeaderFrameMaxY
        }
~~~

`initHeaderFrame_maxY`はヘッダーのデフォルトy座標のことなので、ヘッダービューには初期状態がセットされます。

---

**状態がmove_up**の場合は処理は以下のように記述されています。

~~~Swift
        func move_up(_ sub: CGFloat,_ headerViewFrame: CGRect) {
            print("Move_up")
            myHeaderView.frame.origin.y = (headerViewFrame.origin.y + sub)
        }
~~~

`sub`という変数が出てきましたね。これは **スクロール量**です。
これは**1.必要な値を取得する**で述べた通り、`subSet`のキー`"up"`の値で、以下の計算式で求められます。

(`lastContentOffset - scrollView.contentOffset.y`)

上向きにスクロールしているので、`lastContentOffset > scrollView.contentOffset.y`です。
したがって、`sub`の値は**正**になることが分かりますね。

また、`headerViewFrame.origin.y`は現在のヘッダービューにおけるy座標なので、
(`headerViewFrame.origin.y + sub`)
は現在のヘッダービューにおけるy座標をスクロール量分大きくしている(下げている)ことが分かります。
![ThirdDiagram (13).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/72e7e868-f8ab-20f9-2189-751f3a39df0c.png)

---

**状態がstop_up**の場合は処理は以下のように記述されています。

~~~Swift
        func stop_up(_ scrollViewY: CGFloat, _ initHeaderFrameMaxY: CGFloat) {
            print("Stop_up")
            myHeaderView.frame.origin.y = (scrollViewY + initHeaderFrameMaxY)
        }
~~~

これはスクロールビューのy座標(`scrollViewY`)に常にヘッダーのデフォルトy座標＋ヘッダーの高さ(`initHeaderFrameMaxY`)を足していますね。

条件Cの処理を思い出してください。ロジックは全く同じです。
以下の図に青点が二つありますが、上の方が`(scrollViewY)＋(initHeaderFrameMaxY)`を表している仮想青点です。

仮想青点にヘッダービューのy座標を合わせる処理ということですね。

また、この処理が実行されるのは**橙点と青点が重なる場合**なので、**常に図の左側を表示し続ける**ことになります。
つまり**ヘッダーを常に表示されるように固定している**という事なんですね。
![Untitled Diagram (12).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/4d1c542b-814e-287f-eb13-341f5d15d104.png)


---

**状態がmove_down**の場合は処理は以下のように記述されています。

~~~Swift
        func move_down(_ sub: CGFloat, _ headerViewFrame: CGRect) {
            print("Move_down")
            myHeaderView.frame.origin.y = (headerViewFrame.origin.y + sub)
        }
~~~

こちらも**move_up**の場合の処理と同じです。
違いは`sub`が負の値になっている事ですね。スクロールの向きに由来するものです。

---

**状態がstop_down**の場合は処理は以下のように記述されています。

~~~Swift
        func stop_down(_ scrollViewY: CGFloat, _ initHeaderFrameMinY: CGFloat) {
            print("Stop_down")
            myHeaderView.frame.origin.y = (scrollViewY + initHeaderFrameMinY)
        }
~~~

こちらも**stop_up**の場合の処理と同じように、この処理にたどり着くまでの条件をみていくと分かります。

**stop_down**の場合は**条件D**を参照してください。

この処理は**ヘッダーを常に見えないギリギリの位置に固定している**ことになります。

---

## 必要な値を更新する

最後はプログラムに必要な値を更新する処理です。

と言っても、明示的に行うのは以下のたった一行のコードです。

~~~Swift
lastContentOffset = scrollView.contentOffset.y
~~~

現在のスクロールビューのy座標を次のサイクルで使えるように、更新しているだけですね。

さて、ここまでで論理的なプログラムの説明を終わります。
お疲れ様でした。

# ソースコード

とりあえず、ポイントとなるプログラムは以下に載せておきます。
**Githubにサンプルを上げておくので、そちらも参考にされてみてください。**

https://github.com/Hajime-Ito/HeaderTestSwift

~~~Swift

 var lastContentOffset: CGFloat = 0

  enum headerViewStatus {
        case start(_ initHeaderFrameMinY: CGFloat)
        case move_up(_ sub: CGFloat, _ headerViewFrame: CGRect)
        case stop_up(_ scrollViewY: CGFloat, _ initHeaderFrameMaxY: CGFloat)
        case move_down(_ sub: CGFloat, _ headerViewFrame: CGRect)
        case stop_down(_ scrollViewY: CGFloat, _ initHeaderFrameMinY: CGFloat)
    }
    
    private func getHeaderViewStatus(_ sub: CGFloat, _ scrollViewY: CGFloat, _ headerViewFrame: CGRect, _ lastScrollViewY: CGFloat, _ initHeaderFrame: [String:CGFloat]) -> headerViewStatus {
        if (scrollViewY <= (0 - initHeaderFrame["height"]!)) {
            return headerViewStatus.start(initHeaderFrame["minY"]!)
        } else if (lastScrollViewY > scrollViewY) {
            if(headerViewFrame.origin.y >= scrollViewY + initHeaderFrame["maxY"]!) { return headerViewStatus.stop_up(scrollViewY, initHeaderFrame["maxY"]!)}
            else { return headerViewStatus.move_up(sub, headerViewFrame)}
        } else {
            if(headerViewFrame.origin.y <= scrollViewY + initHeaderFrame["minY"]!) { return headerViewStatus.stop_down(scrollViewY, initHeaderFrame["minY"]!)}
            else { return headerViewStatus.move_down(sub, headerViewFrame)}
        }
    }
    
    
    private func scrolling(status: headerViewStatus) {
        
        func start(_ initHeaderFrameMaxY: CGFloat) {
            print("Start")
            myHeaderView.frame.origin.y = initHeaderFrameMaxY
        }
        
        func move_up(_ sub: CGFloat,_ headerViewFrame: CGRect) {
            print("Move_up")
            myHeaderView.frame.origin.y = (headerViewFrame.origin.y + sub)
        }
        
        func stop_up(_ scrollViewY: CGFloat, _ initHeaderFrameMaxY: CGFloat) {
            print("Stop_up")
            myHeaderView.frame.origin.y = (scrollViewY + initHeaderFrameMaxY)
        }
        
        func move_down(_ sub: CGFloat, _ headerViewFrame: CGRect) {
            print("Move_down")
            myHeaderView.frame.origin.y = (headerViewFrame.origin.y + sub)
        }
        
        func stop_down(_ scrollViewY: CGFloat, _ initHeaderFrameMinY: CGFloat) {
            print("Stop_down")
            myHeaderView.frame.origin.y = (scrollViewY + initHeaderFrameMinY)
        }
        
        switch status {
        case let .start(initHeaderFrameMinY): start(initHeaderFrameMinY)
        case let .move_up(sub, headerViewFrame): move_up(sub, headerViewFrame)
        case let .stop_up(scrollViewY, initHeaderFrameMaxY): stop_up(scrollViewY, initHeaderFrameMaxY)
        case let .move_down(sub, headerViewFrame): move_down(sub, headerViewFrame)
        case let .stop_down(scrollViewY, initHeaderFrameMinY): stop_down(scrollViewY, initHeaderFrameMinY)
        }
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        //MARK: --headerView
        var sub: CGFloat = 0
        // MARK: -- 以下のinitialHeaderFrameに適切な値を入力するだけで、このプログラムは動作します。
        let initialHeaderFrame: [String:CGFloat] = ["Y": -230, "height": 30]
        let headerFrame: [String:CGFloat] = ["minY": initialHeaderFrame["Y"]! , "maxY": initialHeaderFrame["Y"]! + initialHeaderFrame["height"]!, "height": initialHeaderFrame["height"]!]
        
        sub += (lastContentOffset - scrollView.contentOffset.y)*0.1
        
        let status = self.getHeaderViewStatus(sub, scrollView.contentOffset.y, myHeaderView.frame, lastContentOffset, headerFrame)
        self.scrolling(status: status)
        lastContentOffset = scrollView.contentOffset.y
    }
~~~

# おわりに

以上で終わりとなります。

理解するのに以外と頭をひねる必要があったかも知れませんね。
スクロール方向と画面の進む向きが逆になっていたりするからでしょうか?

また機会があれば、他に役に立ちそうなことも記事にしていきたいと思います。