Skip to content

Commit db5cc6f

Browse files
authored
Merge pull request #60 from PureSwift/feature/appstorage
Add @AppStorage
2 parents 4cdc464 + 81bc940 commit db5cc6f

5 files changed

Lines changed: 256 additions & 1 deletion

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//
2+
// AppStorage.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Values that outlive the process. The core owns no platform APIs, so the
6+
// backing store is a protocol the host installs — a JSON file under the app's
7+
// files directory on Android, an in-memory store in tests. Reads and writes
8+
// go through the same `StateBox` machinery `@State` uses, so a change marks the
9+
// view dirty exactly like any other state write.
10+
//
11+
12+
import Foundation
13+
14+
/// Where `@AppStorage` values are kept between launches.
15+
public protocol AppStorageBackend: AnyObject {
16+
func value(forKey key: String) -> Any?
17+
func set(_ value: Any?, forKey key: String)
18+
}
19+
20+
/// The process-wide store. Defaults to memory so the core works — and tests —
21+
/// without a host; `ViewHost` installs a persistent one on Android.
22+
public enum AppStorageStore {
23+
24+
nonisolated(unsafe) public static var backend: AppStorageBackend = InMemoryAppStorage()
25+
26+
internal static func read<Value>(_ key: String, as type: Value.Type) -> Value? {
27+
backend.value(forKey: key) as? Value
28+
}
29+
30+
internal static func write(_ value: Any?, _ key: String) {
31+
backend.set(value, forKey: key)
32+
}
33+
}
34+
35+
public final class InMemoryAppStorage: AppStorageBackend {
36+
private var values: [String: Any] = [:]
37+
public init() {}
38+
public func value(forKey key: String) -> Any? { values[key] }
39+
public func set(_ value: Any?, forKey key: String) { values[key] = value }
40+
}
41+
42+
/// A JSON file on disk. Small by design: `@AppStorage` is for preferences, and
43+
/// rewriting the whole file per write keeps it consistent without a database.
44+
public final class FileAppStorage: AppStorageBackend {
45+
46+
private let url: URL
47+
private var values: [String: Any]
48+
49+
public init(directory: String, name: String = "app-storage.json") {
50+
self.url = URL(fileURLWithPath: directory).appendingPathComponent(name)
51+
if let data = try? Data(contentsOf: url),
52+
let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
53+
self.values = decoded
54+
} else {
55+
self.values = [:]
56+
}
57+
}
58+
59+
public func value(forKey key: String) -> Any? { values[key] }
60+
61+
public func set(_ value: Any?, forKey key: String) {
62+
if let value { values[key] = value } else { values.removeValue(forKey: key) }
63+
guard let data = try? JSONSerialization.data(withJSONObject: values) else { return }
64+
try? data.write(to: url, options: .atomic)
65+
}
66+
}
67+
68+
// MARK: - The wrapper
69+
70+
/// A value persisted under `key`, readable and writable like `@State`.
71+
@propertyWrapper
72+
public struct AppStorage<Value>: DynamicProperty {
73+
74+
internal let box: StateBox<Value>
75+
internal let key: String
76+
77+
private init(key: String, defaultValue: Value) {
78+
self.key = key
79+
// seed from the store so the first read already reflects what was saved
80+
self.box = StateBox(AppStorageStore.read(key, as: Value.self) ?? defaultValue)
81+
}
82+
83+
public var wrappedValue: Value {
84+
get { box.value }
85+
nonmutating set {
86+
box.value = newValue
87+
AppStorageStore.write(newValue, key)
88+
}
89+
}
90+
91+
public var projectedValue: Binding<Value> {
92+
let key = self.key
93+
let box = self.box
94+
return Binding(
95+
get: { box.value },
96+
set: { box.value = $0; AppStorageStore.write($0, key) }
97+
)
98+
}
99+
}
100+
101+
// Only the types a preferences store can round-trip through JSON.
102+
public extension AppStorage where Value == Bool {
103+
init(wrappedValue: Value, _ key: String) { self.init(key: key, defaultValue: wrappedValue) }
104+
}
105+
public extension AppStorage where Value == Int {
106+
init(wrappedValue: Value, _ key: String) { self.init(key: key, defaultValue: wrappedValue) }
107+
}
108+
public extension AppStorage where Value == Double {
109+
init(wrappedValue: Value, _ key: String) { self.init(key: key, defaultValue: wrappedValue) }
110+
}
111+
public extension AppStorage where Value == String {
112+
init(wrappedValue: Value, _ key: String) { self.init(key: key, defaultValue: wrappedValue) }
113+
}
114+
115+
extension AppStorage: _StatePropertyReflectable {
116+
public var _box: AnyObject { box }
117+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,87 @@ private func focusCallback(_ node: RenderNode) -> Int64? {
376376
case .int(let id)? = mod.args["onChange"] else { return nil }
377377
return Int64(id)
378378
}
379+
380+
@Suite("AppStorage")
381+
struct AppStorageTests {
382+
383+
@Test("A stored value survives a fresh wrapper, as it would a relaunch")
384+
func persistsAcrossWrappers() {
385+
AppStorageStore.backend = InMemoryAppStorage()
386+
// first "launch": default, then the user changes it
387+
struct First: View {
388+
@AppStorage("volume") var volume = 5
389+
var body: some View { Text("\(volume)") }
390+
}
391+
let first = First()
392+
#expect(first.volume == 5)
393+
first.volume = 9
394+
395+
// a fresh wrapper reads what was written, not the default
396+
struct Second: View {
397+
@AppStorage("volume") var volume = 5
398+
var body: some View { Text("\(volume)") }
399+
}
400+
#expect(Second().volume == 9)
401+
}
402+
403+
@Test("Each supported type round-trips under its own key")
404+
func supportedTypes() {
405+
AppStorageStore.backend = InMemoryAppStorage()
406+
struct Screen: View {
407+
@AppStorage("on") var on = false
408+
@AppStorage("count") var count = 0
409+
@AppStorage("ratio") var ratio = 0.0
410+
@AppStorage("name") var name = ""
411+
var body: some View { Text(name) }
412+
}
413+
let screen = Screen()
414+
screen.on = true
415+
screen.count = 42
416+
screen.ratio = 0.75
417+
screen.name = "Coleman"
418+
#expect(AppStorageStore.read("on", as: Bool.self) == true)
419+
#expect(AppStorageStore.read("count", as: Int.self) == 42)
420+
#expect(AppStorageStore.read("ratio", as: Double.self) == 0.75)
421+
#expect(AppStorageStore.read("name", as: String.self) == "Coleman")
422+
// keys stay independent
423+
#expect(Screen().count == 42)
424+
#expect(Screen().name == "Coleman")
425+
}
426+
427+
@Test("The projected binding writes through to the store")
428+
func bindingWritesThrough() {
429+
AppStorageStore.backend = InMemoryAppStorage()
430+
struct Screen: View {
431+
@AppStorage("nickname") var nickname = ""
432+
var body: some View { TextField("Name", text: $nickname) }
433+
}
434+
let host = ViewHost(Screen())
435+
let node = host.evaluate()
436+
guard case .int(let id)? = node.props["onChange"] else {
437+
Issue.record("missing field callback"); return
438+
}
439+
host.callbacks.invokeString(Int64(id), "typed in")
440+
#expect(AppStorageStore.read("nickname", as: String.self) == "typed in")
441+
}
442+
443+
@Test("A file-backed store reloads what a previous instance wrote")
444+
func fileBackedRoundTrip() throws {
445+
let directory = NSTemporaryDirectory() + "appstorage-test-\(UInt32.random(in: 0..<100_000))"
446+
try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
447+
defer { try? FileManager.default.removeItem(atPath: directory) }
448+
449+
let first = FileAppStorage(directory: directory)
450+
first.set(7, forKey: "launches")
451+
first.set("dark", forKey: "theme")
452+
453+
// a second instance is what the next launch sees
454+
let second = FileAppStorage(directory: directory)
455+
#expect(second.value(forKey: "launches") as? Int == 7)
456+
#expect(second.value(forKey: "theme") as? String == "dark")
457+
458+
// and removal sticks
459+
second.set(nil, forKey: "theme")
460+
#expect(FileAppStorage(directory: directory).value(forKey: "theme") == nil)
461+
}
462+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct AppStoragePlayground: View {
8+
9+
@AppStorage("demo.launches") private var launches = 0
10+
@AppStorage("demo.nickname") private var nickname = ""
11+
@AppStorage("demo.notify") private var notify = false
12+
@AppStorage("demo.volume") private var volume = 0.5
13+
14+
var body: some View {
15+
ScrollView {
16+
VStack(alignment: .leading, spacing: 0) {
17+
Example("Survives a relaunch") {
18+
VStack(alignment: .leading, spacing: 8) {
19+
Text("Counter: \(launches)")
20+
Text("Kill and reopen the app — it keeps its value.")
21+
Button("Increment") { launches += 1 }
22+
Button("Reset") { launches = 0 }
23+
}
24+
}
25+
Example("String") {
26+
VStack(alignment: .leading, spacing: 8) {
27+
TextField("Nickname", text: $nickname)
28+
Text("Stored: \(nickname)")
29+
}
30+
}
31+
Example("Bool") {
32+
Toggle("Notifications", isOn: $notify)
33+
}
34+
Example("Double") {
35+
VStack(alignment: .leading, spacing: 8) {
36+
Slider(value: $volume, in: 0...1)
37+
Text("Volume: \(Int(volume * 100))%")
38+
}
39+
}
40+
}
41+
}
42+
.navigationTitle("AppStorage")
43+
}
44+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ struct CatalogEntry: Identifiable {
6464
CatalogEntry(id: "accessibility", title: "Accessibility", screen: AnyCatalogScreen(AccessibilityPlayground())),
6565
CatalogEntry(id: "state", title: "State", screen: AnyCatalogScreen(StatePlayground())),
6666
CatalogEntry(id: "preference", title: "Preferences", screen: AnyCatalogScreen(PreferencePlayground())),
67+
CatalogEntry(id: "appstorage", title: "AppStorage", screen: AnyCatalogScreen(AppStoragePlayground())),
6768
CatalogEntry(id: "environment", title: "Environment", screen: AnyCatalogScreen(EnvironmentPlayground())),
6869
CatalogEntry(id: "observable", title: "Observable", screen: AnyCatalogScreen(ObservablePlayground())),
6970
CatalogEntry(id: "bindable", title: "Bindable", screen: AnyCatalogScreen(BindablePlayground())),

Sources/AndroidSwiftUI/MainActivity.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,16 @@ extension MainActivity {
2525
public func onCreateSwift(_ savedInstanceState: BaseBundle?) {
2626
log("\(self).\(#function)")
2727
MainActivity.shared = self
28-
28+
29+
// Point @AppStorage at a file in the app's private storage before any
30+
// view is built, so the first evaluation already reads saved values.
31+
// The path comes through the existing Context binding — no new bridge.
32+
if let directory = (self as AndroidContent.Context).getFilesDir()?.getAbsolutePath() {
33+
AppStorageStore.backend = FileAppStorage(directory: directory)
34+
} else {
35+
log("MainActivity: no files directory; @AppStorage stays in memory")
36+
}
37+
2938
// start app
3039
AndroidSwiftUIMain()
3140

0 commit comments

Comments
 (0)