All skills
rshankras avatar

/state-restoration

@32566aa

Generates state preservation and restoration infrastructure for navigation paths, tab selection, scroll positions, and form data across app launches and background termination. Use when user wants to save/restore app state, remember where the user left off, or persist UI state.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/state-restoration

This session only. Nothing lands on disk.

templates.md

≈5.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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)) ?? [:]
    }
}

Source: SKILL.md on GitHub

No alerts2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    The skill automates the creation of state restoration logic for iOS and macOS applications. It includes standard templates and helpful security guidance advising against the persistence of sensitive data. However, because the skill reads existing project files to inform its code generation, it presents a surface for indirect prompt injection where malicious content in the scanned files could attempt to influence the agent's output.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Edits files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 7 allowed tools
ReadWriteEditGlobGrepBashAskUserQuestion

README badge

README badge for rshankras/claude-code-apple-skills/state-restoration