LoginSignup
1
5

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-02-05

はじめに

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

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

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

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

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

目次

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

環境

  • Xcode 11.2.1
  • Swift5

実行例

testheader.gif

考え方

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

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

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

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

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

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

追記20/2/12:記事にしました[Swift5]ビリビリ動画にあるようなリフレッシュする際にGIFアニメーションを見せるビュー

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

ThirdDiagram (11).png

1.必要な値を取得する

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

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 上向きにスクロールしていて、ヘッダーが全て隠れきっている状態

これらは全て(関数)列挙型で表現されています。

    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

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

    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は以下のコードで表現されています。

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

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

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

ThirdDiagram (11).png


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

if(lastScrollViewY > scrollViewY)

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

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

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

Untitled Diagram (7).png


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

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

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

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

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

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

Untitled Diagram (8).png

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

ここで、図における
header_height30
HeaderView_height230
画面の座標を(0, 100)とすると

赤点の座標(initHeaderFrame["maxY"])=(0, -200)
青点の座標(scrollViewY)=(0, 100)
橙点の座標(headerViewFrame.origin.y)=(0, -100)
となります。

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

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

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

Untitled Diagram (12).png

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

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

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

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


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

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

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

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

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

それぞれの点は、
赤点の座標(initHeaderFrame["minY"])
青点の座標(scrollViewY)
橙点の座標(headerViewFrame.origin.y)
を示しています。

Untitled Diagram (13).png

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

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


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

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

3.ヘッダーの状態をもとにヘッダーに対してスクロール処理を実行する

まずは、ヘッダーの状態と、その際の適切なスクロール処理について表にまとめました。

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

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

ThirdDiagram (12).png

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

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


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

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

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


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

        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


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

        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


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

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

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


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

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

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

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

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


必要な値を更新する

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

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

lastContentOffset = scrollView.contentOffset.y

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

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

ソースコード

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


 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
    }

おわりに

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

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

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

1
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
5