30
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

iOS (その2)Advent Calendar 2018

Day 9

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

Last updated at Posted at 2018-12-08

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

30
11
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
30
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?