FolioのイケてるWalkthrough画面のソースを読み解く

Folioというアプリをご存知でしょうか？

> **FOLIO**（**フォリオ**）とは、人工知能やドローンといったテーマを選んで株式投資をすることができる資産運用サービスです。



このアプリ、iOSアプリエンジニアとして有名な方々が開発に携わっています。

そんなFolioで使用されているUIのソースが[こちら](https://github.com/folio-sec/Folio-UI-Collection)で公開されているので、その中のWalkthroughを本記事では見ていきます🙂

## FolioのイケてるWalkthrough 



FolioのWalkthrough画面は初回起動時に表示される⬇️のような画面です🤗

![IMG_5277.TRIM.MOV.gif](https://qiita-image-store.s3.amazonaws.com/0/138735/c13d6616-a7a1-607a-d973-08fa76fc9ec1.gif)



## 画面の構成をみてみる



ストーリーボードの構成は⬇️のようになっています。

<img width="735" alt="スクリーンショット 2018-11-26 1.39.58.png" src="https://qiita-image-store.s3.amazonaws.com/0/138735/0634d008-fea9-9bc0-0183-fce6fec577ec.png">




簡単な図に起こしてみるとこんな感じです。
（下記の図では画面間のマージンは無視しています。マージンが0の箇所も、見やすさのためにあえて空けています。）

<img width="961" alt="スクリーンショット 2018-11-30 1.25.32.png" src="https://qiita-image-store.s3.amazonaws.com/0/138735/3071a741-736b-0efa-69ee-2d8927f1d6bb.png">


3つのスクロールビューがあり、それらが肝です。

- Outer Scroll View：Viewを4つ持ちます。中のViewではページごとの背景色が設定されています
- Inner Scroll View ：Viewを2つ持ちます。１つはロゴ表示画面で、もう一つはスクロールビューを持つ画面（ベゼルの中の要素が切り替わるのを実現している画面）です。
- Bezel Scroll View ：Viewを3つ持ちます。ベゼルの中の各ページを表すViewです。

## ソースを見てみよう

### 基本情報

```swift
import UIKit
import RxSwift
import RxCocoa

class WalkthroughViewController: UIViewController {
    @IBOutlet weak var outerScrollView: UIScrollView!
    @IBOutlet weak var innerScrollView: UIScrollView!
    @IBOutlet weak var bezelScrollView: UIScrollView!
    @IBOutlet weak var pageControl: UIPageControl!

    private let disposeBag = DisposeBag()
    
    // 省略
}

```

RxSwiftを使用して書かれています。
UIでは、IBOutletとして 3つのスクロールビュー、１つのページコントロールが紐づけられています。



### viewWillApper, viewDisapper

```swift
override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        navigationController?.setNavigationBarHidden(true, animated: animated)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        navigationController?.setNavigationBarHidden(false, animated: animated)
    }
```

ここではナビゲーションの設定が行われています。
walkThrough画面ではナビゲーションを隠した状態になっており、画面が消えるタイミングでナビゲーションを表示しています。



### viewDidLoad

UIのメイン処理部分が設定されています。

まず、outerScrollViewのスクロールに基づく挙動の記述です。

```swift
outerScrollView.rx.contentOffset
            .subscribe(onNext: { [weak self] in
                guard let innerScrollView = self?.innerScrollView, let bezelScrollView = self?.bezelScrollView else {
                    return
                }
                
                ①
                innerScrollView.contentOffset.x = min($0.x, innerScrollView.bounds.width)

                let factor = bezelScrollView.bounds.width / innerScrollView.bounds.width
                 
                ②
                let offsetX = max(0, min(($0.x - innerScrollView.bounds.width) * factor,
                bezelScrollView.contentSize.width - bezelScrollView.bounds.width))
           　　　bezelScrollView.contentOffset.x = offsetX
            })
            .disposed(by: disposeBag)
```

① インナースクロールビューのオフセット値を算出・設定

インナースクロールビューはロゴページとベゼルスクロールページの2ページをもっています。
アウタースクロールビューで2,3,4ページ目を表示しているときにインナースクロールビューは2ページ目を固定で表示しておく必要があるので、innerScrollView.contentOffset.xがinnerScrollView.bounds.width（innerScrollViewの2ページ目までの横幅）より大きくならないようにしています。

② ベゼルスクロールビューのオフセット値を算出・設定

＜minの判定について＞

($0.x - innerScrollView.bounds.width) * factor：ベゼルスクロールビューのオフセット値を算出しています。
インナースクロールビューが2ページ目にあるとき、ベゼルスクロールビューでは1ページ目を表示していれば良いので、ロゴページがある分（innerScrollView.bounds.width)だけxから引いています。
factorがあるのは、アウタースクロールビュー、インナースクロールビューの値で計算したサイズをベゼルスクロールビューのサイズ値に変換するためです。

bezelScrollView.contentSize.width - bezelScrollView.bounds.width：固定値であり、オフセットの最大値です。ベゼルスクロールビューは3ページなのでそれ以上はスクロールしないようになります。

＜maxの判定について＞

インナースクロールビューにおいて、ベゼルスクロールビューのあるページに移るまでは ($0.x - innerScrollView.bounds.width) * factor の結果がマイナスの値になりますが、ベゼルスクロールビューのオフセットとしてマイナスを当てる必要がないので最小値を0としています。



#### ページコントロールとouterScrollViewの連携



```swift
private extension Reactive where Base: UIScrollView {
    ①
    var currentPage: Observable<Int> {
        return didEndDecelerating.map({
            let pageWidth = self.base.frame.width
            let page = floor((self.base.contentOffset.x - pageWidth / 2) / pageWidth) + 1
            
            // これでいいのでは？
            // let page = self.base.contentOffset.x / pageWidth
            return Int(page)
        })
    }
}

private extension UIScrollView {
    ②
    func setCurrentPage(_ page: Int, animated: Bool) {
        var rect = bounds
        rect.origin.x = rect.width * CGFloat(page)
        rect.origin.y = 0
        // 指定されたページが表示されるようにスクロールさせる
        scrollRectToVisible(rect, animated: animated)
    }
}
```

① 現在のページ情報を返すObservableを作成
ここですが、

```swift
let page = self.base.contentOffset.x / pageWidth
```

で良いのではと思ったりしているのですが、どうなんでしょう。

② 特定ページまでスクロールさせるメソッドを作成
引数で指定されたページにスクロールビューをスクロールさせるメソッドです。



viewDidload内ではそれらを以下のように利用しています

```swift
①
outerScrollView.rx.currentPage
            .subscribe(onNext: { [weak self] in
                self?.pageControl.currentPage = $0
            })
            .disposed(by: disposeBag)
        
②
pageControl.rx.controlEvent(.valueChanged)
            .subscribe(onNext: { [weak self] in
                guard let currentPage = self?.pageControl.currentPage else { return }
                self?.outerScrollView.setCurrentPage(currentPage, animated: true)
            })
            .disposed(by: disposeBag)
```

① アウタースクロールビュー → ページコントロール
アウタースクロールビューのページが変わった時に、ページコントロールのcurrentPageを変更しています。

② ページコントロール → アウタースクロールビュー
（タップして）ページコントロールの値が変わった時に、アウタースクロールビューのページも変わるようにsetCurrentPageを読んでいます。



## おわりに

少ないコードで、シンプルに実装されていて非常に勉強になります！

Thank you Folio !!