意外と難しかった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.text
1のようにバインド先の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
が呼ばれていました。 ↩