Skip to content
Flow-UILive
On this page

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.

Ayush MishraPublished 6 min read

An SDUI page is sections of widgets. That envelope is the whole contract. Teams arriving from other products bring extra nouns: screen, card, component, block, module, slice. Flow-UI does not decode those words. If your schema has a type named screen or card, it is not this contract. The three names below are the ones the decoder, the renderer, and the docs share.

This mental model sits on top of the architecture walk. You can name PageStore correctly and still ship JSON that fights the renderer because the document was modelled as a pile of cards. Start from the envelope. The Swift types are PageModel, SectionModel, and AnyWidget.

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

Page is the unit you fetch#

A page is one document: an id, optional nav, optional header and footer bars, a list of sections, optional pagination, optional refresh. The HTTP wrapper is PageResponse: { "page": { ... } }. PageStore loads that document. FlowPageView paints it. Mutations replace or patch it. There is no second root type for "the thing the user is looking at".

What a page is allowed to own#

nav is a bar: title, subtitle, background color, a left button, trailing buttons. header and footer are PageBar values, each a widget strip with a sticky flag. Sticky pins outside the scroll view. Non-sticky scrolls with the sections. Pagination is has_more plus an opaque postback the client echoes. Refresh is pull_to_refresh.

A page is not a navigation graph. It does not list other pages to push. Taps carry ActionData. The host decides whether that means a sheet, a toast, an api mutation, or a deeplink handler you registered. The package does not ship a router.

If the backend omits id, decode fills FlowIdentity.positional("page", in: codingPath). Send a real id anyway. Stores, analytics, and your own cache keys will thank you.

Section is a layout region#

A section groups widgets under one arrangement. It is not a visual "card" around those widgets. Chrome lives on each widget's layout, or on the section's insets and spacing. Mixing a carousel and a vertical list on one page is two sections, not a flag on a widget.

Three arrangements, one fallback#

layout.arrangement is vertical, carousel, or grid. Unknown strings become vertical. columns applies to grid only and defaults to 2. item_spacing is points between widgets. insets pad the whole region. An optional header is one widget that pins while the section scrolls under it.

Sections decode through LossyArray. One child that cannot decode is dropped (droppedElement). The section remains. That is why a merchandising region can survive a bad promotional row.

SectionSketch.swiftswift
let section = SectionModel(
    id: "popular",
    layout: SectionLayout(arrangement: .carousel, itemSpacing: 12),
    widgets: []
)

Do not invent a fourth arrangement in the client for a one-off marketing page. If you need a new region type, that is a package change plus a decode fallback story. Until then, compose with the three you have.

Widget is one native view#

A widget is type, id, layout, data, actions, tracking. Five envelope fields are uniform. data belongs to the type. The framework never inspects data except to hand it to the registered model.

Screens and cards are not types#

There is no ScreenModel. There is no CardModel. Product language on a whiteboard can say "home screen" or "product card". The JSON still says page, section, and widget. The starter type image_text_card is a widget whose widgetType string happens to contain the word card. It is still a widget. Treat it as one row in a section, not as a schema primitive.

Closed enums in gists often name cases screenHeader, productCard, footerComponent. That vocabulary leaks into the payload and then into Android and web. Flow-UI's bet is the smaller vocabulary, so three platforms can share documents without translating nouns.

swift
public struct AnyWidget {
    public var id: String
    public let type: String
    public let layout: WidgetLayout
    public let content: any WidgetContent
}

type selects the registry entry. Missing id becomes prefix@codingPath, for example title_block@page.sections.0.widgets.2. Duplicate ids on the page are renamed id#2 and recorded as duplicateID. Nested widgets inside an accordion payload are not renamed for page-wide uniqueness. They are iterated by the accordion, not by the page ForEach.

How the three nest#

One page. Many sections. Many widgets per section. Header and footer bars are extra widget lists on the page, not sections. A section header is one widget, not a section. Nested widgets (accordion items) are widgets inside a payload, decoded through the same registry.

That nesting is the whole tree. You do not need a fourth layer called "module" unless you are building a CMS on top, and even then the bytes on the wire should flatten to this envelope.

Actions hang on widgets, not on pages. tap, long_press, and change are the usual event names. Built-in action types are toast, dismiss, refresh_page, open_bottom_sheet, and api. Tracking is opaque JSON for the host analytics sink.

Why the vocabulary is strict#

SDUI discussions on the Mobile Native Foundation thread talk about one backend response across iOS, Android, and web. Shared nouns are the cheap part of that dream. If iOS calls it a screen, Android calls it a fragment, and the backend calls it a card, you will spend a year on a mapping layer. Page, section, widget is boring on purpose.

It also matches how SwiftUI wants to diff. PageModel is the document. SectionModel is Identifiable. AnyWidget is Identifiable. Refresh should look like an update to those identities, not like a new "screen" object that throws away scroll position. Positional ids exist so two decodes of the same bytes stay stable. Backend ids exist so a stepper on item_42 is still item_42 after a refresh.

Noun others useFlow-UI typeNotes
Screen, view controller scenePageModelFetched document. Not a type named screen.
Card, cell, moduleAnyWidgetOne registered view. image_text_card is a widget type string.
Component, blockAnyWidgetRegistry key is type. No component table in the schema.
Feed region, shelfSectionModelArrangement plus widgets.

Starter widgets are examples, not a closed world#

FlowWidgets.register(on:) installs title_block, image_text_card, banner, button_row, tag_rail, stepper_row, accordion, and separator. They prove the envelope. Your product types register the same way. The mental model does not change when you add order_row. You still put it in a section on a page.

Pagination is a page concern#

has_more plus opaque postback live on the page, not on a widget. PageStore.loadNextPage appends sections. Failed pagination does not blank a healthy page. If you hang has_more on a list payload, the store will never see it.

Refresh is also a page flag: pull_to_refresh. Refresh keeps content visible, resets WidgetStateStore, and fetches .refresh. That is why a stepper dies on pull and survives page two.

Mutations still use these nouns#

PageMutation replaces a page, appends or prepends sections, or replaces or removes a widget by id. There is no mutation named replaceScreen or replaceCard. If your CMS thinks in those words, flatten at the edge.

Read the narrative contract in pages, sections, and widgets and the field list in the envelope reference. The definition post uses the same three words in the category cluster. The architecture article is how those nouns move through types.

Page, section, widget: learn it once. Refuse extra types until you can point to a decoder that needs them.

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