🏞️

SwiftUI.EnvironmentValueの初期化を遅らせる

2022/12/19に公開

この記事はSwift/Kotlin愛好会 Advent Calendar 2022の12/16の記事です。

SwiftUIでは Environment の仕組みを使いViewを跨いだ値の共有ができます。

Environment:
https://developer.apple.com/documentation/swiftui/environment

非常に便利なのですが、このEnvironmentを利用する上での制約に EnvironmentKey.defaultValue を決定する必要があります。これはデフォルト値が決められるようなものであれば特に困らないのですが、たとえばアプリの起動後にAPIからデータを取得して何らかのアプリの設定を取得する必要がある。その設定が取得されてから初めてアプリとしてのメインストリームとしての処理が実行できるといったケースを考えます。この設定の情報自体は初期値のことは置いておいてEnvironmentで共有するようにするのが適切。こういったケースの時はこのデフォルト値を決める制約に悩まされます。

https://developer.apple.com/documentation/swiftui/environmentkey/defaultvalue?changes=_2

Optionalで表現する。といったことももちろん考えられますが、アプリのメインストリームの処理が始まれば本来絶対存在しているものをOptionalで表現し、使用する時にOptionalをUnwrapするのも不便です。

仕組み上アプリのプロセスが始まる前に値を決定する必要がある。という部分は変更できませんが、見かけ上これを遅らせるテクニックを今回紹介します。

LazyEnvironmentValue

LazyEnvironmentValue という構造体を用意します。これは KeyPathによるdynamicMemberLookupを利用して本来使用したい型のプロキシ的な役割を果たします。つまり、プロパティへのアクセスはジェネリクス<T>で定義された構造体のインスタンスにアクセスする場合と同じように書くことができます。

コメントにも書いてある通り1つ目のinitはEnvironment.defaultValueで使用する。2つ目のinitは実際にAPIから値が取得できたあとに .environment(KeyPath, Value) で使用します。

@dynamicMemberLookup struct LazyEnvironmentValue<T> {
    // EnvironmentKey.defaultValueで使用する
    init() { }

   // 値が準備できたら使う
    private var value: T!
    init(_ value: T) {
        self.value = value
    }

    subscript<U>(dynamicMember keyPath: KeyPath<T, U>) -> U {
        value[keyPath: keyPath]
    }
}

実際のコード例を見てみましょう。利用する場合は下記のようになります

Environmentの宣言

struct AppSetting: Codable {
    let logoURL: URL
    let primaryColorHex: String
}

struct AppSettingEnvironmentKey: EnvironmentKey {
    static var defaultValue: LazyEnvironmentValue<AppSetting> = .init()
}

extension EnvironmentValues {
    var appSetting: LazyEnvironmentValue<AppSetting> {
        get {
            self[AppSettingEnvironmentKey.self]
        }
        set {
            self[AppSettingEnvironmentKey.self] = newValue
        }
    }
}

Viewから使用するコード

擬似的に1秒後に AppSetting のインスタンスを作り、それを AppMainViewenvironmentにより設定しています。 AppMainView では設定された AppSetting の値を問題なく使えていることが確認できます。

struct ContentView: View {
    @State var appSetting: AppSetting?
    var body: some View {
        VStack(spacing: 20) {
            Text("Hello, world")
                .bold()

            if let appSetting {
                AppMainView()
                    .environment(\.appSetting, .init(appSetting))
            } else {
                ProgressView()
            }
        }
        .padding()
        .onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                appSetting = .init(logoURL: URL(string: "path")!, primaryColorHex: "#FFFFFF")
            }
        }
    }
}

struct AppMainView: View {
    @Environment(\.appSetting) var appSetting

    var body: some View {
        Text("Color is \(appSetting.primaryColorHex)")
    }
}

実行結果

スクリーンショットですが無事にColorのHexが出ていることが確認できました :tada:

コードの全体も置いておきます。ぜひ手元でも確認してください
@dynamicMemberLookup struct LazyEnvironmentValue<T> {
    // EnvironmentKey.defaultValueで使用する
    init() { }

    private var value: T!
    // 値が準備できたら使う
    init(_ value: T) {
        self.value = value
    }

    subscript<U>(dynamicMember keyPath: KeyPath<T, U>) -> U {
        value[keyPath: keyPath]
    }
}

struct AppSetting: Codable {
    let logoURL: URL
    let primaryColorHex: String
}

struct AppSettingEnvironmentKey: EnvironmentKey {
    static var defaultValue: LazyEnvironmentValue<AppSetting> = .init()
}

extension EnvironmentValues {
    var appSetting: LazyEnvironmentValue<AppSetting> {
        get {
            self[AppSettingEnvironmentKey.self]
        }
        set {
            self[AppSettingEnvironmentKey.self] = newValue
        }
    }
}

struct ContentView: View {
    @State var appSetting: AppSetting?
    var body: some View {
        VStack(spacing: 20) {
            Text("Hello, world")
                .bold()

            if let appSetting {
                AppMainView()
                    .environment(\.appSetting, .init(appSetting))
            } else {
                ProgressView()
            }
        }
        .padding()
        .onAppear {
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                appSetting = .init(logoURL: URL(string: "path")!, primaryColorHex: "#FFFFFF")
            }
        }
    }
}

struct AppMainView: View {
    @Environment(\.appSetting) var appSetting

    var body: some View {
        Text("Color is \(appSetting.primaryColorHex)")
    }
}

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

まとめ

本質としてはアプリのメインストリームに存在する前提だが、アプリ自体の開始時には初期値を設定できないもの。に対する対処方でした。 environment をプロパティから使用する側では絶対に値が存在するコードになるように気をつけましょう

おしまい \(^o^)/

Discussion