スクロールでNavigationBarとTabBarを隠す機能を自作してみる

## はじめに

この記事は[and factory Advent Calendar 2020](https://qiita.com/advent-calendar/2020/andfactory) の6日目の記事です。
昨日は@myoshitaさんの[Navigation Component with Kotlin DSL](https://qiita.com/myoshita/items/9d7f09337dbe2f4e6a68)でした！

今回は、iOSでスクロール時にNavigationBarとTabBarを隠したり表示したりする機能を自作してみましたので
実装の仕方をまとめてみようと思います。

## 環境
開発環境は以下の通りです。

|Tool|Version|
|:--:|:--:|
|Xcode|12.2|
|Swift|5.3.1|

|Target|
|:--:|
|iOS 13 or later|

|Interface|
|:--:|
|Storyboard (UIKit)|

※ 本投稿ではSwiftUI版の内容は含んでおりませんm(_ _)m

## 完成イメージ
スクロール方向に応じて、Barを隠したり表示したりします

|dragging|scrolling|
|:--:|:--:|
|![Dragging.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/8e9fbbc4-e6de-e4b8-d9ef-f74503f7a66c.gif)|![Scrolling.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/cad29cd6-d2cc-1db7-2f3d-c46fc6400e44.gif)|

## 実装の方針について
スクロールに応じてNavigationBarを隠したり表示したりする機能はいくつかOSSがあったのですが、
Barを隠した分TableViewをうまくリサイズできなかったりしたので
勉強も兼ねてスクラッチで実装してみようと思い、やってみました！

※ OSSに関しては、自分が実装の仕方誤っていただけかもなので、本記事では特に扱いませんm(_ _)m

### 実装の流れ
大まかには以下のような実装になります。
スクロール開始時にスクロール位置を保持
↓
スクロール量に応じて、NavigationBarを上に、TabBarを下にそれぞれ移動させる
↓
ScrollView（サンプルではTableView）のTopとBottomのConstraintを更新する

## AutoLayoutの設定
特に意識せずにTableViewのAutoLayoutを設定するとSafeAreaに対して上下の制約を設定してしまいますが
今回は、Superviewに対して制約を設定します。

![top_constraint.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/24c0acff-f930-fbe6-4dca-9f8a7a0a4b43.png)

![bottom_constraint.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/13612c74-e1dd-7f69-e7ff-882bfdbfa241.png)

設定した制約をソースコードに紐付けて、制約を制御することで
NavigationBarとTabBarが表示されている場合は、Barの高さ分だけ制約でスペースを開けて
NavigationBarとTabBarが隠されている場合は、画面の端までTableViewを広げられるようになります。

## 実装
複数の画面で同じ実装をすることになりそうだったので、
HideBarManagerというクラスを作って、ViewControllerから必要なプロパティを渡して
スクロール量の計算や制約の更新をします（\*・ω・\*）

### 初期処理

```swift

final class HideBarManager {

    /// スクロールビューのTopの制約
    ///
    /// - Note: Topの制約は、SafeAreaInsets.TopではなくSuperview.Topに対して設定してください
    private weak var topConstraint: NSLayoutConstraint?
    /// スクロールビューのBottomの制約
    ///
    /// - Note: Bottomの制約は、SafeAreaInsets.BottomではなくSuperview.Bottomに対して設定してください
    private weak var bottomConstraint: NSLayoutConstraint?
    /// スクロールビュー
    private weak var scrollView: UIScrollView!
    /// タブバー
    private weak var tabBar: UITabBar?
    /// ナビゲーションバー
    private weak var navigationBar: UINavigationBar?

    /// タブバーのフレーム
    private var tabBarDefaultFrame: CGRect!
    /// ナビゲーションバーのフレーム
    private var navigationBarDefaultFrame: CGRect!
    /// スクロール開始時の位置
    private var scrollBeginningOffsetY: CGFloat?
    /// タブバーの移動量
    private var tabBarMovingDistance: CGFloat = 0
    /// ナビゲーションバーの移動量
    private var navigationBarMovingDistance: CGFloat = 0
    /// スクロール方向
    private var scrollDirectionY = UIScrollView.ScrollDirectionY.none

    private init() {}

    static func build(topConstraint: NSLayoutConstraint?,
                      bottomConstraint: NSLayoutConstraint?,
                      scrollView: UIScrollView!,
                      tabBar: UITabBar?,
                      navigationBar: UINavigationBar?) -> HideBarManager {
        let hideBarManager = HideBarManager()
        hideBarManager.topConstraint = topConstraint
        hideBarManager.bottomConstraint = bottomConstraint
        hideBarManager.scrollView = scrollView
        hideBarManager.tabBar = tabBar
        hideBarManager.navigationBar = navigationBar
        return hideBarManager
    }
}
```

VC側

```swift
private lazy var hideBarManager = HideBarManager.build(
    topConstraint: self.tableViewTopConstraint,
    bottomConstraint: self.tableViewBottomConstraint,
    scrollView: self.tableView,
    tabBar: self.tabBarController?.tabBar,
    navigationBar: self.navigationController?.navigationBar
)
```

### ライフサイクル処理
ViewControllerの各ライフサイクルのメソッドが呼び出されるタイミングで実行する処理をHideBarManagerに実装していきます。

```swift
extension HideBarManager {

    func viewDidLoad() {
        self.addObservers()
    }

    func viewDidLayoutSubviews() {
        // 初回のみ代入したいためnilチェックもする
        if let tabBar = self.tabBar {
            self.tabBarDefaultFrame = self.tabBarDefaultFrame ?? tabBar.frame
        }
        if let navigationBar = self.navigationBar {
            self.navigationBarDefaultFrame = self.navigationBarDefaultFrame ?? navigationBar.frame
        }
        self.updateConstraints()
    }

    func viewWillDisappear() {
        self.showBarImmediately()
    }
}

extension HideBarManager {
    private func addObservers() {
        // アプリ復帰時にナビゲーションバーを表示しておくために、非アクティブになる直前に表示しておく
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(showBarImmediately),
                                               name: UIApplication.willResignActiveNotification,
                                               object: nil)
    }

    /// タブバーとナビゲーションバーを表示させる
    @objc private func showBarImmediately() {
        self.tabBar?.frame = self.tabBarDefaultFrame
        self.navigationBar?.frame = self.navigationBarDefaultFrame
        self.tabBarMovingDistance = 0
        self.navigationBarMovingDistance = 0
        self.updateConstraints()
    }

    /// TopとBottomの制約を更新する
    private func updateConstraints() {
        guard let superview = self.scrollView.superview else {
            return
        }
        if self.tabBar != nil {
            self.bottomConstraint?.constant = -superview.safeAreaInsets.bottom + self.tabBarMovingDistance
        }
        if self.navigationBar != nil {
            self.topConstraint?.constant = superview.safeAreaInsets.top + self.navigationBarMovingDistance
        }
    }
}
```

VC側

```swift
override func viewDidLoad() {
    super.viewDidLoad()
    self.hideBarManager.viewDidLoad()
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    self.hideBarManager.viewDidLayoutSubviews()
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    self.hideBarManager.viewWillDisappear()
}
```

### UIScrollViewDelegateの処理

UIScrollViewDelegateの各メソッドが呼び出されるタイミングで実行する処理をHideBarManagerに実装していきます。

```swift
extension HideBarManager {
    func scrollViewShouldScrollToTop() {
        self.showBarImmediately()
    }

    /// ドラッグ開始
    func scrollViewWillBeginDragging() {
        if self.scrollBeginningOffsetY == nil {
            self.scrollBeginningOffsetY = self.scrollView.contentOffset.y
            self.tabBarMovingDistance = 0
            self.navigationBarMovingDistance = 0
        }
    }

    /// ドラッグ終了
    func scrollViewDidEndDragging(decelerate: Bool) {
        // 慣性によるスクロールがない場合は、スクロール開始位置とスクロール方向を初期化する
        if !decelerate {
            self.scrollBeginningOffsetY = nil
            self.scrollDirectionY = .none
        }
    }

    /// スクロール中
    func scrollViewDidScroll() {
        guard self.scrollView.isDragging else {
            return
        }
        let scrollViewHeight = scrollView.frame.size.height
        let scrollContentSizeHeight = scrollView.contentSize.height
        let scrollOffset = scrollView.contentOffset.y
        let scrollBeginningOffset = self.scrollBeginningOffsetY ?? 0

        // 一番上に到達した場合は、Barを表示する
        if scrollOffset <= 0 {
            self.showBarImmediately()
            return
        }

        // 一番下に到達した場合は、何もしない
        if scrollContentSizeHeight <= scrollOffset + scrollViewHeight {
            return
        }

        // 下へスクロールしていて、
        // スクロール方向をまだ保持していない or 保持しているスクロール方向がbottomの場合
        // ナビゲーションバーとタブバーを移動させる
        if scrollBeginningOffset < scrollOffset {
            if self.scrollDirectionY == .none || self.scrollDirectionY == .bottom {
                self.scrollDirectionY = .bottom
                self.moveTabBarAndNavigationBar()
                return
            }
        }

        // 上へスクロールしていて、
        // スクロール方向をまだ保持していない or 保持しているスクロール方向がtopの場合
        // ナビゲーションバーとタブバーを移動させる
        if scrollBeginningOffset > scrollOffset {
            if self.scrollDirectionY == .none || self.scrollDirectionY == .top {
                self.scrollDirectionY = .top
                self.moveTabBarAndNavigationBar()
                return
            }
        }
    }

    /// スクロール停止
    func scrollViewDidEndDecelerating() {
        self.scrollBeginningOffsetY = nil
        self.scrollDirectionY = .none
    }
}

extension HideBarManager {
    /// タブバーとナビゲーションバーを移動させる
    private func moveTabBarAndNavigationBar() {
        guard
            let tabBarFrame = self.calcTabBarFrame(),
            let navigationBarFrame = self.calcNavigationBarFrame() else {
            return
        }
        self.tabBar?.frame = tabBarFrame
        self.navigationBar?.frame = navigationBarFrame
        self.tabBarMovingDistance = tabBarFrame.origin.y - self.tabBarDefaultFrame.origin.y
        self.navigationBarMovingDistance = navigationBarFrame.origin.y - self.navigationBarDefaultFrame.origin.y
        self.updateConstraints()
    }

    /// スクロール量からタブバーのoffsetを算出し、タブバーのframeを返す
    private func calcTabBarFrame() -> CGRect? {
        guard let scrollBeginningOffsetY = self.scrollBeginningOffsetY else {
            return nil
        }
        // 移動量
        let movingDistance = self.scrollView.contentOffset.y - scrollBeginningOffsetY

        var newTabBarOriginY = self.tabBarDefaultFrame.origin.y + movingDistance

        // もとの位置より上には移動させない
        let min = self.tabBarDefaultFrame.origin.y
        if newTabBarOriginY < min {
            newTabBarOriginY = min
        }
        // 画面外に出たらそれ以上は下に移動させない
        let max = self.tabBarDefaultFrame.origin.y + (self.tabBarDefaultFrame.size.height * 2)
        if newTabBarOriginY > max {
            newTabBarOriginY = max
        }

        var newTabBarFrame = self.tabBarDefaultFrame!
        newTabBarFrame.origin.y = newTabBarOriginY
        return newTabBarFrame
    }

    /// スクロール量からナビゲーションバーのoffsetを算出し、ナビゲーションバーのframeを返す
    private func calcNavigationBarFrame() -> CGRect? {
        guard let scrollBeginningOffsetY = self.scrollBeginningOffsetY else {
            return nil
        }
        // 移動量
        let movingDistance = self.scrollView.contentOffset.y - scrollBeginningOffsetY

        var newNavigationBarOriginY = self.navigationBarDefaultFrame.origin.y - movingDistance

        let statusBarHeight = self.scrollView.window?.windowScene?.statusBarManager?.statusBarFrame.height ?? 0

        // 画面外に出たらそれ以上は上に移動させない
        let min = self.navigationBarDefaultFrame.origin.y - (self.navigationBarDefaultFrame.size.height * 2) - statusBarHeight
        if newNavigationBarOriginY < min {
            newNavigationBarOriginY = min
        }
        // もとの位置より下には移動させない
        let max = self.navigationBarDefaultFrame.origin.y
        if newNavigationBarOriginY > self.navigationBarDefaultFrame.origin.y {
            newNavigationBarOriginY = max
        }

        var newNavigationBarFrame = self.navigationBarDefaultFrame!
        newNavigationBarFrame.origin.y = newNavigationBarOriginY
        return newNavigationBarFrame
    }
}
```

VC側

```swift
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
    self.hideBarManager.scrollViewShouldScrollToTop()
    return true
}

func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
    self.hideBarManager.scrollViewWillBeginDragging()
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    self.hideBarManager.scrollViewDidEndDragging(decelerate: decelerate)
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    self.hideBarManager.scrollViewDidScroll()
}

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    self.hideBarManager.scrollViewDidEndDecelerating()
}
```

## さいごに
今回実装したソースコード全体は、[GitHub](https://github.com/y-okudera/HideBarManagerDemo)にpushしていますm(_ _)m

実装過程で、iOS13ではちゃんとNavigationBarが隠れたのにiOS14では同じロジックでちゃんと隠れてくれなかったりして
思いの外大変でした。。

現状の実装では、非表示状態から再表示される時など見え方が微妙なところもまだまだあるので
もう少しきれいに表示/非表示切り替えられるようアプデしていきたいなーと思います|ω・\`）

明日の投稿もお楽しみに〜(・ω・)ノ