🦋

SwiftUI: Observableの使い方メモ

2024/10/26に公開
Item(前提)
struct Item: Identifiable {
    var value: String
    var id: String { value }
}

普通にViewModelを定義する

import Observation

@Observable class ContentViewModel {
    var items = [Item]()
    var flag = false

    init() {}
}

変更通知したくない時

プロパティに@ObservationIgnoredをつける。

プロトコルを経由してViewModelを定義する

import Observation

protocol ContentViewModelProtocol: Observable {
    var items: [Item] { get set }
    var flag: Bool { get set }
}

@Observable class ContentViewModel: ContentViewModelProtocol {
    var items = [Item]()
    var flag = false

    init() {}
}

定義したViewModelを使う

struct ContentView: View {
    @State var viewModel = ContentViewModel()

    var body: some View {
        VStack {
            ForEach(viewModel.items) { item in
                Text(item.value)
            }
        }
        .onAppear {
            viewModel.items = (0 ..< 5).map { Item(value: $0.description) }
        }
    }
}

ViewModelを子Viewにも伝搬させる

パターン1:値を読むだけ
struct ContentView: View {
    @State var viewModel = ContentViewModel()

    var body: some View {
        VStack {
            ForEach(viewModel.items) { item in
                Text(item.value)
            }
            ChildView(viewModel: viewModel)
        }
        .onAppear {
            viewModel.items = (0 ..< 5).map { Item(value: $0.description) }
        }
    }
}

struct ChildView: View {
    // ただのvarでOK
    var viewModel: ContentViewModel

    var body: some View {
        Image(systemName: viewModel.flag ? "checkmark" : "xmark")
    }
}
パターン2:バインドして値を変更する
struct ContentView: View {
    @State var viewModel = ContentViewModel()

    var body: some View {
        VStack {
            ForEach(viewModel.items) { item in
                Text(item.value)
            }
            ChildView(viewModel: viewModel)
        }
        .onAppear {
            viewModel.items = (0 ..< 5).map { Item(value: $0.description) }
        }
    }
}

struct ChildView: View {
    // @Bindableをつけると値の変更を伝搬できる
    @Bindable var viewModel: ContentViewModel

    var body: some View {
        Toggle(isOn: $viewModel.flag) {
            Image(systemName: viewModel.flag ? "checkmark" : "xmark")
        }
    }
}

補足

関数を叩いて値を変更する分には@Bindableをつける必要はない。

@Observable class ContentViewModel {
    var items = [Item]()
    var flag = false

    init() {}

    // 変更を加える関数
    func appendItem() {
        items.append(Item(value: items.count.description))
    }
}

struct ContentView: View {
    @State var viewModel = ContentViewModel()

    var body: some View {
        VStack {
            ForEach(viewModel.items) { item in
                Text(item.value)
            }
            ChildView(viewModel: viewModel)
        }
        .onAppear {
            viewModel.items = (0 ..< 5).map { Item(value: $0.description) }
        }
    }
}

struct ChildView: View {
    // @Bindableをつけていない
    var viewModel: ContentViewModel

    var body: some View {
        VStack {
            Image(systemName: viewModel.flag ? "checkmark" : "xmark")
            Button("append item") {
                viewModel.appendItem()
            }
        }
    }
}

Viewの外からViewModelを注入する場合の話

レガシーなObservableObjectの場合はStateObject.init()の引数が@autoclosureだったので注意が必要だった。

@MainActor @frozen @propertyWrapper @preconcurrency public struct StateObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
    @inlinable nonisolated public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)
}

Observableの場合はState.init()の引数は@autoclosureではないので初期化のタイミングのことは難しく考えずに渡しても大丈夫なはず。

@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
    public init(wrappedValue value: Value)
}

Discussion