🦋

SwiftUI: ViewのObservableの関数をSceneで呼ぶ方法

に公開

macOSだとScene.commands()をつけることでメニューバーのコマンドを追加したり上書きしたりできます。しかし、コマンドで実際に動かす処理はSceneではなくViewやそのObservable classに定義されているのが一般的だと思います。そのため、SceneからViewの関数を呼ぶ手段が欲しくなります。

struct SomeView: View {
    @State var isPresented = false

    var body: some View {
        Button("Import") {
            isPresented = true
        }
        .fileImporter(
            isPresented: $isPresented,
            allowedContentTypes: [UTType.image],
            onCompletion: { _ in //読み込み処理 }
        )
    }
}

struct SomeApp: App {
    var body: some Scene {
        WindowGroup {
            SomeView()
        }
        .commands {
            CommandGroup(before: .newItem) {
                Button {
                    // ここでSomeViewのButton("Import")を押した時と同じ処理をしたい
                } label: {
                    Text("Import")
                }
            }
        }
    }
}

そんな時はFocusedValueKeyFocusedValues.focusedSceneValue()を使えば関数を外から叩けます。

まずはSomeViewの関数を伝搬させるための構造体を定義します。
そして、その構造体にFocusedValuesからアクセスできるように下準備をします。

struct SomeViewAction {
    var importAction: @MainActor @Sendable () -> Void
}

struct SomeViewActionKey: FocusedValueKey {
    typealias Value = SomeViewAction
}

extension FocusedValues {
    var someViewAction: SomeViewAction? {
        get { self[SomeViewActionKey.self] }
        set { self[SomeViewActionKey.self] = newValue }
    }
}

次に、実際にSomeView側でfocusedSceneValue()を使ってSomeViewActionを渡して、Sceneの方では@FocusedValueで受け取ります。

struct SomeView: View {
    @State var isPresented = false

    var body: some View {
        Button("Import") {
            isPresented = true
        }
        .focusedSceneValue(\.someViewAction, SomeViewAction(
            importAction: { isPresented = true }
        ))
        .fileImporter(
            isPresented: $isPresented,
            allowedContentTypes: [UTType.image],
            onCompletion: { _ in //読み込み処理 }
        )
    }
}

struct SomeApp: App {
    @FocusedValue(\.someViewAction) private var someViewAction

    var body: some Scene {
        WindowGroup {
            SomeView()
        }
        .commands {
            CommandGroup(before: .newItem) {
                Button {
                    someViewAction?.importAction()
                } label: {
                    Text("Import")
                }
            }
        }
    }
}

応用

コマンドを状態によってdisabled()したいこともあります。
そんな時は条件もSomeViewAction経由で渡せるようにしてしまいます。

struct SomeViewAction {
    var importAction: @MainActor @Sendable () -> Void
    var canImport: @MainActor @Sendable () -> Bool
}
struct SomeView: View {
    @State var isPresented = false
    @State var canImport = true

    var body: some View {
        Button("Import") {
            isPresented = true
        }
        .disabled(!canImport)
        .focusedSceneValue(\.someViewAction, SomeViewAction(
            importAction: { isPresented = true },
            canImport: { canImport }
        ))
        .fileImporter(
            isPresented: $isPresented,
            allowedContentTypes: [UTType.image],
            onCompletion: { _ in /*読み込み処理*/ }
        )
    }
}

struct SomeApp: App {
    @FocusedValue(\.someViewAction) private var someViewAction

    var body: some Scene {
        WindowGroup {
            SomeView()
        }
        .commands {
            CommandGroup(before: .newItem) {
                Button {
                    someViewAction?.importAction()
                } label: {
                    Text("Import")
                }
                .disabled(!(someViewAction?.canImport() ?? false))
            }
        }
    }
}

Observable classを経由する

これは難しくありません。普通にObservableを使えば良いです。

@MainActor @Observable class SomeViewModel {
    var isPresented = false
    var canImport = true

    func onImport() {
        isPresented = true
    }
}

struct SomeView: View {
    @State var viewModel = SomeViewModel()

    var body: some View {
        Button("Import") {
            viewModel.onImport()
        }
        .disabled(!viewModel.canImport)
        .focusedSceneValue(\.someViewAction, SomeViewAction(
            importAction: { viewModel.onImport() },
            canImport: { viewModel.canImport }
        ))
        .fileImporter(
            isPresented: $viewModel.isPresented,
            allowedContentTypes: [UTType.image],
            onCompletion: { _ in /*読み込み処理*/ }
        )
    }
}

Discussion