Reputation: 5277
I've been using @AppStorage
for a long time, but it came to my attention that there seems to be an issue with how @AppStorage properties work with ObservableObject
, or rather with multiple ObservableObject
.
Basically, when using a shared @AppStorage
property in two or more ObservableObject
s, if the property is updated in one of them, the other doesn't receive the information and is effectively desynced with the actual value. Updating the desynced property triggers an update on the View
s, but doesn't sync the value with the other ObservableObject
s.
I recorded a video of this behavior: https://youtube.com/shorts/kbFM2W0IvRo
Here's my sample project:
struct ContentView: View {
var body: some View {
TabView {
TabAView()
.tabItem {
Label {
Text("From View")
} icon: {
Image(systemName: "square")
}
}
TabAView()
.tabItem {
Label {
Text("From View 2")
} icon: {
Image(systemName: "circle")
}
}
TabBView()
.tabItem {
Label {
Text("From ObservableObj")
} icon: {
Image(systemName: "triangle")
}
}
TabBView()
.tabItem {
Label {
Text("From ObservableObj 2")
} icon: {
Image(systemName: "fireworks")
}
}
}
}
}
struct TabAView: View {
@AppStorage("test") private var increment = 0
var body: some View {
VStack {
Text("\(increment)")
Button {
increment += 1
} label: {
Text("Increment from View")
}
}
}
}
final class TabBModel: ObservableObject {
@AppStorage("test") var increment = 0
}
struct TabBView: View {
@StateObject private var model = TabBModel()
var body: some View {
VStack {
Text("\(model.increment)")
Button {
model.increment += 1
} label: {
Text("Increment from ObservableObject")
}
}
}
}
I filed a feedback to Apple over this issue: FB13250915
.
This issue seems to have started from the moment I started building my projects with Xcode 15. I've tried building on 15.1 Beta 1, and also 14.3.1 with the same results.
I have two questions:
@AppStorage
properties in more than one ObservableObject
. Or have I missed a glaring issue in the code shown above?@AppStorage
anymore, but I'm not sure what the best alternative would be.Upvotes: 0
Views: 545
Reputation: 5277
So, I don't have a direct answer to the problem I described, but here's the workaround I found:
This ObservableObject is my replacement to @AppStorage. It's not as simple, but it works. You add it as a @StateObject to your app and you can pass it down via EnvironmentObject
.
@StateObject private var storageController = StorageController.shared
You can also use it as a Singleton, and this is the way to use it in other ObservableObject
s.
Here it is:
final class StorageController: ObservableObject {
// MARK: - Properties
private var tasks = [String: Task<Void,Error>]()
@Published var codableItem: ChargingSession? = decodeFromUserDefaults(type: SomeCodable.self, key: "codableItem") {
didSet {
saveDataToUserDefaults(codableItem, key: "codableItem")
}
}
@Published var booleanItem: Bool = UserDefaults.standard.bool(forKey: "booleanItem") {
didSet {
saveToUserDefaults(booleanItem, key: "booleanItem")
}
}
// MARK: - Singleton
static let shared = StorageController()
private init() { }
// MARK: - Helpers
/// Save a Codable type to UserDefaults.
///
/// The saving will be performed in a background queue.
private func saveDataToUserDefaults<Value: Codable>(_ value: Value, key: String) {
tasks[key]?.cancel()
tasks[key] = Task(priority: .background) {
try Task.checkCancellation()
let data = try JSONEncoder().encode(value)
UserDefaults.standard.set(data, forKey: key)
}
}
private func saveToUserDefaults(_ value: Any?, key: String) {
tasks[key]?.cancel()
tasks[key] = Task(priority: .background) {
try Task.checkCancellation()
UserDefaults.standard.set(value, forKey: key)
}
}
}
/// Decode a Codable conforming type from UserDefaults.
func decodeFromUserDefaults<DecodedType: Codable>(type: DecodedType.Type, key: String) -> DecodedType? {
guard let data = UserDefaults.standard.data(forKey: UserDefaultsKey.chargingSession()),
let decoded = try? JSONDecoder.extended8601Decoder.decode(type, from: data) else {
return nil
}
return decoded
}
In your View
s, you can still rely on @AppStorage, no need to bother with the following.
But in your ObservableObject
s, you can do this:
@Published private var booleanItem: Bool = StorageController.shared.booleanItem {
willSet {
StorageController.shared.booleanItem = newValue
}
}
Here, we do three things:
init
StorageController.shared.$booleanItem
.receive(on: RunLoop.main)
.filter { [weak self] newValue in
newValue != self?.booleanItem
}
.assign(to: &$notificationsEnabled)
This code ensures our property always has the latest value. The filter
part is crucial because it prevents an infinite loop that the willSet
above would trigger.
Upvotes: 1