SwiftUIでDictionary型のAppStorageをProperty Wrapperで実装する
Table of Contents
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でも簡単に扱えるようになりました。
参考資料
- https://www.hackingwithswift.com/plus/intermediate-swiftui/creating-a-custom-property-wrapper-using-dynamicproperty
- https://software.small-desk.com/development/2020/10/03/swift-propertywrapper-projectedvalue/
- https://software.small-desk.com/development/2020/10/02/swift-propertywrapper-initialize/
- https://www.donnywals.com/writing-custom-property-wrappers-for-swiftui/
- https://qiita.com/inuha/items/2f760a0122003a3b90d3