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

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

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

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

そんなFolioで使用されているUIのソースがこちらで公開されているので、その中のWalkthroughを本記事では見ていきます🙂

FolioのイケてるWalkthrough

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

IMG_5277.TRIM.MOV.gif

画面の構成をみてみる

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

スクリーンショット 2018-11-26 1.39.58.png

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

スクリーンショット 2018-11-30 1.25.32.png

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

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

ソースを見てみよう

基本情報

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つのスクロールビュー、1つのページコントロールが紐づけられています。

viewWillApper, viewDisapper

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のスクロールに基づく挙動の記述です。

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の連携

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を作成
ここですが、

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

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

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

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


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 !!


『 Swift 』Article List