WWDC2019で発表されたApple製のCombineフレームワークは
非同期なデータの流れを簡単に扱え
多くの場面で活用できると期待されているフレームワークです。
詳しくはこちらに記載していますので
よろしければご参照ください。
https://qiita.com/shiz/items/5efac86479db77a52ccc
2019/7/5現在では
NotificationCenterやURLSessionなどのextensionで
Publisherに適合する実装が用意されていますが
UIKitを拡張したものは用意されていません。
そこで
今回はUIKitのイベントが発生した際にデータを送る
Custom Publisherの作り方を見ていきたいと思います。
また
その中でCombineフレームワークが
どういう仕組みで動いているのかについても
改めて見ていきたいと思います。
主に下記の記事を参考にしています。
https://www.avanderlee.com/swift/custom-combine-publisher/
UIKitのイベントに応答するには?
UIControlのイベントが発生した時に
データをPublisherから送るようにします。
必要なもの
大きく分けて2つのものが必要になります。
- Custom Subscriptionを作成する
- Custom Publisherを作成する
Custom Subscriptionを作成する
まずは下記のようにUIControlを拡張します。
extension UIControl {
final class Subscription<SubscriberType: Subscriber, Control: UIControl>: Combine.Subscription where SubscriberType.Input == Control {
private var subscriber: SubscriberType?
private let control: Control
init(subscriber: SubscriberType, control: Control, event: UIControl.Event) {
self.subscriber = subscriber
self.control = control
control.addTarget(self, action: #selector(eventHandler), for: event)
}
func request(_ demand: Subscribers.Demand) {
}
func cancel() {
subscriber = nil
}
@objc private func eventHandler() {
_ = subscriber?.receive(control)
}
deinit {
print("UIControlTarget deinit")
}
}
}
上記のコードを見ていきます。
Protocolへの適合
まずSubscription Protocolに適合させる必要があります。
SubscriptionはPublisherとSubscriberを繋ぐ存在で
PublisherとSubscriberの接続状態を表します。
Subscription ProtocolはCancellable Protocolを継承しており
cancelメソッドの実装が必須です。
Publisherからデータを受け取ることをキャンセルした時の処理を実装する必要があります。
さらにrequestメソッドも必須です。
https://developer.apple.com/documentation/combine/subscription/3213720-request
上記の例では何も行っていないため
来たデータをそのまま流しているだけですが
ここで流すデータに条件を追加したりすることもできます。
さらに引数で指定するDemandの値で
Publisherへ要求するデータ量をコントロールすることができます。
Demandは2つのcaseを持つenumで
- max(Int) 指定した回数を超えたらデータを受け取ることを止める
- unlimited 無制限にデータを受け取る
sinkメソッドはunlimitedになる
SubscriberのInputを制約する
次に注目する点としてはクラスの制約で
class Subscription<SubscriberType: Subscriber, Control: UIControl>
: Combine.Subscription where SubscriberType.Input == Control
とすることで
Subscriberが受け取る値
つまりPublisherが出力する値をUIControlを継承したものに限定しています。
イベントを受け取る
SubscriptionはSubscriberとイベントを発生させるUIControlを保持しており
-
init時にaddTargetでイベントハンドラーを登録 - イベントハンドラーで保持する
Subscriberへデータを流す
ことを行っています。
Subscriberは下記の3つのメソッドでデータを受け取ることができます。
receive(subscription: Subscription)
SubscriberがPublisherと接続に成功し
データを要求することができるようになった通知を受け取ります。
receive(Self.Input) -> Subscribers.Demand
Publisherが出力した値を受け取ります。
receive(completion: Subscribers.Completion<Self.Failure>)
Publisherがデータを出力するのを完了したことを受け取ります。
正常に終了した場合もエラーが起きて終了した場合もあります。
cancelされた時
保持するSubscriberへの参照をnilにすることで
メモリから解放されるようにしています。
Custom Publisherを作成する
次にPublisherを作成します。
extension UIControl {
struct Publisher<Control: UIControl>: Combine.Publisher {
typealias Output = Control
typealias Failure = Never
let control: Control
let controlEvents: UIControl.Event
init(control: Control, events: UIControl.Event) {
self.control = control
self.controlEvents = events
}
func receive<S>(subscriber: S) where S : Subscriber, S.Failure == Failure, S.Input == Output {
let subscription = UIControl.Subscription(subscriber: subscriber, control: control, event: controlEvents)
subscriber.receive(subscription: subscription)
}
}
}
先ほども少し触れましたが
PublisherのOutputはUIControlを継承したクラスになります。
init時にControlと出力するUIControl.Eventを保持します。
また
Publisher Protocolに適合させる必要があります。
そのためにはreceive<S>(subscriber: S)メソッドを実装します。
この中で
先ほど作成したCustom Subscriptionのインスタンスを生成し
引数のSubscriberのreceive(subscription: Subscription)メソッドに渡します。
やることはこれだけです。
もっと簡単にPublisherを作成できるようにする
NotificationCenterなどと似た形でUIControl.Publisherを作成できるようにします。
protocol CombineCompatible { }
extension UIControl: CombineCompatible { }
extension CombineCompatible where Self: UIControl {
func publisher(for events: UIControl.Event) -> UIControl.Publisher<Self> {
return UIControl.Publisher(control: self, events: events)
}
}
CombineCompatibleという空のProtocolを使用することで
UIControl以外にも拡張しやすくしたり
テスト時のモックを作りやすくしています。
これを使って実装例を見てみます。
実装例
参考にした記事に出てきた例で単純にボタンをタップしたときの挙動を見てみます。
let button = UIButton()
button.publisher(for: .touchUpInside).sink { button in
print("Button is pressed!")
}
button.sendActions(for: .touchUpInside) // Button is pressed!
sendActionsを呼び出すとprintの内容が出力されます。
UISwitchのイベントに応答する
UISwitchのisOnプロパティは
KVOに対応していないため
Publisherで変化をキャッチできるようにしてみます。
extension CombineCompatible where Self: UISwitch {
var isOnPublisher: AnyPublisher<Bool, Never> {
return publisher(for: [.allEditingEvents, .valueChanged]).map { $0.isOn }.eraseToAnyPublisher()
}
}
※
これはUIのイベントには応答しますが
プログラム上でisOnプロパティを変更しても反応しません。
まとめ
Custom Publisherの作成を通して
Combineフレームワークの動作を確認してみました。
Combineフレームワークは
応用できる幅がたくさんあると思いますので
今後も色々な使い方を見ていきたいと思います。
Custom Publisherを活用しているライブラリには
下記のようなものもありますので
ぜひ参考にしてみてください😄
@noppefoxwolf さんのCombinative
iOS13より下のバージョンやLinux, WindowsでもCombineを使用可能にしようと試みているOpenCombine
間違いなどございましたらご指摘いただけると嬉しいです🙇🏻♂️