[SwiftUI]@Stateの代わりに@ObservedObjectを使い、Child Viewの状態変化をParent Viewから参照可能にする

SwiftUIでは、@Stateを使うことで@Stateとして宣言したプロパティの値が変更されるとリアルタイムに更新され大変便利なのですが、@StateにはViewの本体、またはViewから呼び出されるメソッドからしかアクセスすることができません。

You should only access a state property from inside the view’s body, or from methods called by it. 

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

では、このリアルタイムに更新される値をViewの外部からアクセスするにはどうすればよいか?今回は@Stateと同じProperty Wrapperである@ObservedObjectを用いて、Viewの外部からアクセスしてみようと思います。

はじめに

今回は、ドラッグ可能なオブジェクト(DraggableCircle)の位置をリアルタイムに親View(ContentView)からアクセスするシナリオを考えてみます。

ドラッグ可能なオブジェクトの詳細は以下を参考にしてみてください。

Model定義

今回はMVVMアーキテクチャを意識して作成していきます。

最初にデータモデルを定義します。CGPointを使いたいためSwiftUIをImportしています。

//  CircleInfo.swift

import SwiftUI

struct CircleInfo: Identifiable {
    let id = UUID()
    let name: String
    var location: CGPoint
}

Circleオブジェクトに関連する、ID、名前、位置情報(CGPoint)の3つを定義します。

ModelView定義

次にModelViewを定義します。

//  CircleViewModel.swift

import SwiftUI

class CircleViewModel: ObservableObject {
    @Published var circle: CircleInfo =
        CircleInfo(name: "circle1", location: CGPoint(x: 100, y: 200))
}

このCircleViewModel自体をObservableObjectとし、@Published属性をつけた変数として、先程定義したModelを用います。Initializerが必要なので、ここでデータの初期値を設定します。このClass内にprivate funcを作ることで様々な処理が可能ですが、今回は簡潔さを重視し、Initializerのみとします。

View定義

ここでは、Parent View(CircleView)とChild View(DraggableCircle)の2つを定義します。最初にChid Viewから定義します。

Child View(DraggableCircle)

//  DraggableCircle.swift

import SwiftUI

struct DraggableCircle: View {
    @ObservedObject var viewModel = CircleViewModel()

    var body: some View {
        ZStack {
            Circle()
                .fill(Color.yellow)
                .frame(width: 60, height: 60)
            VStack {
                Text(viewModel.circle.name)
                Text("\(Int(viewModel.circle.location.x)) , \(Int(viewModel.circle.location.y))")
            }

        }
        .position(viewModel.circle.location)
        .gesture(DragGesture().onChanged({ value in viewModel.circle.location = value.location}))
    }
}

struct DraggableCircle_Previews: PreviewProvider {
    static var previews: some View {
        DraggableCircle()
    }
}

このコードをPreviewすると以下のようになります。viewModelで初期化した名前”circle1″が、同じく初期化した座標上に表示されます。このCircleオブジェクトをLive Previewでドラッグすると、ドラッグに応じて、座標の表示が変わることが分かると思います。

解説

まず、6行目でViewModelで定義したObservableObjectであるCircleViewModel()をObservedObjectとして定義しています。このプロパティviewModelはこのView(DraggableCircle)が呼び出されると、CircleViewModelオブジェクトが生成され、Initializerによって、初期である名前と座標の情報を持ち、DraggableCircle内で参照することができます。

これだけであれば、@Stateを用いて同様のことが実現できます。それでは、次に本題である、この名前や座標をParent View側からアクセスしてみます。

Parent View(Circle View)

//  CircleView.swift

import SwiftUI

struct CircleView: View {
    
    @ObservedObject var viewModel = CircleViewModel()
    
    var body: some View {
        ZStack {
            List {
                HStack {
                    Text(viewModel.circle.name)
                    Text(NSCoder.string(for: viewModel.circle.location))
                }
            }
            .frame(minWidth: 0,  maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading)
            DraggableCircle(viewModel: viewModel)
        }
    }
}

struct CircleView_Previews: PreviewProvider {
    static var previews: some View {
        CircleView(viewModel: CircleViewModel())
    }
}

このViewをPreviewすると以下のようになります。Child Viewでの座標がリアルタイムに表示されつつ、Parent Viewで定義したList内にも同じ値がリアルタイムに表示されていることがわかると思います。

解説

Parent ViewであるCircleViewもChild View同様にviewModelを@ObservedObjectとして宣言します。あとは、Child View同様にアクセスすることでParent Viewでもこの値にアクセスすることができました。また、Child ViewのDraggableCircleをCallする際には、このviewModelを引数に指定します。

ContentView

最後にContentViewを紹介します。CircleViewをCallする際には、新規に作成するCircleViewModelを引数にしていることに注意してください。

//  ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        CircleView(viewModel: CircleViewModel())
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

まとめ

@ObservedObjectは、View内でのアクセスに限られている@Stateに比べ多少使い方のお作法が必要ですが、子から親へ情報を伝達することができるため、その分使えるシーンは増えそうです。
大元の親でObservedObjectを作成し、その参照を渡していくことで複数のViewからこのObservedObjectにアクセスできるというかたちになります。Model, ViewModel, Viewそれぞれの役割と、概念的なコミュニケーション図を以下に作成しました。

Property Wrapperには他にも@EnvironmentObjectもあります。今回のケースであれば@EnvironmentObjectでも実装できるはずですが、ObservedObjectの数を動的に増やす場合などは@ObservedObjectの方が向いていそうです。

References