UIKitでもCombineしたい!を叶えるCombineCocoaを試してみる

2020.11.24

はじめに

iOS 14がリリースされてからしばらく経ち、皆さんが日々開発しているアプリにもiOS 13から使えるCombineフレームワーク が使えるようになってきたのではないでしょうか?Combineの使用用途としてまず挙がるのがネットワーキング等の非同期処理だと思います。一方でCombineはリアクティブプログラミングを実現する手段であるためUIKitでもCombineを使いたい!というニーズもあることでしょう。しかしながらCombineは標準ではUIKitをあまりサポートしていないため、自分でUIControlなどにPublisherを実装する必要があります。1つのアプリを開発する度に毎回Publisherを実装したくないですよね。そこで今回は自分でPublisherを実装しなくてもUIKitで簡単にCombineを使えるようにするライブラリ、CombineCocoaを試してみました。

開発環境

  • macOS Catalina Version 10.15.6
  • Xcode Version 12.1 (12A7403)
  • iPhone 12 Pro iOS 14.1 シミュレーター
  • CombineCocoa 0.2.1

What is CombineCocoa?

UIKitで簡単にCombineを使えるようにするライブラリです。MITライセンスで公開されています。

以下、リポジトリREADME.mdからの引用です。

CombineCocoa attempts to provide publishers for common UIKit controls so you can consume user interaction as Combine emissions and compose them into meaningful, logical publisher chains.

CombineCocoaはUIKitの各種コントロールに対してpublisherを生やしてくれているのでユーザーインタラクションをストリームイベントとして処理できる。そしてストリームなのでチェインできる。良さそうですね。

導入

早速導入してみましょう。CocoaPods、Swift Package Manager、Carthageを使ってインストールできますが、今回はXcodeのGUI操作でサクっとインストールできるSwift Package Managerを使ってインストールしました。 記事執筆時点の最新バージョンは 0.2.1 です。 Swift Package Managerを使った具体的なライブラリインストール方法は以下の記事を参照してください。

Xcode 11 + Swift Package Managerでライブラリを管理する

使ってみる

基本的にUIKitのコントロールに〜Publisher というPublisherが生えているのでそれをsubscribeして流れてくる値を処理します。 各コントロールでどんなイベントを捕捉できるのか調べてみました。

UITextField

textField
    .textPublisher // AnyPublisher<String?, Never>
    .compactMap { $0 } // nilを処理したくないのでcompactMapを使ってnilを除去
    .sink { text in
        // UI操作でtextが変更される度に呼ばれる
        print("text:", text)
    }
    .store(in: &subscriptions)

textField
    .returnPublisher // AnyPublisher<Void, Never>
    .sink {
        // return buttonタップ時に呼ばれる
        print("did tap return key.")
    }
    .store(in: &subscriptions)

UISegmentedControl

segmentedControl
    .selectedSegmentIndexPublisher // AnyPublisher<Int, Never>
    .sink { index in
        // UI操作でselectedSegmentIndexが変更される度に呼ばれる。
        // コードでの変更は通知されない。
        print("index:", index)
    }
    .store(in: &subscriptions)

UIButton

button
    .tapPublisher // AnyPublisher<Void, Never>
    .sink {
        // ボタンタップ時に呼ばれる
        print("did tap button.")
    }
    .store(in: &subscriptions)

UIScrollView

以下サンプルはWKWebView が持つ UIScrollView を使用。

guard let url = URL(string:"https://dev.classmethod.jp/") else { return }
let request = URLRequest(url: url)
webView.load(request)

webView
    .scrollView
    .contentOffsetPublisher // AnyPublisher<CGPoint, Never>
    .sink { point in
        // スクロールしてcontentOffsetが変わる度に呼ばれる
        print("point:", point)
    }
    .store(in: &subscriptions)

webView
    .scrollView
    .reachedBottomPublisher() // AnyPublisher<Void, Never>
    .sink {
        // WebViewで一番下までスクロールした時に呼ばれる
        print("the bottom of the scrollview is reached.")
    }
    .store(in: &subscriptions)

UISlider

slider
    .valuePublisher // AnyPublisher<Float, Never>
    .sink { value in
        // UISliderのvalueが変わる度に呼ばれる
        print("value:", value)
    }
    .store(in: &subscriptions)

UIBarButtonItem

barButtonItem
    .tapPublisher // AnyPublisher<Void, Never>
    .sink {
        // ボタンタップ時に呼ばれる
        print("did tap barButtonItem.")
    }
    .store(in: &subscriptions)

UISwitch

`switch`
    .isOnPublisher // AnyPublisher<Bool, Never>
    .sink { isOn in
        // ON/OFFが変わる度に呼ばれる
        print("isOn:", isOn)
    }
    .store(in: &subscriptions)

UIStepper

stepper
    .valuePublisher // AnyPublisher<Double, Never>
    .sink { value in
        // UIStepperのvalueが変わる度に呼ばれる
        print("value:", value)
    }
    .store(in: &subscriptions)

UIDatePicker

datePicker
    .datePublisher // AnyPublisher<Date, Never>
    .sink { date in
        // UIDatePickerのdateが変わる度に呼ばれる
        print("date:", date)
    }
    .store(in: &subscriptions)

datePicker
    .countDownDurationPublisher // AnyPublisher<TimeInterval, Never>
    .sink { duration in
        // UIDatePickerのcountDownDurationが変わる度に呼ばれる
        print("duration:", duration)
    }
    .store(in: &subscriptions)

UIRefreshControl

refreshControl
    .isRefreshingPublisher // AnyPublisher<Bool, Never>
    .sink { isRefreshing in
        // UIRefreshControlのisRefreshingが変わる度に呼ばれる
        print("isRefreshing", isRefreshing)

        if (isRefreshing) {
            DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                refreshControl.endRefreshing()
            }
        }
    }
    .store(in: &subscriptions)

UIPageControl

pageControl
    .currentPagePublisher // AnyPublisher<Int, Never>
    .sink { currentPage in
        // UIPageControlのcurrentPageが変わる度に呼ばれる
        print("currentPage:", currentPage)
    }
    .store(in: &subscriptions)

UITapGestureRecognizer

tapGestureRecognizer
    .tapPublisher //  AnyPublisher<UITapGestureRecognizer, Never>
    .sink { recognizer in
        // UITapGestureRecognizerがジェスチャーを認識した時に呼ばれる
        print("the Tap Gesture Recognizer is triggered")
    }
    .store(in: &subscriptions)

UIPinchGestureRecognizer

pinchGestureRecognizer
    .pinchPublisher // AnyPublisher<UIPinchGestureRecognizer, Never>
    .sink { recognizer in
        // UIPinchGestureRecognizerがジェスチャーを認識した時に呼ばれる
        print("the Pinch Gesture Recognizer is triggered")
    }
    .store(in: &subscriptions)

UIRotationGestureRecognizer

rotationGestureRecognizer
    .rotationPublisher // AnyPublisher<UIRotationGestureRecognizer, Never>
    .sink { recognizer in
        // UIRotationGestureRecognizerがジェスチャーを認識した時に呼ばれる
        print("the Rotation Gesture Recognizer is triggered")
    }
    .store(in: &subscriptions)

UISwipeGestureRecognizer

swipeGestureRecognizer
    .swipePublisher // AnyPublisher<UISwipeGestureRecognizer, Never>
    .sink { recognizer in
        // UISwipeGestureRecognizerがジェスチャーを認識した時に呼ばれる
        print("the Swipe Gesture Recognizer is triggered")
    }
    .store(in: &subscriptions)

UIPanGestureRecognizer

panGestureRecognizer
    .panPublisher // AnyPublisher<UIPanGestureRecognizer, Never>
    .sink { recognizer in
        // UIPanGestureRecognizerがジェスチャーを認識した時に呼ばれる
        print("the Pan Gesture Recognizer is triggered")
    }
    .store(in: &subscriptions)

UIScreenEdgePanGestureRecognizer

screenEdgePanGestureRecognizer
    .screenEdgePanPublisher // AnyPublisher<UIScreenEdgePanGestureRecognizer, Never>
    .sink { recognizer in
        // UIScreenEdgePanGestureRecognizerがジェスチャーを認識した時に呼ばれる
        print("the Screen Edge Pan Gesture Recognizer is triggered")
    }
    .store(in: &subscriptions)

UILongPressGestureRecognizer

longPressGestureRecognizer
    .longPressPublisher // AnyPublisher<UILongPressGestureRecognizer, Never>
    .sink { recognizer in
        // UILongPressGestureRecognizerがジェスチャーを認識した時に呼ばれる
        print("the Long Press Gesture Recognizer is triggered")
    }
    .store(in: &subscriptions)

おわりに

今回はCombineCocoaでどんなイベントを処理できるのか確認してみました。注意点として、ユーザーインタラクションをストリームイベントとして処理できるライブラリなのでユーザー操作によるUIイベントしか処理できません。例えば UITextFieldtextプロパティをコードで変更してもストリームには何も流れてきません。あくまでも対象は「ユーザー操作によるUIイベント」です。

個人的にめっちゃ便利だと思ったのはUIScrollViewreachedBottomPublisher です。 私が作成するアプリによくあるのが、WKWebViewでアプリの利用規約を表示して「ユーザーが最後まで規約を読んだかどうか」を「UIScrollViewを最後までスクロールしたかどうか」で判定することが多いのですがそのようなシチュエーションでめっちゃ使えそうですね。

今回ご紹介したCombineCocoa、記事執筆時点の最新バージョンがまだまだ0.2.1ということもあり、今後更に機能追加される可能性もあります。 気になった方はチェックしてぜひPRを送ってみてはいかがでしょうか? 今回は以上です。