[SwiftUI] DynamicProperty を使って OptionalObservedObject を実現
SwiftUI のデータバインディング用の各 Property Wrapper の中に、@ObservedObject
というものがあります。@ObservedObject
は ObservableObject
に準拠するオブジェクトを包装し、データ更新を自動的に UI に反映させることができます。
今、ある ObservableObject
だけに依存するUI画面ができたと仮定します。ただ、そのオブジェクトが、nil
である可能性があります。例えばネットのレスポンスがまだ来ない、或いはユーザーがまだ作成していない。この場合、ObservableObject
をオプショナルとして使いたいが、@ObservedObject
はサポートしてくれません:
なぜかと言うと、@ObservedObject
が ObservableObject
を望んでいますが、Optional<ObservableObject>
はただの enum
です。
なので、この記事で、オプショナルの ObservableObject
をサポートする @OptionalObservedObject
を紹介したいと思います。
実現
中間層
まず、オプショナルの ObservableObject
を包装する中間層 OptionalWrapper
を作ります。想定する動作は、オプショナル状態が変わった時、或いは中にある ObservableObject
に更新がある時に、中間層クラスが通知を発火します。
実現がこちら:
class OptionalWrapper<ObjectType>: ObservableObject {
var cancellable: AnyCancellable?
// 1
var optionalObject: ObjectType? {
// 2
willSet {
objectWillChange.send()
observe(newValue)
}
}
init(optionalObject: ObjectType?) {
self.optionalObject = optionalObject
// 3
observe(optionalObject)
}
// 4
private func observe(_ newObject: ObjectType?) {
cancellable = newObject?.objectWillChange.sink { [weak self] _ in
// 5
self?.objectWillChange.send()
}
}
}
- オプショナルの
ObservableObject
を保有する変数 - この値がセットされるとき、
objectWillChange
を発火し、新しいオブジェクトを観察し始める - 初期化関数の中に
optionalObject
に値を代入する時willSet
が呼ばられないため、明示的にobserve
を呼ぶ - 新しいオブジェクトを観察し始める方法。注意点として、渡された新オブジェクトが
nil
であろうとなかろうと、前のcancellable
がdeinit
され、cancellable.cancel()
が自動的に呼ばれるので、手動で呼ぶ必要はない -
newObject
に値がある時、その値を観察し、更新通知を転送する
こうすると、この中間層の OptionalWrapper
を @ObservedObject
に使うと、間接的にオプショナルの ObservableObject
を使うことができました:
struct TodoDetailView: View {
@ObservedObject var todo: OptionalWrapper<Todo>
var body: some View {
if let todo = todo.optionalObject {
Text(todo.title)
} else {
Button("Make Todo") {
todo.optionalObject = Todo()
}
}
}
}
もちろんこのままでもいけますが、使う時毎回 .optionalObject
を呼ぶのが流石に面倒くさいので、やはり Property Wrapper にしたいところですね。
Property Wrapper
@propertyWrapper
を実現するため、中には wrappedValue
が必要(この場合は ObjectType?
型)。また、SwiftUIがデータ更新を感知できるようにするため、DynamicProperty
に準拠します(詳細が後述)。
class OptionalWrapper<ObjectType>: ObservableObject { /*...*/ }
@propertyWrapper
struct OptionalObservedObject<ObjectType: ObservableObject>: DynamicProperty {
// 1
@ObservedObject var objectWrapper: OptionalWrapper
init(wrappedValue: ObjectType?) {
// 2
_objectWrapper = .init(wrappedValue: OptionalWrapper(optionalObject: wrappedValue))
}
var wrappedValue: ObjectType? {
get { objectWrapper.optionalObject }
// 3
nonmutating set { objectWrapper.optionalObject = newValue }
}
}
-
@ObservedObject
でOptionalWrapper
を装飾する。そうするとOptionalWrapper
が発火した通知が@ObservedObject
通して SwiftUI に届ける。 - アンダーラインをつけることで、Property Wrapper の
@ObservedObject
自体をアクセスすることができ、直接に初期化する - SwiftUI に扱われる Property Wrapper が値変更(mutating)できないため、
non mutating
が必要
これで完成です。使用効果はこちら:
struct TodoDetailView: View {
@OptionalObservedObject var todo: Todo?
var body: some View {
if let todo = todo {
Text(todo.title)
} else {
Button("Make Todo") {
todo = Todo()
}
}
}
}
DynamicProperty
SwiftUI にデータの更新を感知できるようにするため、Property Wrapper を DynamicProperty
プロトコルに準拠する必要があります。
しかし、公式ドキュメントでは準拠方法についてあまり話していないため、ここで少し解説したいと思います。
Property Wrapper が SwiftUI View を更新できるようにしたいなら、DynamicProperty
に準拠するだけではなく、Property Wrapper の中に既存の DynamicProperty
を使わなければなりません。既存の DynamicProperty
というのは、@State
@Environment
など、SwiftUI がビルトインした Property Wrapper。上のドキュメントですべての DynamicProperty
が確認できます。
つまり、DynamicProperty
を実現したいなら、既存の DynamicProperty
に頼らず、完全自作することができません。OptionalWrapper
が DynamicProperty
に直接準拠するではなく、中間層として使われているのもこのためです。
また、DynamicProperty
に update()
もありますが、デフォルトの実現があるため、別に実現しなくても問題ありません。
WWDC21 SwiftUI Lounges で、アップルのエンジニアに上述のことを確認しました。
まとめ
この記事は、@OptionalObservedObject
の実現と DynamicProperty
の使用方法について解説しました。
まとめると:
- SwiftUI View を更新できる
DynamicProperty
を作るため、既存のDynamicProperty
を使わなければならない - そのため中間層の
OptionalWrapper
を導入して、@OptionalObservedObject
を実現する
What’s next?
-
@OptionalStateObject
も実現してみよう!
最終実現は Gist にアップしました
-
DynamicProperty
のもう一つの素晴らしい応用:
Discussion