UIScrollViewのページングをライフサイクルとして扱えるようにする
もっといいタイトル無いだろうか・・・笑
利用用途
UIViewControllerのviewをページとしてUIScrollViewにマウントしておいて、スワイプで切り替えて使うみたいな想定です。
UIScrollViewでページャーのviewDidAppearみたいなライフサイクルがあれば表示されたViewControllerをリロードしたいときに使えます。
やること
方針
- RxSwiftでUIScrollViewのReactiveを拡張
- プロトコル指向な実装でUIViewControllerにロジックを実装しない
ベタベタに実装すればできるんですが、UIViewControllerから実装を切り分けることで、UIViewControllerをファットにならずに済むわけです
大まかな流れ
- ページが切り替わった
- 表示領域から該当するUIViewControllerの検出
- ライフサイクルメソッドの発行
実装
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周辺はオブジェクト指向な実装になりがちですが
こんな感じでプロトコル指向プログラミングできそうですね!
プロトコル指向プログラミングの紹介はこちら