🥡

[SwiftUI] DynamicProperty を使って OptionalObservedObject を実現

2021/06/28に公開

SwiftUI のデータバインディング用の各 Property Wrapper の中に、@ObservedObjectというものがあります。@ObservedObjectObservableObject に準拠するオブジェクトを包装し、データ更新を自動的に UI に反映させることができます。

今、ある ObservableObject だけに依存するUI画面ができたと仮定します。ただ、そのオブジェクトが、nil である可能性があります。例えばネットのレスポンスがまだ来ない、或いはユーザーがまだ作成していない。この場合、ObservableObject をオプショナルとして使いたいが、@ObservedObject はサポートしてくれません:

なぜかと言うと、@ObservedObjectObservableObject を望んでいますが、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()
        }
    }
}
  1. オプショナルの ObservableObject を保有する変数
  2. この値がセットされるとき、objectWillChange を発火し、新しいオブジェクトを観察し始める
  3. 初期化関数の中に optionalObject に値を代入する時 willSet が呼ばられないため、明示的に observe を呼ぶ
  4. 新しいオブジェクトを観察し始める方法。注意点として、渡された新オブジェクトが nil であろうとなかろうと、前の cancellabledeinit され、cancellable.cancel() が自動的に呼ばれるので、手動で呼ぶ必要はない
  5. 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 }
    }
}

  1. @ObservedObjectOptionalWrapper を装飾する。そうすると OptionalWrapper が発火した通知が @ObservedObject 通して SwiftUI に届ける。
  2. アンダーラインをつけることで、Property Wrapper の @ObservedObject 自体をアクセスすることができ、直接に初期化する
  3. 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 プロトコルに準拠する必要があります。

https://developer.apple.com/documentation/swiftui/dynamicproperty

しかし、公式ドキュメントでは準拠方法についてあまり話していないため、ここで少し解説したいと思います。

Property Wrapper が SwiftUI View を更新できるようにしたいなら、DynamicProperty に準拠するだけではなく、Property Wrapper の中に既存の DynamicProperty を使わなければなりません。既存の DynamicProperty というのは、@State @Environment など、SwiftUI がビルトインした Property Wrapper。上のドキュメントですべての DynamicProperty が確認できます。

つまり、DynamicProperty を実現したいなら、既存の DynamicProperty に頼らず、完全自作することができません。OptionalWrapperDynamicProperty に直接準拠するではなく、中間層として使われているのもこのためです。

また、DynamicPropertyupdate() もありますが、デフォルトの実現があるため、別に実現しなくても問題ありません。

WWDC21 SwiftUI Lounges で、アップルのエンジニアに上述のことを確認しました。

まとめ

この記事は、@OptionalObservedObject の実現と DynamicProperty の使用方法について解説しました。

まとめると:

  • SwiftUI View を更新できるDynamicProperty を作るため、既存の DynamicProperty を使わなければならない
  • そのため中間層の OptionalWrapper を導入して、 @OptionalObservedObject を実現する

What’s next?

  • @OptionalStateObject も実現してみよう!

最終実現は Gist にアップしました
https://gist.github.com/ribilynn/9727ae9a7171c8ec9d59dd10d7cd3b21

  • DynamicProperty のもう一つの素晴らしい応用:

https://qiita.com/itsukiss/items/a02f28bd3667c4febaf4

Discussion