will and way

ただの自分用メモを人に伝える形式で書くことでわかりやすくまとめてるはずのブログ

UIScrollViewのページングをライフサイクルとして扱えるようにする

もっといいタイトル無いだろうか・・・笑

利用用途

UIViewControllerのviewをページとしてUIScrollViewにマウントしておいて、スワイプで切り替えて使うみたいな想定です。

UIScrollViewでページャーのviewDidAppearみたいなライフサイクルがあれば表示されたViewControllerをリロードしたいときに使えます。

やること

https://github.com/matsuokah/PagerLifecycle/blob/master/Images/behavior.gif?raw=true

方針

  1. RxSwiftでUIScrollViewのReactiveを拡張
  2. プロトコル指向な実装でUIViewControllerにロジックを実装しない

ベタベタに実装すればできるんですが、UIViewControllerから実装を切り分けることで、UIViewControllerをファットにならずに済むわけです

大まかな流れ

  1. ページが切り替わった
  2. 表示領域から該当するUIViewControllerの検出
  3. ライフサイクルメソッドの発行

実装

1. RxでUIScrollViewのページング完了を検出する

/// Page
struct UIScrollViewPage {
    let vertical: Int
    let horizontal: Int
}

extension UIScrollViewPage: Equatable {
    public static func ==(lhs: UIScrollViewPage, rhs: UIScrollViewPage) -> Bool {
        return lhs.vertical == rhs.vertical && lhs.horizontal == rhs.horizontal
    }
}

/// Pageの切り替わりの検出
extension Reactive where Base: UIScrollView {
    /// ページが切り替わった場合に選択されたページを流すイベント発行します
    /// 慣性スクロールなしのスクロール停止 or 慣性スクロールが停止した場合にPageが選択されたと検出しています
    var: Observable<UIScrollViewPage> {
        return Observable.merge(didEndDragging.asObservable().filter { return !$0 },
                                didEndDecelerating.asObservable().map { _ in return true })
            .map { _ in
                // 1以下(0)で割るとページが異常値になることがありうるのでフレームサイズが1以下のときは強制的に0ページ扱いにする
                let verticalPage = Int(self.base.frame.height < 1 ? 0 : self.base.contentOffset.y / self.base.frame.height)
                let horizontalPage = Int(self.base.frame.width < 1 ? 0 : (self.base.contentOffset.x / self.base.frame.width))
                return UIScrollViewPage(vertical: verticalPage, horizontal: horizontalPage)
        }
    }
}
extension UIScrollViewPage: Equatable {

Equatableに準拠している理由はdidSelectedPageのイベントをdistinctUntilChangeしたいため。

2. バインディングするViewControllerのProtocolを定義する

protocol PagerScrollViewControllerProtocol: class {
    func setupPagerScrollView()
    var pager: UIScrollView { get }
    var pagerDelegateViews: [PageDelegate] { get }
    var disposeBag: DisposeBag { get }
}

3. ページャーがあるUIViewControllerでPagerScrollViewControllerProtocolに準拠

class ViewController: UIViewController {
    var disposeBag = DisposeBag()
    @IBOutlet weak var scrollView: UIScrollView!

    override func viewDidLoad() {
        super.viewDidLoad()
        setupPagerScrollView()
    }
}

extension ViewController: PagerScrollViewControllerProtocol {
    var pager: UIScrollView {
        return scrollView
    }
    
    var pagerDelegateViews: [PageDelegate] {
        return childViewControllers.filter { $0 is PageDelegate }.map { $0 as! PageDelegate }
    }
}

pagerのライフサイクルを伝えるViewの選択戦略はここで差し替えればOK
IBOutletで登録していたり、

var pagerDelegateViews: [PageDelegate] {
    return childViewControllers.filter { $0 is PageDelegate }.map { $0 as! PageDelegate }
}

4. ページャーのライフサイクルを受け取るコンテンツのプロトコルを定義

protocol UIPageViewProtocol: class {
    var targetPageView: UIView { get }
}

protocol PageState: class {
    var isVisiblePage: Bool { get set }
}

protocol PageDelegate: PageState, UIPageViewProtocol {
    func viewDidAppear()
    func viewDidDisappear()
}

targetPageViewはUIScrollViewの表示区域内にマウントされているかを検出する対象のViewです

ライフサイクルを受け取る側はPageDelegateの拡張準拠実装すればOK

final class PageViewController: UIViewController {
    var isVisiblePage = false
}

extension PageViewController: PageDelegate {
    var targetPageView: UIView { 
            return self.view
        }

    func viewDidAppear() {}
    func viewDidDisappear() {}
}

5. PagerScrollViewControllerProtocolのデフォルト拡張でページング検知を実装

extension PagerScrollViewControllerProtocol where Self: UIViewController {
    /// ページ選択を検出するためのセットアップを行います
    /// ターゲットとなるScrollViewのイベントのバインディング
    /// 選択ステータスの初期化
    func setupPagerScrollView() {
        pager.rx.didSelectedPage
            .distinctUntilChanged()
            .subscribe(onNext: { [weak self] page in
                guard let `self` = self else { return }
                self.applyAppearPage()
            }).disposed(by: disposeBag)
        
        // initialise for state
        applyAppearPage()
    }
    
    private func applyAppearPage() {
        let offset = pager.contentOffset
        let pagerSize = pager.frame.size
        let visibleArea = CGRect(x: offset.x, y: offset.y, width: pagerSize.width, height: pagerSize.height)
        
        pagerDelegateViews
            // 表示View, 非表示Viewの振り分け
            .reduce(into: Dictionary<Bool, Array<PageDelegate>>()) { base, content in
                let isVisisble = visibleArea.intersects(content.targetPageView.frame)
                base[isVisisble, default: []].append(content)
            }
            .forEach { (isVisisble, views) in
                if isVisisble {
                    // 表示されていなかったViewがページ切り替えによって表示になった
                    views
                        .filter { page in
                            return !page.isVisiblePage
                        }
                        .forEach { page in
                            page.viewDidAppear()
                            page.isVisiblePage = true
                    }
                } else {
                    // 表示されていたViewがページ切り替えによって非表示になった
                    views.filter { page in
                        return page.isVisiblePage
                        }
                        .forEach { page in
                            page.viewDidDisappear()
                            page.isVisiblePage = false
                    }
                }
        }
    }
}

まとめ

ということで、Rx拡張, Protocolのデフォルト拡張, 各Protocolに必要なプロパティを持たせるという実装に分けることで
各々がプロトコルのプロパティの部分だけ拡張実装すればOKという状態ができました。

UIKitがオブジェクト指向なので、UIKit周辺はオブジェクト指向な実装になりがちですが
こんな感じでプロトコル指向プログラミングできそうですね!

プロトコル指向プログラミングの紹介はこちら

blog.matsuokah.jp