Edited at

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