Skip to main content
  1. Posts/

SwiftUIでDictionary型のAppStorageをProperty Wrapperで実装する

SwiftUIでは、@AppStorageを使用して、簡単にUserDefaultsにアクセスできます。 しかし、この@AppStorageは、対応している型が限られており、特にDictionary型については非対応のようです。 そこで、今回は、AppStorageのような使い方でStateのように振る舞える、独自の型をProperty Wrapperで実装しました。

ライブラリ化したので、Swift Packageから使用できます GitHub sussan0416/DictionaryDefaults

実装 #

まずは実装の全体像です。 なお「これで偶然うまく行った」という感じなので、皆さんの環境でもこの実装が最適かどうかは保証できません。 特に、Dictionaryを[String: Any]で固定していますが、場合によってはここをGenericsにする方が都合良い場合があるかもしれません。

import SwiftUI

@propertyWrapper
struct DictionaryDefaults: DynamicProperty {
    private let key: String
    private let userDefaults: UserDefaults

    @State private var dictionary: [String: Any] {
        didSet {
            userDefaults.set(dictionary, forKey: key)
        }
    }

    var wrappedValue: [String: Any] {
        get {
            dictionary
        }

        nonmutating set {
            dictionary = newValue
        }
    }

    var projectedValue: Binding<[String: Any]> {
        Binding {
            wrappedValue
        } set: { newValue in
            wrappedValue = newValue
        }
    }

    init(wrappedValue defaultValue: [String: Any], key: String, suiteName: String? = nil) {
        self.key = key

        // UserDefaultsにはアクセスできる前提
        userDefaults = UserDefaults(suiteName: suiteName)!

        let dict = userDefaults.dictionary(forKey: key)
        if let dict {
            dictionary = dict
        } else {
            userDefaults.set(defaultValue, forKey: key)
            dictionary = defaultValue
        }
    }
}

サンプルとして、以下のようなViewを実装してみました。

struct FirstView: View {
    // 今回実装したDictionaryDefaults
    @DictionaryDefaults(key: "my_dict")
    private var myDictionary = [String: Any]()

    // 2番目の画面の表示フラグ
    @State private var isPresentSecondView = false

    // 表示用にDictionaryをArrayに変換しておく...
    private var keys: [String] {
        get {
            myDictionary.keys.map { $0 }
        }
    }

    var body: some View {
        NavigationStack {
            List {
                ForEach(keys, id: \.self) { key in
                    Text(myDictionary[key] as! String)
                }
            }
            .toolbar {
                // 全削除ボタン
                Button {
                    myDictionary.removeAll()
                } label: {
                    Text("Clear")
                }
                // 追加ボタン
                Button {
                    myDictionary.updateValue(
                        "FirstView", 
                        forKey: UUID().uuidString
                    )
                } label: {
                    Text("Add")
                }
                // 次の画面を開くボタン
                Button {
                    isPresentSecondView.toggle()
                } label: {
                    Text("Show SecondView")
                }
            }
            .sheet(isPresented: $isPresentSecondView) {
                // 次の画面に、myDictionaryのBindingを渡す
                SecondView(dict: $myDictionary)
            }
        }
    }
}

struct SecondView: View {
    // DictionaryをBindする
    @Binding var dict: [String: Any]

    var body: some View {
        // 追加ボタン
        Button {
            dict.updateValue(
                "SecondView",
                forKey: UUID().uuidString
            )
        } label: {
            Text("Add")
        }
        // 全削除ボタン
        Button {
            dict.removeAll()
        } label: {
            Text("Clear")
        }
    }
}

実行したスクリーンキャプチャ #

Dictionaryのため、順序が入れ替わりますが、StateとBindingが正しく動作し、UserDefaultsにも保存できていることがわかると思います。

解説 #

Property Wrapper #

まず、SwiftUIの@AppStorageのように書きたかったので、Property Wrapperを作ります。 今回は、DictionaryDefaultsという名前のProperty Wrapperを定義しました。

@propertyWrapper
struct DictionaryDefaults {
}

Property Wrapperは、var wrappedValueを必ず持ちます。 Property Wrapperを使用して宣言した変数は、このwrappedValueを経由して値をやり取りすることになります。 例えば、上の例のFirstViewでは、DictionaryDefaultsをmyDictionaryとして宣言してmyDictionary.removeAll()などしていますが、このmyDictionaryは、DictionaryDefaultsのwrappedValueということになります。

今回は、DictionaryDefaults内に、private var dictionary: [String: Any]をStored Propertyとして用意し、それをwrappedValue経由でアクセスできるようにしました。 そして、dictionaryが更新されるたびに、UserDefaultsに反映するように didSet で保存処理を実装しておきました。

private var dictionary: [String: Any] {
    didSet {
        userDefaults.set(dictionary, forKey: key)
    }
}

var wrappedValue: [String: Any] {
    get {
        dictionary
    }

    nonmutating set {
        dictionary = newValue
    }
}

ちなみに、初期値を受け入れられるように、initの第一引数にwrappedValueを定義しています。 引数の一番最初に出てくるwrappedValueは、プロパティを初期化する時の値を自動で受けてくれます。 つまり、以下のコードは、=の右辺の結果を、引数のwrappedValueに自動的に挿入して、プロパティを初期化しているということになります。

@DictionaryDefaults(key: "my_dict") var myDict = [String: Any]()



// DictionaryDefaults
// 第一引数に、先の `=` の右辺 [String: Any]() が入ってくる。
init(wrappedValue defautls: [String: Any], key: String, suiteName: String? = nil)

Stateの振る舞いの追加 #

Property Wrapperにしただけでは、値の変更に応じたViewの更新ができないので、さらにコードを追加していきます。 dictionary@Stateにします。ですが、まだまだこれだけでは、値の更新をViewに反映できません。 DictionaryDefaultsをDynamicPropertyに準拠させます。こうすることで、値の更新がViewに通知されるようになります。 DynamicPropertyの細かい仕様は、残念ながら理解していません……。

@propertyWrapper
struct DictionaryDefaults: DynamicProperty {
    @State private var dictionary: [String: Any] {
        ...

ちなみに、State自体もDynamicPropertyに準拠しているようです。 ということは、今回実装したDictionaryDefaultsもPropertyに準拠させておかないと、Stateの更新をViewに伝播できないということなのでしょう。

ひとまずこれで、Stateの振る舞いを追加することができました。

Bindingの振る舞いを追加 #

最後に、Bindingもできるようにしておきます。SwiftUIでは、Stateをつけた変数は、$variableのように$を先頭につけることで、変数のBindingを得ることができます。この$は、Property Wrapperで扱えるようになる projectedValue のことを指しています。この振る舞いに合わせるため、DictionaryDefaultsも、projectedValueでBindingを返すように実装します。

var projectedValue: Binding<[String: Any]> {
    Binding {
        wrappedValue
    } set: { newValue in
        wrappedValue = newValue
    }
}

Bindingの初期化には、init(get:set:)を使用しました。 wrappedValueをget/setするようにしていますが、今回の場合はdictionaryでも問題ありませんでした。 wrappedValueにしておくほうが、なんとなく安心なのかなという気がしています(Viewで参照するのはwrappedValueの方なので)。

ここまでの実装で、Bindingができるようになりました。 これにより、以下のように、DictionaryDefaultsの状態を、他の画面にBindできるようになりました。

struct FirstView: View {
    @DictionaryDefaults(key: "my_dict")
    private var myDictionary = [String: Any]()
    ...

    var body: some View {
        NavigationStack {
            List {
                ...
            }
            .sheet(isPresented: $isPresentSecondView) {
                SecondView(dict: $myDictionary)
            }
        }
    }
}

struct SecondView: View {
    @Binding var dict: [String: Any]
    ...
}

これで、Dictionary型の値を扱うUserDefaultsを、SwiftUIでも簡単に扱えるようになりました。

参考資料