意外と難しかったCombine
RxSwiftならある程度使えるし、簡単に使えるようになるかな?と思っていました。
しかし、いざ試してみるとなかなか苦労したので、まずは普段の開発に取り入れられる部分から切り出してみました。
UIKitに値をバインドする
import Combine
import UIKit
class ViewModel {
@Published var labelText: String = "Default value."
}
class ViewController: UIViewController {
var label: UILabel! // 適当な場所(storyboard, viewDidLoadなど)で初期化する
let viewModel = ViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.$labelText
.map({ Optional($0) })
.receive(on: DispatchQueue.main)
.assign(to: \UILabel.text, on: label)
.store(in: &cancellables)
}
}
適当なタイミングで、viewModel.labelText = "Hello!"などと値を変更すると、自動的にUILabelが更新されます。
解説
@PublishedとPublisherプロトコル
バインドしたいプロパティの変更を監視するために、import Combineしたうえで、@PublishedというProperty Wrapperをバインドしたいプロパティに付与します。
class ViewModel {
@Published var labelText: String = "Default value."
}
Property Wrapperを付与したプロパティをもつクラスには、自動的に$labelTextのようにプロパティ名の先頭に$がついたgetterが生成されます。@Publishedを付与した場合、このgetterはPublished<Output>.Publisher型になります。
Publisherというのが監視される側のプロトコルです(RxSwiftならObservable)。また、この場合Output = Stringです。
バインド
Publisherに準拠したプロパティをUIViewにバインドするには、assignメソッドを使います。
viewModel.$labelText
.map({ Optional($0) })
.receive(on: DispatchQueue.main)
.assign(to: \UILabel.text, on: label)
.store(in: &cancellables)
assign
assignメソッドの定義は次のようになっています。
public func assign<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable
keyPathには\UILabel.text1のようにバインド先のUIViewの型名とそのプロパティ名を記載します。
assignメソッドはAnyCancellableというCancellableプロトコルに準拠したクラスのインスタンスを返します。このインスタンスのcancelメソッドを呼ぶことでバインド(監視)が解除されます。
また、AnyCancellableのインスタンスはメモリから解放されるタイミングでもcancelメソッドを呼んでバインドを解除するようです2。そのため、ViewControllerにSet<AnyCancellable>型のプロパティを持たせて参照を保持します。追加する際にチェーンでメソッドを呼べるようにstoreメソッドが用意されています。
map
Publisherには様々なオペレーターが用意されており、監視している値に変更を加えたり、流れてくる時間や回数などの条件によって流れをせきとめたりすることができます。
mapはもっとも基本的なオペレーターで、流れてきた値を加工することができます。
ここでは、UILabel.text: String?の型に合わせて、ViewModel.labelText: StringをOptional型に変換しています。
receive(on: DispatchQueue.main)
receive(on:)はメソッドチェーンのうちで、このメソッド以降のチェーン内の処理が行われるスレッドを指定することができます。
assignメソッドはUIの更新処理にあたるので、メインスレッドを指定しています。
おまけ:SwiftUI版
import Combine
import SwiftUI
class ViewModel: ObservableObject {
@Published var labelText: String = ""
}
struct ContentView: View {
@ObservedObject private var viewModel = ViewModel()
var body: some View {
NavigationView {
VStack {
Text(viewModel.labelText)
TextField("Title", text: $viewModel.labelText)
.multilineTextAlignment(.center)
}
}
}
}
解説
@ObservedObject/ObservableObject
SwiftUIのViewにバインドするには、バインドしたいプロパティをもつviewModelに@ObservedObjectを付与します。
(ObservableObjectプロトコルに準拠させることで、@ObservedObjectが付与できるようになります。)
struct ContentView: View {
@ObservedObject private var viewModel = ViewModel()
...
}
class ViewModel: ObservableObject {
@Published var labelText: String = ""
}
バインド
var body: some View {
NavigationView {
VStack {
Text(viewModel.labelText)
TextField("Title", text: $viewModel.labelText)
.multilineTextAlignment(.center)
}
}
}
@ObservedObjectを付与したプロパティはViewに監視されるようになり、値の変更があればUIが自動更新されます。
そのため、通常のString型を引数にとるクラスではviewModel.labelTextを渡すだけでバインド完了です。
@ObservedObjectによって生成された$viewModelプロパティのlabelTextはBinding<String>という型を返します。これをBinding<String>型を引数にとるTextFieldに渡すことによって、双方向バインディングとなります。
こちらのSwiftUI版では、viewModel.labelTextの値を変更するとTextとTextFieldの値が更新されますし、キーボードからTextFieldを変更しても、viewModel.labelText(とText)の値が更新されます。
-
バックスラッシュはKey-Pathを表現するときの記法です。ドキュメントはこちら Key-Path Expression ↩
-
ドキュメントに記述を見つけられなかったのですが、参照を保持しなかった場合は
viewDidLoadを抜けた時点でcancelが呼ばれていました。 ↩