Shared State Across SwiftUI and WidgetKit

How the SwiftUI app, WidgetKit, caches, and deep links carry the same park state

Hangangjari’s iOS implementation connects the app, widgets, SwiftData cache, App Group snapshots, deep links, and external map-app handoff in one flow. An App Group snapshot is a small bundle of display-ready values shared by the app and widgets.

The app supports short checks before and during a visit to the Han River. A widget shows a frequently visited park, and tapping it opens the same park and screen in the app.

Hangangjari is checked in short moments: before leaving home, before taking the subway, near a parking-lot entrance, or while looking for facilities inside a park. The client had to arrange API values and carry state between those moments.

Even if the network is briefly slow or a widget is reading an old snapshot, the screen has to explain the state so users do not misunderstand it.

The iOS implementation first defined where shared state is stored and how it is read again. API responses are stored in SwiftData, and the app state store reshapes them into a form the UI can use.

Widgets read App Group snapshots, and deep links carry the selected park and screen back into the app. Together they preserve context across the two runtimes.

Code Shared by App and Widget

The client is currently split into three targets.

  • Hangangjari: the SwiftUI main app.
  • HangangjariWidget: parking and general park widgets.
  • HangangjariTests: tests for model, networking, cache, and widget logic.

The app and widgets share model, networking, repository, localization, and widget snapshot code. The WidgetKit extension is not the same process as the app, so values that both sides need are written separately into the App Group area.

flowchart LR
  View["SwiftUI view"] --> Store["App state store\n@Observable @MainActor"]
  Store --> ParkingRepo["Parking repository"]
  Store --> ForecastRepo["Forecast repository"]
  ParkingRepo --> API["API client"]
  ForecastRepo --> API
  ParkingRepo --> Cache["SwiftData cache"]
  ForecastRepo --> Cache
  Store --> Snapshot["Widget snapshot store"]

  Widget["WidgetKit provider"] --> Loader["Widget entry loader"]
  Loader --> Snapshot
  Loader --> API
  Loader --> Location["Widget location resolver"]
  Settings["Widget / favorite settings"] --> AppGroup["App Group UserDefaults"]
  Widget --> AppGroup
  API --> Backend["FastAPI /v2"]

Screen State Coordinated in One Place

The app state store gathers the values each screen needs and delegates network and storage work to repositories.

The current state contains these groups.

  • Park list.
  • Parking-lot lists per park and the latest status per parking lot.
  • Outing overview per park.
  • First-screen HomeSummary.
  • Parking forecast overview and timeline.
  • Park congestion forecast overview and timeline.
  • Screen state derived from favorites, widgets, and deep links.

The app state store does not handle HTTP implementation details directly. Repositories own API calls and SwiftData persistence, while the store builds screen state transitions and display shapes.

The store retains screen-state coordination. Separate components handle URL construction, request proof, decoding, and SwiftData row upsert.

Inside the store, I tried to leave only UI-ready state such as “this park is being viewed,” “this value is loading,” and “this snapshot can be displayed.”

A Repository Between Cache and API

The parking repository handles parks, lots, statuses, outing overview, and home summary. The forecast repository handles forecast config, parking forecasts, park congestion forecasts, and timeline payloads.

Repositories handle API calls, local cache freshness decisions, response storage, and widget snapshot updates.

sequenceDiagram
  autonumber
  participant View as SwiftUI view
  participant Store as App state store
  participant Repo as Repository
  participant Cache as SwiftData cache
  participant API as API client
  participant Snapshot as Widget snapshot store

  View->>Store: Request first-screen values
  Store->>Repo: Check stored first-screen values
  Repo->>Cache: Read cached response
  alt Cache is fresh
    Cache-->>Repo: Usable first-screen value
    Repo-->>Store: Screen model from cache
  else Stale or missing
    Repo->>API: Request first-screen response
    API-->>Repo: Decoded first-screen response
    Repo->>Cache: Store home / parking / forecast values
    Repo-->>Store: Fresh screen model
  end
  Store->>Snapshot: Update parking/general widget display values
  Store-->>View: Update screen state

With this approach, the first screen does not wait for several APIs one by one. The server sends a screen-ready response, the app preserves it in SwiftData, and then the app splits it into shapes for the screen and widgets.

The repository, cache, and snapshot steps provide a specific inspection point when the app reopens, a widget cannot reach the network, or the server adds a field.

WidgetKit Outside the App Process

Hangangjari has two widget types.

  • Parking widget: recommended parking lot, remaining spaces, status, and nearby sorting.
  • General park widget: congestion, weather, event and facility signals, and information for deciding whether to go today.

The widget reads the stored snapshot first. If it is fresh, it uses it. If it is missing or old, it calls the API. If that fails, it falls back to a stale snapshot or placeholder.

flowchart TB
  Provider["Widget timeline provider"] --> Request["Widget load request"]
  Request --> Loader["Widget entry loader"]
  Loader --> Cached["App Group snapshot"]
  Loader --> API["home-summary / forecast / status API"]
  Loader --> Location["Selected widget location"]
  API --> Builder["Snapshot builder"]
  Cached --> Entry["Widget display entry"]
  Builder --> Entry
  Entry --> Timeline["Timeline\nrefresh policy"]

The parking widget and general widget use separate snapshot keys. Even for the same park, the first thing each widget should say is different.

A Common API Shape for App and Widget

The API client calls the v2 app API. Write requests or sensitive requests have a path for attaching an app access token and request proof.

For app access validation, I kept these boundaries.

  • The app and widgets share the same API client structure.
  • Bootstrap flow is separated from protected v2 app APIs.
  • Whether unsigned fallback is allowed on failure is controlled by runtime policy.
  • Write requests such as telemetry and push subscription go through stronger validation than read requests.

The WidgetKit extension runs separately from the app. App Group snapshots, repositories, screen-ready server responses, and deep links therefore have to carry the park and state the user just saw together.

The server and app interpret fresh, stale, and unavailable as the same UI states. Value freshness, retry availability, and the park selected by a deep link have to align for short checks before and during a visit to remain continuous.

App Group snapshots and deep links connect information checked in a widget to the corresponding detail screen in the app.

Comments

Comments

    Image preview