[Swift5]ビリビリ動画にあるようなスクロールできてタッチできるメニューバー

# はじめに
筆者は中国大手の動画共有サイト「bilibli」のUIを気に入っているのですが、bilibiliで実際に取り入れられている**「スクロールできてタッチできるメニューバー」**を実装したので共有したいと思います。

スクロール操作だけのメニューバーについての記事はよく見かけますが、**タッチ操作ができるようなもの**はあまり見かけないので、そのような実装をしてみたい人にとっては役に立つ記事になるのではないでしょうか。
(タッチ操作を可能にすることで、**スクロールメニューバーの選択バーのアニメーション**や**スクロールビューのタッチイベント**を可能にしたりなど難易度が少し上がるからでしょうか?)

# 目次
- 環境
- 実行例
- 考え方
    - 画面構成
    - 実装について
        - UIScrollViewを作成する
        - UIScrollViewにUILabelを貼り付ける
        - UIScrollViewのタッチイベントを受け取れるようにしタッチした際の処理をする
        - UIScrollViewのスクロールイベント関数内でUIScrollViewBarについて処理をする
- ソースコード
- 終わりに

# 環境
- Xcode 11.2.1
- Swift5

# 実行例
![samplescrollview.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/7196ad13-34e7-da21-e77f-79193e42592b.gif)


# 考え方

ロジックはそこまで必要になりませんが知識の幅は必要になるかなと思います。


## 画面構成
画面の構成は以下のようになります。

![Diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/508045c3-16bd-a1af-c33f-417fd75276a4.png)

`UIView(黄)`に`UIScrollView`と`UIImageView`と`UIView(紫)`が貼り付けられていて、`UIScrollView`に`UILabel`が貼り付けられています。

`UILabel`がメニューバーのメニューとなっていて、`UIView(紫)`がスクロールメニューバーの選択バーとなっています。

## 実装について

今回ちょっとした**コツ**がいるのは以下の3つになります。

|ポイント|
|:--|
|スクロールビューをタッチできるようにすること。|
|選択しているメニューの下に表示するバーの実装。|
|二つのスクロールビュー操作をひとつのスクロールイベント関数で処理わけする。|

また、全体的な**処理の流れ**は以下になります。

|順番|処理内容|
|:--|:--|
|1|UIScrollView、UIView(スクロールビューのバー)を作成する|
|2|UIScrollViewにUILabelを貼り付ける|
|3|UIScrollViewのタッチイベントを受け取れるようにしタッチした際の処理をする|
|4|UIScrollViewのスクロールイベント関数内でUIScrollViewBarについて処理をする|

### 1.UIScrollViewを作成する
まずはUIScrollViewを作成しましょう。

~~~Swift
private func createHeaderView() {
        let displayWidth: CGFloat! = self.view.frame.width
        // 上に余裕を持たせている（後々アニメーションなど追加するため）
        myHeaderView = UIView(frame: CGRect(x: 0, y: -230, width: displayWidth, height: 230))
        myHeaderView.alpha = 1
        myHeaderView.backgroundColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 1)
        myTableView.addSubview(myHeaderView)
        scrollView = UIScrollView(frame: CGRect(x: 0, y: 200, width: displayWidth, height: 30))
        scrollView.bounces = false
        scrollView.alwaysBounceHorizontal = false
        scrollView.alwaysBounceVertical = false
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.backgroundColor = UIColor(red: 238/255, green: 142/255, blue: 160/255, alpha: 1)
        makeScrollMenu(scrollView: &scrollView)
        myHeaderView.addSubview(scrollView)
        scrollViewBar = UIView(frame: CGRect(x: 0, y: 225, width: 70, height: 5))
        scrollViewBar.backgroundColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 0.8)
        myHeaderView.addSubview(scrollViewBar)
        let image = UIImageView(frame: CGRect(x: (displayWidth-100)/2, y: 100, width: 100, height: 100))
        if bilibili {
            image.image = UIImage(named: "bili2")
        } else {
            image.image = UIImage(named: "bili")
        }
        myHeaderView.addSubview(image)
    }
~~~

`scrollView`ではBounceしないように設定してます。
また、スクロールバーも非表示の設定にしてます。
`makeScrollMenu(scrollView: &scrollView)`でラベルを貼り付けていきます。

スクロールビューの選択バーもここで作成しています。(`ScrollViewBar: UIView`)

**動くヘッダービュー**に関してはこちらの記事では説明しません。興味のある人は[こちら](https://qiita.com/Hajime-Ito/items/2ef143b02ba65d43001d)をご覧ください。

### 2.UIScrollViewにUILabelを貼り付ける
UIScrollViewにUILabelを貼り付けるのは以下のように表現されます。

~~~Swift
    func makeScrollMenu(scrollView: inout(UIScrollView)) {
        let menuLabelWidth:CGFloat = 70
        let titles = Data.TitleMenu
        let menuLabelHeight:CGFloat = scrollView.frame.height
        var X: CGFloat = 0
        var count = 1
        for title in titles {
            let scrollViewLabel = UILabel()
            scrollViewLabel.textAlignment = .center
            scrollViewLabel.frame = CGRect(x:X, y:0, width:menuLabelWidth, height:menuLabelHeight)
            scrollViewLabel.text = title
            scrollViewLabel.isUserInteractionEnabled = true
            scrollViewLabel.tag = count
            scrollView.addSubview(scrollViewLabel)
            X += menuLabelWidth
            count += 1
            scrollViewLabelArray.append(scrollViewLabel)
        }
        
        changeColorScrollViewLabel(tag: 1)
        
        scrollView.contentSize = CGSize(width:X, height:menuLabelHeight)
    }
~~~

`UILabel`の高さは`UIScrollView`と**同じ高さ**に、横幅は好きな大きさに設定します。
ラベル同士を被らないように並べていくのは横幅分ずらして設置していくだけです。
また、タッチした際に識別できるように`tag`を設定していきます。
そしてタッチできるように`isUserInteractionEnabled`を`true`にします。

`changeColorScrollViewLabel(tag: Int)`で選択されているラベルの色を変えます。
`scrollViewLabelArray: [UILabel]`に作成したラベルを追加していますが、それは`changeColorScrollViewLabel()`で使うためです。

### 3.UIScrollViewのタッチイベントを受け取れるようにしタッチした際の処理をする
`UIScrollView`は**そのままではタッチイベントを受け取れません**。
なので`extension`でタッチイベントを受け取れるように以下のように記述します。

~~~Swift
    override open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.next?.touchesBegan(touches, with: event)
    }
~~~

これでスクロールビューからタッチイベントを取得できるようになったので、次はタッチイベント関数での処理を記述していきましょう。

~~~Swift
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.next?.touchesBegan(touches, with: event)
        for touch: AnyObject in touches {
            let t: UITouch = touch as! UITouch
            guard t.view is UILabel else {
                return
            }
            
            switch t.view!.tag {
            case 1:
                print(1)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 1)
                
                Data.setIndex(v: 0)
                myTableView.reloadData()
            case 2:
                print(2)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 2)
                
                Data.setIndex(v: 1)
                myTableView.reloadData()
            case 3:
                print(3)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 3)
                
                Data.setIndex(v: 2)
                myTableView.reloadData()
            case 4:
                print(4)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 4)
                
                Data.setIndex(v: 3)
                myTableView.reloadData()
            case 5:
                print(5)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 5)
                
                Data.setIndex(v: 4)
                myTableView.reloadData()
            case 6:
                print(6)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 6)
                
                Data.setIndex(v: 5)
                myTableView.reloadData()
            default:
                break
            }
        }
    }
~~~

はじめに`guard t.view is UILabel else {return}`で`UILabel`以外のタッチに対しては**無視**するようにしています。
状況によって適切な条件を用意してあげてください。

それ以降は`Switch文`でラベルの`tag`で場合分けしています。
では、ラベルをタッチした際に行う処理を見ていきましょう。
はじめに`UIView.animate()`で`scrollViewBar`に対してアニメーション処理をしているのがわかりますね。
これは**選択バーの位置を選択されたメニューの位置に動かす**ような操作をしています。

~~~Swift
self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
~~~
上のコードのように選択されたラベルのx座標からスクロールビューの現在のx座標を引いた値をスクロールビューバーのx座標にしています。
(これによりスクロールビューがどのような位置にあっても`UILabel`にくっついたように表示することができます)

これは以下の図を見ると理解しやすいと思います。
![Untitled Diagram (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/531a74fe-b4a5-9978-03af-bd0ec117e0f1.png)


次に選択されたメニューの色を変えます。
コード内では`changeColorScrollViewLabel(tag: Int)`を呼び出して色を変えています。
この関数は以下のように表現されています。

~~~Swift
    private func changeColorScrollViewLabel(tag: Int) {
        for label in scrollViewLabelArray {
            if label.tag == tag {
                label.textColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 1)
            } else {
                label.textColor = .white
            }
        }
    }
~~~

最後に`TableView`で使用するデータを切り替える処理を行います。
`Data.setIndex(v: Int)`でデータを切り替え、`myTableView.reloadData()`で更新していますね。
`Data`は構造体`data`のインスタンスです。
構造体`data`は以下のように表現されています。

~~~Swift


    struct data {
        let TitleMenu = ["アニメ","ドラマ","映画","ニュース","漫画","生放送"]
        var index = 0
        
        mutating func setIndex(v: Int) {
            index = v
        }
        
        func getTitle() -> String {
            return TitleMenu[index]
        }
    }
~~~

### 4.UIScrollViewのスクロールイベント関数内でUIScrollViewBarについて処理をする
スクロールイベント関数は以下のように表現されています。

~~~Swift
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        switch scrollView {
        case self.scrollView:
            scrollingViewBar(scrollView: scrollView)
        case self.myTableView:
            scrollingMyTableView(scrollView: scrollView)
        default:
            break
        }
    }
~~~

今回このスクロールイベント関数を呼び出される`ScrollView`は**2つ**あります。
`scrollView`と`myTableView`ですね。
なのでスクロールイベント関数がどっちの`ScrollView`によって呼び出されたものなのかを判断して処理を分けてあげないと**不具合**が起きてしまうことがあります。(実は筆者はここでハマりました。笑)

では`scrollView`がスクロールされた際に呼び出されている関数`scrollingViewBar`を見ていきましょう。

~~~Swift
    private func scrollingViewBar(scrollView: UIScrollView) {
        scrollViewBar.frame.origin.x += (lastContentOffsetX - scrollView.contentOffset.x)
        lastContentOffsetX = scrollView.contentOffset.x
    }
~~~

`lastContentOffsetX`は前に呼び出された際のスクロールビューの位置を保存しています。
`lastContentOffsetX - scrollView.contentOffset.x`でスクロール量を求めることができます。
`scrollViewBar`をスクロールした分だけ横にずらせればいいので、`scrollViewBar`のx座標にスクロール量を足すだけですね。

参考までにそれを説明した図を再掲しておきます。
![Untitled Diagram (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/576493/7a321de7-01ab-0c21-b05d-6e5bc98900e4.png)

# ソースコード

今まで説明したコードを抜粋して載せます。
Githubにサンプルを載せておくので参考にしてみてください。
https://github.com/Hajime-Ito/SampleScrollMenuBar

~~~Swift

var scrollView: UIScrollView!
var scrollViewBar: UIView!
var myHeaderView: UIView!
var lastContentOffset: CGFloat = 0
var lastContentOffsetX: CGFloat = 0
var scrollViewLabelArray: [UILabel] = []

private func createHeaderView() {
        let displayWidth: CGFloat! = self.view.frame.width
        // 上に余裕を持たせている（後々アニメーションなど追加するため）
        myHeaderView = UIView(frame: CGRect(x: 0, y: -230, width: displayWidth, height: 230))
        myHeaderView.alpha = 1
        myHeaderView.backgroundColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 1)
        myTableView.addSubview(myHeaderView)
        scrollView = UIScrollView(frame: CGRect(x: 0, y: 200, width: displayWidth, height: 30))
        scrollView.bounces = false
        scrollView.alwaysBounceHorizontal = false
        scrollView.alwaysBounceVertical = false
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.backgroundColor = UIColor(red: 238/255, green: 142/255, blue: 160/255, alpha: 1)
        makeScrollMenu(scrollView: &scrollView)
        myHeaderView.addSubview(scrollView)
        scrollViewBar = UIView(frame: CGRect(x: 0, y: 225, width: 70, height: 5))
        scrollViewBar.backgroundColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 0.8)
        myHeaderView.addSubview(scrollViewBar)
        let image = UIImageView(frame: CGRect(x: (displayWidth-100)/2, y: 100, width: 100, height: 100))
        if bilibili {
            image.image = UIImage(named: "bili2")
        } else {
            image.image = UIImage(named: "bili")
        }
        myHeaderView.addSubview(image)
    }
    
    private func updateHeaderView() {
        let displayWidth: CGFloat! = self.view.frame.width
        self.myHeaderView.subviews[2].removeFromSuperview()
        let image = UIImageView(frame: CGRect(x: (displayWidth-100)/2, y: 100, width: 100, height: 100))
        if bilibili {
            image.image = UIImage(named: "bili2")
        } else {
            image.image = UIImage(named: "bili")
        }
        myHeaderView.addSubview(image)
    }
    
    func addHeaderViewGif() {
        let displayWidth: CGFloat! = self.view.frame.width
        let image = UIImageView(frame: CGRect(x: (displayWidth-100)/2, y: 100, width: 100, height: 100))
        if bilibili {
            image.loadGif(name: "bili2")
        } else {
            image.loadGif(name: "bili")
        }
        myHeaderView.addSubview(image)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            self.myHeaderView.subviews[2].removeFromSuperview()
        }
    }
    
    func makeScrollMenu(scrollView: inout(UIScrollView)) {
        let menuLabelWidth:CGFloat = 70
        let titles = Data.TitleMenu
        let menuLabelHeight:CGFloat = scrollView.frame.height
        var X: CGFloat = 0
        var count = 1
        for title in titles {
            let scrollViewLabel = UILabel()
            scrollViewLabel.textAlignment = .center
            scrollViewLabel.frame = CGRect(x:X, y:0, width:menuLabelWidth, height:menuLabelHeight)
            scrollViewLabel.text = title
            scrollViewLabel.isUserInteractionEnabled = true
            scrollViewLabel.tag = count
            scrollView.addSubview(scrollViewLabel)
            X += menuLabelWidth
            count += 1
            scrollViewLabelArray.append(scrollViewLabel)
        }
        
        changeColorScrollViewLabel(tag: 1)
        
        scrollView.contentSize = CGSize(width:X, height:menuLabelHeight)
    }
    
    private func changeColorScrollViewLabel(tag: Int) {
        for label in scrollViewLabelArray {
            if label.tag == tag {
                label.textColor = UIColor(red: 142/255, green: 237/255, blue: 220/255, alpha: 1)
            } else {
                label.textColor = .white
            }
        }
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.next?.touchesBegan(touches, with: event)
        print("touch")
        for touch: AnyObject in touches {
            let t: UITouch = touch as! UITouch
            guard t.view is UILabel else {
                return
            }
            switch t.view!.tag {
            case 1:
                print(1)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 1)
                
                Data.setIndex(v: 0)
                myTableView.reloadData()
            case 2:
                print(2)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 2)
                
                Data.setIndex(v: 1)
                myTableView.reloadData()
            case 3:
                print(3)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 3)
                
                Data.setIndex(v: 2)
                myTableView.reloadData()
            case 4:
                print(4)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)

                changeColorScrollViewLabel(tag: 4)
                
                Data.setIndex(v: 3)
                myTableView.reloadData()
            case 5:
                print(5)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 5)
                
                Data.setIndex(v: 4)
                myTableView.reloadData()
            case 6:
                print(6)
                UIView.animate(withDuration: 0.3, delay: 0.0, options: [],animations: {
                    self.scrollViewBar.frame.origin.x = t.view!.frame.origin.x - self.scrollView.contentOffset.x
                }, completion: nil)
                
                changeColorScrollViewLabel(tag: 6)
                
                Data.setIndex(v: 5)
                myTableView.reloadData()
            default:
                break
            }
        }
    }
    
}

    private func scrollingViewBar(scrollView: UIScrollView) {
        scrollViewBar.frame.origin.x += (lastContentOffsetX - scrollView.contentOffset.x)
        lastContentOffsetX = scrollView.contentOffset.x
    }


    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        switch scrollView {
        case self.scrollView:
            scrollingViewBar(scrollView: scrollView)
        case self.myTableView:
            scrollingMyTableView(scrollView: scrollView)
        default:
            break
        }
    }

//MARK: --

extension UIScrollView {
    override open func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.next?.touchesBegan(touches, with: event)
    }
}
~~~

# 終わりに
今回は動かせるヘッダーとしてスクロールできるメニューバーをくっ付けたので、普通にメニューバーを作るよりも難易度が少し高かったのかなと思います。
しかし今回実装したスクロールできてタッチで選択できるメニューバーはよく見かけるくらい使われる実装だと思うので、理解しておいて損はないはずです。

それにしても、見たものを再現するのは暇つぶしには最適だなぁと思いました。