
State Restoration
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates state preservation and restoration for navigation paths, tab selection, scroll positions, and form data across app launches and termination.
About
Generates state restoration infrastructure that saves and restores selected tab, scroll position, navigation path, and form data across launches and background termination using @SceneStorage, @AppStorage, and file-based persistence. A developer uses it to remember where the user left off.
- Restores tab, scroll, navigation path, and form data
- Codable state models with @SceneStorage and @AppStorage
State Restoration by the numbers
- 3 all-time installs (skills.sh)
- +1 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #887 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill state-restorationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates state preservation and restoration for navigation paths, tab selection, scroll positions, and form data across app launches and termination.
Files
State Restoration Generator
Generate production state restoration infrastructure that saves and restores app state (selected tab, scroll position, navigation path, form data) across launches and background termination. Uses Codable state models, @SceneStorage, @AppStorage, and custom file-based persistence.
When This Skill Activates
Use this skill when the user:
- Asks to "add state restoration" or "restore state"
- Wants to "save app state" or "persist UI state"
- Mentions "preserve navigation" or "remember navigation path"
- Asks to "remember scroll position" or "restore scroll position"
- Wants the app to "resume where left off" or "remember where I was"
- Asks about "saving form drafts" or "preserve form data"
- Mentions "tab selection persistence" or "remember selected tab"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Check deployment target (iOS 17+ / macOS 14+ for @Observable)
- [ ] Check for existing state saving code
- [ ] Identify source file locations
2. Conflict Detection
Search for existing state restoration:
Glob: **/*StateRestoration*.swift, **/*AppState*.swift, **/*SceneStorage*.swift
Grep: "SceneStorage" or "NavigationPath" or "selectedTab" or "scrollPosition"If existing state management found:
- Ask if user wants to replace or extend it
- If extending, integrate with the existing approach
3. Navigation Pattern Detection
Determine which navigation pattern the app uses:
Grep: "NavigationStack" or "NavigationSplitView" or "TabView" or "NavigationLink"This affects which restoration components to generate.
Configuration Questions
Ask user via AskUserQuestion:
1. What state to restore? (multi-select)
- Navigation path (back stack, detail selection)
- Selected tab (TabView selection)
- Scroll position (list/scroll view offset)
- Form data (unsaved drafts and input fields)
- All of the above — recommended
2. Storage method?
- @SceneStorage / @AppStorage (simple, per-scene, limited to basic types)
- UserDefaults (shared across scenes, limited size)
- File-based (Codable to JSON file in Application Support — recommended for complex state)
3. Restore behavior?
- Always restore (seamless resume on every launch)
- Time-limited (restore only if last session was within N minutes) — recommended
- Ask user (show "Resume where you left off?" prompt)
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. AppState.swift — Codable struct capturing all restorable state 2. StateRestorationManager.swift — @Observable manager with auto-save and restore
Step 3: Create Feature Files
Based on configuration: 3. NavigationStateModifier.swift — If navigation path selected 4. ScrollRestorationModifier.swift — If scroll position selected 5. TabRestorationModifier.swift — If tab selection selected 6. FormDraftManager.swift — If form data selected
Step 4: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/StateRestoration/ - If
App/exists ->App/StateRestoration/ - Otherwise ->
StateRestoration/
Output Format
After generation, provide:
Files Created
StateRestoration/
├── AppState.swift # Codable state model
├── StateRestorationManager.swift # Auto-save/restore orchestrator
├── NavigationStateModifier.swift # Navigation path persistence (optional)
├── ScrollRestorationModifier.swift # Scroll position persistence (optional)
├── TabRestorationModifier.swift # Tab selection persistence (optional)
└── FormDraftManager.swift # Form draft auto-save (optional)Integration Steps
Basic setup in App struct:
@main
struct MyApp: App {
@State private var stateManager = StateRestorationManager()
var body: some Scene {
WindowGroup {
ContentView()
.environment(stateManager)
}
}
}Restore navigation path:
struct ContentView: View {
@Environment(StateRestorationManager.self) private var stateManager
var body: some View {
@Bindable var sm = stateManager
NavigationStack(path: $sm.navigationPath) {
HomeView()
.navigationDestination(for: Route.self) { route in
RouteView(route: route)
}
}
.modifier(NavigationStateModifier(stateManager: stateManager))
}
}Restore tab selection:
struct MainTabView: View {
@Environment(StateRestorationManager.self) private var stateManager
var body: some View {
@Bindable var sm = stateManager
TabView(selection: $sm.selectedTab) {
HomeTab().tag(0)
SearchTab().tag(1)
ProfileTab().tag(2)
}
.modifier(TabRestorationModifier(stateManager: stateManager))
}
}Restore scroll position:
struct ItemListView: View {
let items: [Item]
var body: some View {
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
}
.modifier(ScrollRestorationModifier(scrollViewID: "item-list"))
}
}Auto-save form drafts:
struct ComposeView: View {
@State private var draftManager = FormDraftManager(formID: "compose")
@State private var title = ""
@State private var body = ""
var body: some View {
Form {
TextField("Title", text: $title)
TextEditor(text: $body)
}
.onAppear { draftManager.restore(into: &title, &body, keys: "title", "body") }
.onChange(of: title) { draftManager.save(key: "title", value: title) }
.onChange(of: body) { draftManager.save(key: "body", value: body) }
.onSubmit { draftManager.clearDraft() }
}
}Testing
@Test
func stateRestoredFromDisk() async throws {
let manager = StateRestorationManager(storage: .file(directory: tempDir))
manager.selectedTab = 2
manager.saveState()
let restored = StateRestorationManager(storage: .file(directory: tempDir))
restored.restoreState()
#expect(restored.selectedTab == 2)
}
@Test
func timeLimitedRestoreExpires() async throws {
let manager = StateRestorationManager(
storage: .file(directory: tempDir),
restoreBehavior: .timeLimited(minutes: 30)
)
// Simulate state saved 60 minutes ago
manager.appState.lastSavedDate = Date().addingTimeInterval(-3600)
manager.saveState()
let restored = StateRestorationManager(
storage: .file(directory: tempDir),
restoreBehavior: .timeLimited(minutes: 30)
)
restored.restoreState()
#expect(restored.selectedTab == 0) // Default, not restored
}
@Test
func formDraftClearedOnSubmit() async throws {
let draft = FormDraftManager(formID: "test", storage: .file(directory: tempDir))
draft.save(key: "title", value: "My Draft")
#expect(draft.value(for: "title") == "My Draft")
draft.clearDraft()
#expect(draft.value(for: "title") == nil)
}Common Patterns
Save Navigation Path with Codable Routes
enum Route: Codable, Hashable {
case detail(id: UUID)
case settings
case profile(userID: String)
}
// NavigationPath supports Codable serialization
let representation = navigationPath.codable
let data = try JSONEncoder().encode(representation)Restore Tab Selection with String Tags
// Use String tags instead of Int for readability and stability
TabView(selection: $stateManager.selectedTab) {
HomeView().tag("home")
SearchView().tag("search")
ProfileView().tag("profile")
}Preserve Form Draft with Debounced Save
// Save draft only after user pauses typing (1 second)
.onChange(of: textContent) {
draftManager.save(key: "content", value: textContent)
// Internally debounced — won't write to disk on every keystroke
}Gotchas
- @SceneStorage only works with simple types — String, Int, Double, Bool, URL, Data. For complex state like NavigationPath, use
NavigationPath.CodableRepresentationconverted to Data. - NavigationPath Codable limitations — All types in the path must conform to both Codable and Hashable. If any route type changes between versions, decoding fails silently. Always wrap in try/catch and fall back to empty path.
- Background termination vs force quit — State is saved when the app enters background (via
scenePhase). Force quit from the app switcher does NOT triggerscenePhasechange on iOS. Save state on every significant change, not just on backgrounding. - State migration between versions — When adding new fields to AppState, provide default values so old JSON files decode without error. Consider a
stateVersionfield for breaking changes. - @SceneStorage is per-scene — On iPad with multiple windows, each scene has its own @SceneStorage. This is usually correct (each window has its own state) but be aware when sharing state across scenes.
- Avoid saving sensitive data — Never persist passwords, tokens, or PII in state restoration files. Use Keychain for sensitive data.
References
- templates.md — All production Swift templates
- Related:
generators/persistence-setup— Core Data / SwiftData persistence layer - Related:
generators/deep-linking— URL-based navigation restoration
State Restoration Code Templates
Production-ready Swift templates for state restoration infrastructure. All code targets iOS 17+ / macOS 14+ and uses @Observable, Codable, and modern Swift concurrency.
AppState.swift
import Foundation
/// Codable model capturing all restorable app state.
///
/// Add fields for each piece of UI state you want to persist
/// across app launches and background termination.
///
/// Usage:
/// ```swift
/// var state = AppState()
/// state.selectedTab = 2
/// state.navigationPathData = try? JSONEncoder().encode(path.codable)
/// ```
struct AppState: Codable, Sendable {
/// Currently selected tab index or tag.
var selectedTab: Int = 0
/// Encoded NavigationPath data for restoration.
/// Use `NavigationPath.CodableRepresentation` to encode/decode.
var navigationPathData: Data?
/// Scroll positions keyed by scroll view identifier.
/// Maps a scroll view ID string to the ID of the item at the top.
var scrollPositions: [String: String] = [:]
/// Form draft values keyed by form ID, then field key.
/// Cleared when the form is successfully submitted.
var formDrafts: [String: [String: String]] = [:]
/// Timestamp of the last successful save.
/// Used for time-limited restore behavior.
var lastSavedDate: Date = .distantPast
/// State schema version for migration support.
/// Increment when making breaking changes to this struct.
var stateVersion: Int = 1
}StateRestorationManager.swift
import Foundation
import SwiftUI
/// Storage backend for state persistence.
enum StateStorageMethod {
/// Persist to a JSON file in Application Support.
case file(directory: URL? = nil)
/// Persist to UserDefaults with a given suite name.
case userDefaults(suiteName: String? = nil)
}
/// Controls when saved state is restored.
enum RestoreBehavior {
/// Always restore saved state on launch.
case always
/// Restore only if the state was saved within the given number of minutes.
case timeLimited(minutes: Int)
/// Show a prompt asking the user whether to restore.
case askUser
}
/// Central manager for saving and restoring app state.
///
/// Observes state changes and auto-saves with debouncing.
/// Restores state on initialization based on the configured behavior.
///
/// Usage:
/// ```swift
/// @State private var stateManager = StateRestorationManager()
///
/// ContentView()
/// .environment(stateManager)
/// ```
@Observable
final class StateRestorationManager {
// MARK: - Public State
/// The currently selected tab.
var selectedTab: Int = 0
/// The navigation path for NavigationStack.
var navigationPath = NavigationPath()
/// Whether the user should be prompted to restore (for .askUser behavior).
var showRestorePrompt = false
// MARK: - Private
private var appState = AppState()
private let storage: StateStorageMethod
private let restoreBehavior: RestoreBehavior
private let fileURL: URL
private var saveTask: Task<Void, Never>?
private let debounceInterval: Duration
// MARK: - Init
init(
storage: StateStorageMethod = .file(),
restoreBehavior: RestoreBehavior = .timeLimited(minutes: 30),
debounceInterval: Duration = .milliseconds(500)
) {
self.storage = storage
self.restoreBehavior = restoreBehavior
self.debounceInterval = debounceInterval
switch storage {
case .file(let directory):
let dir = directory ?? FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("StateRestoration", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.fileURL = dir.appendingPathComponent("appState.json")
case .userDefaults:
self.fileURL = URL(fileURLWithPath: "/dev/null") // Not used for UserDefaults
}
restoreState()
}
// MARK: - Save
/// Save current state immediately.
func saveState() {
appState.selectedTab = selectedTab
appState.lastSavedDate = Date()
// Encode navigation path
if let codable = navigationPath.codable {
appState.navigationPathData = try? JSONEncoder().encode(codable)
}
persistState()
}
/// Schedule a debounced save. Call this on every state change.
func scheduleSave() {
saveTask?.cancel()
saveTask = Task { [weak self] in
try? await Task.sleep(for: self?.debounceInterval ?? .milliseconds(500))
guard !Task.isCancelled else { return }
self?.saveState()
}
}
/// Save state when the app enters the background.
///
/// Call from `.onChange(of: scenePhase)`:
/// ```swift
/// .onChange(of: scenePhase) { _, newPhase in
/// if newPhase == .background {
/// stateManager.saveOnBackground()
/// }
/// }
/// ```
func saveOnBackground() {
saveTask?.cancel()
saveState()
}
// MARK: - Restore
/// Restore state from persistent storage.
func restoreState() {
guard let loaded = loadState() else { return }
switch restoreBehavior {
case .always:
applyState(loaded)
case .timeLimited(let minutes):
let elapsed = Date().timeIntervalSince(loaded.lastSavedDate)
let limit = TimeInterval(minutes * 60)
if elapsed <= limit {
applyState(loaded)
}
case .askUser:
appState = loaded
showRestorePrompt = true
}
}
/// Apply the pending restored state (called after user confirms in .askUser mode).
func confirmRestore() {
applyState(appState)
showRestorePrompt = false
}
/// Discard the pending restored state.
func declineRestore() {
appState = AppState()
showRestorePrompt = false
clearState()
}
// MARK: - Scroll Positions
/// Save the scroll position for a given scroll view.
func saveScrollPosition(id scrollViewID: String, topItemID: String) {
appState.scrollPositions[scrollViewID] = topItemID
scheduleSave()
}
/// Get the saved scroll position for a given scroll view.
func scrollPosition(for scrollViewID: String) -> String? {
appState.scrollPositions[scrollViewID]
}
// MARK: - Form Drafts
/// Save a form field value as a draft.
func saveFormDraft(formID: String, key: String, value: String) {
if appState.formDrafts[formID] == nil {
appState.formDrafts[formID] = [:]
}
appState.formDrafts[formID]?[key] = value
scheduleSave()
}
/// Get a saved draft value for a form field.
func formDraftValue(formID: String, key: String) -> String? {
appState.formDrafts[formID]?[key]
}
/// Clear all draft data for a form (call on successful submission).
func clearFormDraft(formID: String) {
appState.formDrafts.removeValue(forKey: formID)
scheduleSave()
}
// MARK: - Clear
/// Clear all saved state.
func clearState() {
appState = AppState()
selectedTab = 0
navigationPath = NavigationPath()
switch storage {
case .file:
try? FileManager.default.removeItem(at: fileURL)
case .userDefaults(let suiteName):
let defaults = suiteName.map { UserDefaults(suiteName: $0) } ?? UserDefaults.standard
defaults?.removeObject(forKey: stateDefaultsKey)
}
}
// MARK: - Private Helpers
private let stateDefaultsKey = "com.app.stateRestoration.appState"
private func applyState(_ state: AppState) {
appState = state
selectedTab = state.selectedTab
// Restore navigation path
if let data = state.navigationPathData,
let codable = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self,
from: data
) {
navigationPath = NavigationPath(codable)
}
}
private func persistState() {
guard let data = try? JSONEncoder().encode(appState) else { return }
switch storage {
case .file:
try? data.write(to: fileURL, options: .atomic)
case .userDefaults(let suiteName):
let defaults = suiteName.map { UserDefaults(suiteName: $0) } ?? UserDefaults.standard
defaults?.set(data, forKey: stateDefaultsKey)
}
}
private func loadState() -> AppState? {
let data: Data?
switch storage {
case .file:
data = try? Data(contentsOf: fileURL)
case .userDefaults(let suiteName):
let defaults = suiteName.map { UserDefaults(suiteName: $0) } ?? UserDefaults.standard
data = defaults?.data(forKey: stateDefaultsKey)
}
guard let data else { return nil }
return try? JSONDecoder().decode(AppState.self, from: data)
}
}
// MARK: - Environment Integration
private struct StateRestorationManagerKey: EnvironmentKey {
static let defaultValue: StateRestorationManager? = nil
}
extension EnvironmentValues {
var stateRestorationManager: StateRestorationManager? {
get { self[StateRestorationManagerKey.self] }
set { self[StateRestorationManagerKey.self] = newValue }
}
}NavigationStateModifier.swift
import SwiftUI
/// ViewModifier that persists and restores NavigationStack path.
///
/// Wraps a NavigationStack and automatically saves the path
/// whenever it changes. Restores on appear.
///
/// Usage:
/// ```swift
/// NavigationStack(path: $stateManager.navigationPath) {
/// RootView()
/// .navigationDestination(for: Route.self) { route in
/// RouteView(route: route)
/// }
/// }
/// .modifier(NavigationStateModifier(stateManager: stateManager))
/// ```
///
/// **Important:** All route types in the NavigationPath must conform
/// to both `Codable` and `Hashable`. If any type changes between
/// app versions, decoding fails and the path resets to empty.
struct NavigationStateModifier: ViewModifier {
let stateManager: StateRestorationManager
@Environment(\.scenePhase) private var scenePhase
func body(content: Content) -> some View {
content
.onChange(of: stateManager.navigationPath) {
stateManager.scheduleSave()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
stateManager.saveOnBackground()
}
}
}
}
extension View {
/// Attach navigation state persistence to a NavigationStack.
func persistNavigationState(using stateManager: StateRestorationManager) -> some View {
modifier(NavigationStateModifier(stateManager: stateManager))
}
}ScrollRestorationModifier.swift
import SwiftUI
/// ViewModifier that saves and restores scroll position by item ID.
///
/// Uses @SceneStorage for lightweight per-scene persistence and
/// ScrollViewReader to programmatically scroll on restore.
///
/// Usage:
/// ```swift
/// ScrollView {
/// LazyVStack {
/// ForEach(items) { item in
/// ItemRow(item: item)
/// }
/// }
/// }
/// .modifier(ScrollRestorationModifier(scrollViewID: "item-list"))
/// ```
///
/// For this modifier to work, each item in the scroll view must have
/// an `.id()` that matches the string stored in scroll state.
struct ScrollRestorationModifier: ViewModifier {
/// Unique identifier for this scroll view (used as the storage key).
let scrollViewID: String
@SceneStorage private var savedScrollPosition: String?
@State private var hasRestored = false
init(scrollViewID: String) {
self.scrollViewID = scrollViewID
_savedScrollPosition = SceneStorage(wrappedValue: nil, "scroll_\(scrollViewID)")
}
func body(content: Content) -> some View {
ScrollViewReader { proxy in
content
.onAppear {
guard !hasRestored, let savedID = savedScrollPosition else { return }
hasRestored = true
// Slight delay to let the ScrollView populate
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
withAnimation(.none) {
proxy.scrollTo(savedID, anchor: .top)
}
}
}
}
}
/// Call this to update the saved scroll position when visible items change.
///
/// Typically called from a GeometryReader or onAppear of list items:
/// ```swift
/// .onAppear { scrollModifier.updatePosition(topItemID: item.id.uuidString) }
/// ```
func updatePosition(topItemID: String) {
savedScrollPosition = topItemID
}
}
/// Convenience: scroll position tracker for items in a LazyVStack.
///
/// Attach to each item row to track which item is at the top.
///
/// Usage:
/// ```swift
/// LazyVStack {
/// ForEach(items) { item in
/// ItemRow(item: item)
/// .modifier(ScrollPositionTracker(
/// itemID: item.id.uuidString,
/// scrollViewID: "item-list",
/// stateManager: stateManager
/// ))
/// }
/// }
/// ```
struct ScrollPositionTracker: ViewModifier {
let itemID: String
let scrollViewID: String
let stateManager: StateRestorationManager
func body(content: Content) -> some View {
content
.onAppear {
stateManager.saveScrollPosition(id: scrollViewID, topItemID: itemID)
}
}
}
extension View {
/// Track this item's visibility for scroll position restoration.
func trackScrollPosition(
itemID: String,
scrollViewID: String,
stateManager: StateRestorationManager
) -> some View {
modifier(ScrollPositionTracker(
itemID: itemID,
scrollViewID: scrollViewID,
stateManager: stateManager
))
}
}TabRestorationModifier.swift
import SwiftUI
/// ViewModifier that persists the selected tab to @AppStorage.
///
/// Automatically saves the tab selection whenever it changes
/// and restores it on app launch.
///
/// Usage:
/// ```swift
/// TabView(selection: $stateManager.selectedTab) {
/// HomeView().tag(0)
/// SearchView().tag(1)
/// ProfileView().tag(2)
/// }
/// .modifier(TabRestorationModifier(stateManager: stateManager))
/// ```
///
/// For String-based tab tags, use `TabRestorationStringModifier` instead.
struct TabRestorationModifier: ViewModifier {
let stateManager: StateRestorationManager
@Environment(\.scenePhase) private var scenePhase
func body(content: Content) -> some View {
content
.onChange(of: stateManager.selectedTab) {
stateManager.scheduleSave()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
stateManager.saveOnBackground()
}
}
}
}
/// Standalone tab persistence using @AppStorage (no StateRestorationManager needed).
///
/// Use this for simple cases where you only need tab persistence.
///
/// Usage:
/// ```swift
/// struct ContentView: View {
/// @AppStorage("selectedTab") private var selectedTab = 0
///
/// var body: some View {
/// TabView(selection: $selectedTab) {
/// HomeView().tag(0)
/// SearchView().tag(1)
/// ProfileView().tag(2)
/// }
/// }
/// }
/// ```
///
/// For String tags:
/// ```swift
/// @AppStorage("selectedTab") private var selectedTab = "home"
///
/// TabView(selection: $selectedTab) {
/// HomeView().tag("home")
/// SearchView().tag("search")
/// ProfileView().tag("profile")
/// }
/// ```
extension View {
/// Attach tab selection persistence.
func persistTabSelection(using stateManager: StateRestorationManager) -> some View {
modifier(TabRestorationModifier(stateManager: stateManager))
}
}FormDraftManager.swift
import Foundation
/// Auto-saves form field values as the user types, with debounced persistence.
///
/// Clears draft data when the form is successfully submitted.
/// Each form is identified by a unique `formID`.
///
/// Usage:
/// ```swift
/// struct ComposeView: View {
/// @State private var draftManager = FormDraftManager(formID: "compose")
/// @State private var title = ""
/// @State private var bodyText = ""
///
/// var body: some View {
/// Form {
/// TextField("Title", text: $title)
/// TextEditor(text: $bodyText)
/// Button("Submit") { submit() }
/// }
/// .onAppear {
/// title = draftManager.value(for: "title") ?? ""
/// bodyText = draftManager.value(for: "body") ?? ""
/// }
/// .onChange(of: title) { draftManager.save(key: "title", value: title) }
/// .onChange(of: bodyText) { draftManager.save(key: "body", value: bodyText) }
/// }
///
/// private func submit() {
/// // ... submit form ...
/// draftManager.clearDraft()
/// }
/// }
/// ```
@Observable
final class FormDraftManager {
/// Unique identifier for the form.
let formID: String
// MARK: - Private
private var drafts: [String: String] = [:]
private let storage: StateStorageMethod
private let fileURL: URL
private var saveTask: Task<Void, Never>?
private let debounceInterval: Duration
private let defaultsKey: String
// MARK: - Init
init(
formID: String,
storage: StateStorageMethod = .file(),
debounceInterval: Duration = .seconds(1)
) {
self.formID = formID
self.storage = storage
self.debounceInterval = debounceInterval
self.defaultsKey = "com.app.formDraft.\(formID)"
switch storage {
case .file(let directory):
let dir = directory ?? FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("FormDrafts", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.fileURL = dir.appendingPathComponent("\(formID).json")
case .userDefaults:
self.fileURL = URL(fileURLWithPath: "/dev/null")
}
loadDrafts()
}
// MARK: - Public API
/// Save a field value. Debounced — won't write to disk on every keystroke.
func save(key: String, value: String) {
drafts[key] = value
scheduleSave()
}
/// Get the saved draft value for a field.
func value(for key: String) -> String? {
drafts[key]
}
/// Restore saved draft values into bindings.
///
/// Usage:
/// ```swift
/// .onAppear {
/// title = draftManager.value(for: "title") ?? ""
/// body = draftManager.value(for: "body") ?? ""
/// }
/// ```
func allDraftValues() -> [String: String] {
drafts
}
/// Check if there is a saved draft for this form.
var hasDraft: Bool {
!drafts.isEmpty
}
/// Clear all draft data for this form.
///
/// Call this after the form is successfully submitted.
func clearDraft() {
saveTask?.cancel()
drafts.removeAll()
switch storage {
case .file:
try? FileManager.default.removeItem(at: fileURL)
case .userDefaults(let suiteName):
let defaults = suiteName.map { UserDefaults(suiteName: $0) } ?? UserDefaults.standard
defaults?.removeObject(forKey: defaultsKey)
}
}
// MARK: - Private
private func scheduleSave() {
saveTask?.cancel()
saveTask = Task { [weak self] in
try? await Task.sleep(for: self?.debounceInterval ?? .seconds(1))
guard !Task.isCancelled else { return }
self?.persistDrafts()
}
}
private func persistDrafts() {
guard let data = try? JSONEncoder().encode(drafts) else { return }
switch storage {
case .file:
try? data.write(to: fileURL, options: .atomic)
case .userDefaults(let suiteName):
let defaults = suiteName.map { UserDefaults(suiteName: $0) } ?? UserDefaults.standard
defaults?.set(data, forKey: defaultsKey)
}
}
private func loadDrafts() {
let data: Data?
switch storage {
case .file:
data = try? Data(contentsOf: fileURL)
case .userDefaults(let suiteName):
let defaults = suiteName.map { UserDefaults(suiteName: $0) } ?? UserDefaults.standard
data = defaults?.data(forKey: defaultsKey)
}
guard let data else { return }
drafts = (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
}
}