Skip to content
Flow-UILive
On this page

Dynamic SwiftUI screens from a backend

Dynamic SwiftUI screens are pages the backend composes. The client does not hardcode the widget list; it renders whatever the envelope contains.

Ayush MishraPublished 6 min read

Search calls them dynamic SwiftUI screens. In this stack they are pages. The backend composes a page document. The client does not hardcode the widget list. It registers views, loads bytes, and renders whatever the envelope contains. A new heading, a dropped banner, a different section arrangement: those are data deploys, not a new VStack in the app target.

The word screen stays in the title because that is how people search. The types are still page, section, and widget. JSON to SwiftUI explained the conversion. Rendering on iOS 17 explained Observation. This article is the host seam: FlowPageView plus PageLoader.

The Flow-UI mark: two nodes joined through a filled centre

Dynamic means the list is not in the binary#

A hardcoded SwiftUI page names every child. A dynamic page names widget types the binary already knows, then lets JSON pick and order them. That is the specialised SDUI Jacob Bartlett argued for: feeds, merchandising, onboarding. It is not HTML in JSON. If you need a map canvas or a custom gesture arena, keep a handwritten SwiftUI page. When not to use SDUI is that boundary.

What still lives in the binary#

Every WidgetView. Every ActionHandler you care about beyond the built-ins. Your ThemeProvider if you use tokens. PageLoader. Feature flags that gate whether you even fetch a Flow page. Dynamic UI does not replace the app. It replaces the part of the tree that used to be a long body you shipped to change a subtitle.

Starter widgets exist so a backend can compose a useful page on day one: title_block, image_text_card, banner, button_row, tag_rail, stepper_row, accordion, separator. Product widgets join the same registry. Last writer wins.

FlowPageView is the dynamic root#

FlowPageView(store:) is the SwiftUI entry. You do not pass a widget array. You pass a PageStore. The view draws nav, bars, sections, refresh, pagination, sheets, and toasts from whatever the store decoded. Custom loading views are supported. Custom dispatchers are how you add deeplink handlers. Omitting the dispatcher uses built-ins: toast, dismiss, refresh_page, open_bottom_sheet, api.

UIKit does not block you#

FlowHostingController hosts the same FlowPageView for UIKit stacks. Dynamic pages can land in an existing UINavigationController. You do not migrate the app to SwiftUI first. Widgets remain SwiftUI views inside the hosting controller.

DynamicHome.swiftswift
let registry = WidgetRegistry()
FlowWidgets.register(on: registry)
registry.register(OrderCardWidget.self)
 
let store = PageStore(pageID: "home", loader: APILoader(), registry: registry)
FlowPageView(store: store)

pageID is the name you and the backend agree. It is not a route table inside Flow-UI. How you push this view is host navigation.

PageLoader is the only network seam#

PageLoader has one requirement: loadPage(_ request: PageRequest) async throws -> Data. Flow-UI does not ship HTTP. Dynamic pages from a backend still need a backend, or a file, or a mock. The framework must not care which.

Four kinds of request#

PageRequest.Kind.initial is first paint. .refresh is pull to refresh or a refresh_page action. .nextPage(postback:) echoes pagination JSON. .action sends the raw api object. Your implementation maps those to GET, POST, GraphQL, or disk. Return the envelope bytes. Decoding, lossy arrays, and diagnostics stay in the store.

swift
struct APILoader: PageLoader {
    func loadPage(_ request: PageRequest) async throws -> Data {
        switch request.kind {
        case .initial, .refresh:
            return try await client.get("/pages/\(request.pageID)")
        case .nextPage(let postback):
            return try await client.post("/pages/\(request.pageID)/next", body: postback)
        case .action(let payload):
            return try await client.post("/actions", body: payload)
        }
    }
}

Caching, offline snapshots, and prefetch are loader policies. The package does not cache pages. If the backend is down, your loader throws, and the store shows .failed. If you want stale-while-revalidate, return cached Data first from the loader, then refresh. That is still host code.

What the backend is allowed to change#

Order of widgets. Which registered types appear. Copy inside atoms. Layout chrome. Section arrangement among vertical, carousel, and grid. Pagination cursors. Whether pull to refresh is on. Header and footer strips. Actions on events. Nested accordion children, as long as those children are registered types.

The backend cannot invent a type the registry does not name and expect old binaries to draw it. Unknown types drop (release skip, debug placeholder). The backend cannot eval Swift. It cannot push a new WidgetView. It cannot drive NavigationStack unless you wrote a handler that reads a deeplink action.

PageMutation lets an api response replace the page, append or prepend sections, replace a widget id, or remove a widget id. That is dynamic after first paint, still within the envelope.

Dynamic is not a second app runtime#

Guideline 2.5.2 conversations belong in the production post. Declarative JSON that selects compiled widgets is data. Treat this paragraph as a pointer, not legal advice.

The architecture concept is the map: load, decode, resolve, render, act. Dynamic SwiftUI pages are that map with a backend that actually changes the document. Feature flags still toggle code you shipped. Remote config still swaps numbers. SDUI rearranges widgets. They stack, as flags versus SDUI argued.

What FlowPageView will and will not invent#

It will draw nav, sticky bars, sections (vertical, carousel, grid), pull to refresh when the envelope says so, a pagination sentinel, sheets, and toasts. It will install registry, widget state, and dispatcher in the environment. It will call loadInitial() once if the store is still loading.

It will not fetch. PageLoader does. It will not route. Your NavigationStack or UINavigationController does. It will not register widgets. You do, once, at launch. Last writer wins.

Unknown types still drop. A backend that composes freely will ship strings old binaries do not name. That is expected. Diagnostics collect unknownType. Release policy skips. Dynamic composition without containment is a crash loop.

Keep saying page in code reviews even when the ticket says screen. PageStore, FlowPageView, PageLoader. Three names. The backend composes. The client renders native SwiftUI.

PageRequest.Kind.action is how a dynamic page talks back without a second loader protocol. The api handler calls performAction, decodes ActionResponse off the main actor, and applies mutations. The backend can replace one row after a tap. The widget list on the page is still whatever the envelope said, plus the mutation. That is dynamic after first paint, still not a router.

A feed that is actually dynamic#

Monday the envelope is a title, a banner, a tag rail, and a grid of image text rows. Wednesday the banner is gone and a carousel of the same image text type appears. Friday an accordion of FAQs is prepended. No app release. The registry already knew those types. PageLoader returned different bytes. PageStore published a new loaded value. Observation invalidated FlowPageView. That is dynamic UI.

What would have required a release: a map, a new gesture, a widget type nobody registered. Be honest with product. Dynamic pages rearrange the catalogue. They do not grow the catalogue. When not to use SDUI still applies to the handwritten rest of the app. Host caching, auth, and retries stay in PageLoader. The page is dynamic. The network stack is still yours. Feature flags still gate whether you fetch a Flow page at all. They stack with SDUI. They are not the same, as flags versus SDUI already argued. Keep the nouns: page, section, widget.

More on the blog.

Read the source

Flow-UI is MIT licensed. The schema, renderer and starter widgets live on GitHub.

GitHub

Get started with Flow-UI

Install the Swift package, register a widget, and render a page from JSON.

Get started