Rendering JSON as SwiftUI on iOS 17
Flow-UI renders JSON as SwiftUI on iOS 17 using Observation and PageStore. Paste a payload in the playground to see the same pipeline.
Flow-UI renders JSON as SwiftUI on iOS 17 because that is the floor where @Observable and the modern scroll APIs live. There is no iOS 16 shim in the package. PageStore is the observable object. FlowPageView is the view that switches on its state. Paste a payload in the playground to watch the same pipeline without Xcode. The playground is not a documentation slug and not the Simulator. It is a browser preview of the registry idea.
This article is the iOS 17-shaped sibling of JSON to SwiftUI. Installation, Observation, and the store's load states are the point. The architecture names remain: store, registry, page view, dispatcher.
iOS 17 is a requirement, not a suggestion#
Installation states iOS 17 and macOS 14, Swift 6 language mode, no third-party dependencies. Observation replaces the ObservableObject plus @Published stack many SDUI gists still copy. PageStore is @Observable and @MainActor. SwiftUI reads store.state in FlowPageView.body and invalidates when the store publishes a new value. You do not write objectWillChange. You do not wrap the store in StateObject for that reason. You hold the store the way you hold any other @Observable model the view needs.
Why Observation fits a page document#
A page is a value that gets replaced, appended, or mutated. The interesting signals are state, isLoadingMore, and diagnostics. Observation tracks access in the view body. Loading, loaded, failed, and empty each have a branch. Pagination does not swap state to .loading, so the loaded tree stays on screen while isLoadingMore is true. Refresh does not swap to .loading either. It keeps content, resets widget state, and fetches. Those choices only work if the view is looking at a real state machine, not at isLoading: Bool.
Pass a store you created at the call site, typically once per page id. FlowPageView will loadInitial() from .task when it sees .loading and no page yet. UIKit hosts use FlowHostingController(store:) and get the same Observation-backed view.
PageStore is the render input#
JSON does not enter FlowPageView as a string. It enters PageLoader.loadPage as a PageRequest, comes back as Data, and is decoded into PageModel. The store is the boundary between bytes and SwiftUI.
Kind is how iOS 17 networking stays honest#
PageRequest.Kind is initial, refresh, nextPage(postback:), and action. Your loader switches on kind and talks to whatever client the app already has. The framework does not perform HTTP. A fixture Data loader is enough to render JSON as SwiftUI in tests and in debug menus.
In-flight fetch tasks cancel the previous fetch. Two rapid pulls do not race with "last response wins" regardless of age. Pagination uses a separate task. If that task fails, the catch is empty: a healthy page is not blanked. appendNextPage re-reads state after the await so an api mutation that landed during the fetch is not overwritten by a stale capture.
WidgetStateStore hangs off the page store. Observation on the page store is not the same as observation on per-widget state, but both are @Observable and @MainActor. Refresh calls stateStore.reset(). Pagination does not. That is why a stepper count survives page two and dies on pull to refresh.
What SwiftUI actually renders on 17#
FlowPageView uses LazyVStack, pinned section headers, refreshable when the envelope allows it, and a carousel built with scrollTargetLayout plus .viewAligned behavior. Those APIs are why the floor is 17. SectionRenderer switches vertical, carousel, and grid. WidgetRowView applies layout and gestures.
Unknown widgets on a real device#
Debug: UnknownWidgetPolicy.placeholder. Release: .skip. Rendering JSON as SwiftUI includes rendering less JSON than you received. Diagnostics collect unknownType, malformedPayload, droppedElement, and duplicateID. Surface them in debug overlays. Do not wait for a blank page in TestFlight to learn the backend shipped hologram_projector.
Starter widgets register with FlowWidgets.register(on:). They are ordinary WidgetView types. Your types register the same way. Last writer wins, so an override in the app target is a second register call, not a fork.
Playground versus the iOS pipeline#
The docs playground at /playground/ lets you paste a page document and see widgets, layout, and diagnostics. Use it to agree the JSON with the backend. It does not run PageStore. It does not call your PageLoader. It does not prove Observation invalidations. After the payload looks right, drop the same bytes into a loader and wrap FlowPageView in the iOS 17 app.
macOS 14 is supported for the same Observation and scroll stack. The engine is not iPhone-only. Widget views are still @MainActor because View is.
A minimal iOS 17 host#
- Add the
FlowUIproduct. See installation. - Build a
WidgetRegistry. Register starters, then product widgets. - Implement
PageLoaderfor the fourPageRequest.Kindcases you need. - Construct
PageStore(pageID:loader:registry:). - Show
FlowPageView(store:)or pushFlowHostingController.
Deeplinks still need a host ActionHandler on ActionDispatcher. Built-ins cover toast, dismiss, refresh_page, open_bottom_sheet, and api. Mutations from api responses apply on the store: replace page, append or prepend sections, replace or remove a widget by id.
Observation details that bite#
PageStore publishes state, isLoadingMore, and diagnostics. Widget bindings publish through WidgetStateStore. A stepper tick must not re-decode the page. A refresh must not require the stepper view to exist in order to clear counts: the page store calls stateStore.reset() itself.
Decode is detached. Views are main actor. Putting JSONDecoder().decode in body will hitch and skip diagnostics. The store already owns that path.
Empty versus failed: empty is a decoded document with no widgets in allWidgets. Failed is a loader or envelope error. Do not treat unknown widgets as failed. They dropped. The page can still be .loaded.
The implement on iOS guide covers containment and UIKit in tutorial form. This post is the render story: iOS 17, Observation, PageStore, then the playground as a JSON sandbox.
Rendering JSON as SwiftUI on iOS 17 is Observation on a store, not a decoder inside a View body. Keep decode off the main actor (the store already detaches it). Keep views on the main actor. Let the envelope, not a view model per page type, decide what appears.
iOS 17 also means you can use containerRelativeFrame for width fractions, which is how WidgetWidthModifier honours 0.75 in vertical sections as well as carousels. Older gists that sized only carousel items will surprise you: a fraction in a vertical section now does something. That is Observation-era SwiftUI doing what the schema already promised.
Playground, then device, then UIKit#
Paste in /playground/ until the JSON agrees. Drop the same bytes into a loader. Wrap FlowPageView in an iOS 17 target. If the app is still UIKit, push FlowHostingController. There is no iOS 16 compatibility layer. Observation and the modern scroll APIs are why. macOS 14 is the desktop floor for the same reasons.
If a page hitch-stutters, look at image loads and widget count before blaming Observation. Paginate. Do not decode on the main actor. The store already moved decode off it. Your custom loader should return Data quickly or fail. Caching is a loader policy, not a framework cache.
Related documentation
Related articles
- SwiftUI6 min read
JSON to SwiftUI: how a payload becomes a view
JSON becomes SwiftUI when the registry maps a type string to a WidgetView and the renderer emits that view for each widget in the page.
- SwiftUI3 min read
What native means in an SDUI stack
Native SDUI means the widget is a real SwiftUI view: scrolling, gestures, and accessibility come from the platform, not from HTML.
- Schema6 min read
Page, section, widget: the mental model
An SDUI page is sections of widgets. That envelope is the whole contract. Screens and cards are not types in Flow-UI.
More on the blog.
Read the source
Flow-UI is MIT licensed. The schema, renderer and starter widgets live on GitHub.
GitHubGet started with Flow-UI
Install the Swift package, register a widget, and render a page from JSON.
Get started