Nested widgets and accordion
Nesting is a widget that holds more widgets. Accordion is the starter example: decode children through the same registry.
Nesting is a widget that holds more widgets. Accordion is the starter example: decode children through the same registry. There is no second renderer, no child-only protocol, and no extra registration step for types that already live in the table. If image_text_card works on a page, it works inside an accordion. If your order_card is registered, it works there too.
Closed SDUI stacks usually special case composition. A “container” enum case holds a hardcoded list of allowed children. Flow-UI’s accordion holds [AnyWidget]. AnyWidget is the same envelope the page already uses: type, id, layout, data, actions. Decode goes through WidgetDecoding on the registry. Render goes through WidgetRowView. That is the composition proof.
Read the JSON contract on Accordion. This article is the host and backend rules that the file comments imply and that starter widgets as worked examples only named.
Accordion is a widget, not a new page kind#
The type string is accordion. The payload is AccordionContent: a header as TextData, optional initially_expanded, and items as a lossy array of AnyWidget. Missing items becomes []. One malformed child drops. The rest of the group still decodes.
The expanded flag is not written back into JSON. It lives in the page WidgetStateStore, keyed by the accordion’s widget id and the name "expanded". A refresh clears that store. Scrolling the accordion out of a lazy stack does not, which is why the binding is on the store rather than on @State inside the view.
Body: header toggle, then children#
The header is a plain SwiftUI Button that toggles expanded with a short spring. A chevron rotates. When expanded is true, a VStack iterates content.items and wraps each in WidgetRowView. Layout, envelope gestures, unknown placeholders, and impression reporting therefore apply to children exactly as they do to page-level widgets.
That loop is the whole nesting API. If you write a second container (a tab strip, a stacked group, a named slot), copy this pattern. Do not call registry.view(for:context:) unless you are prepared to reimplement layout and unknown handling.
Children use the same registry#
AccordionContent decodes items with LossyArray<AnyWidget>. AnyWidget looks up decoder.userInfo for WidgetDecoding. PageStore installs the host registry on that decoder. Nested types therefore do not need a nested registry.
A #Preview that renders an accordion with an image_text_card child must still put those types in an environment registry, because a preview is not a PageStore. The accordion file’s own preview does that: it builds a WidgetRegistry, registers ImageTextCardWidget.self, and sets \ .flowRegistry. Production pages already did this at launch.
Host widgets nest for free#
You do not register “accordion-compatible” variants. Compatibility is “is this type in the table.” That is the point of an open registry versus a closed Codable enum, which we argued at the protocol layer in WidgetView, WidgetContext, and AnyView erasure.
If a child type is unknown, the child becomes UnknownWidgetContent. Debug builds show a labelled placeholder. Release builds skip it. The accordion header still works. One hologram projector inside a FAQ group does not blank the group.
Nested ids are not unique page-wide#
PageModel.makeWidgetIdentifiersUnique() walks the page header, each section header, each section body, and the footer. It does not walk into widget payloads. Accordion items are type erased behind WidgetContent. Their ids never have to be unique across the whole page.
Two accordions may both contain a child with id faq_answer. Each ForEach is a separate collection. SwiftUI identity is local to that stack. Mutations that target ids (replace_widget, remove_widget) also walk only header, sections, and footer. They do not reach into accordion items. If you need to replace a nested row from an api response, replace the whole accordion widget by its page-level id, or lift that row out of the accordion onto the section.
Inside a single accordion, ids still need to be unique among siblings. ForEach(content.items) uses AnyWidget.id. Duplicate siblings in one parent will render incorrectly. The page-level deduplicator will not save you. Send distinct ids for children of the same parent.
What the backend should send#
Send a stable id on the accordion itself. Expansion state keys off it. A positional fallback (accordion@page.sections.0.widgets.2) is stable across identical bytes, but a reordered section will look like a new accordion and collapse on refresh.
Send ids on children you might one day lift or debug. They are cheap. They make the layout inspector readable.
Do not assume a nested id can be the target of replace_widget as implemented today. That mutation finds a widget in header, section, or footer lists. Nested payloads are not those lists.
When nesting is the right shape#
FAQ groups, “what’s included” expanders, and policy disclosures are the obvious accordion cases. The header is copy. The children are real widgets: a text row, a button row, another image row.
Nesting is the wrong shape when you wanted a new page. A checkout flow that is three full pages of widgets is not three accordions. It is navigation the host owns, or a sheet with its own sections. Accordions are in-page composition.
Nesting is also the wrong shape when the children need independent pagination. An accordion that contains “the rest of the catalogue” will decode the whole list up front. Pagination is a page concern (has_more plus postback), not a widget concern.
Write your own container the same way#
A product bundle_group widget is an accordion with different chrome: maybe always expanded, maybe a grid of children. Decode [AnyWidget] lossily. Render WidgetRowView. Store any local UI flags on WidgetContext.state. Register the type. You have not forked Flow-UI. You have used the same composition proof.
Keep the child list in data. Do not invent a parallel children key at the envelope level unless you are changing the package. The envelope is uniform: type, id, layout, data, actions, tracking. Composition belongs in data for that type.
Failure modes that look like nesting bugs#
If children render as unknown placeholders, the child types are not registered on the registry that decoded the page. Register them at launch. Nested decode cannot see a registry you built later in a different object.
If expansion resets when you scroll, you put @State on the view instead of WidgetStateStore. Lazy stacks recreate views. The store does not.
If expansion resets on pull to refresh, that is intended. PageStore.refresh() calls stateStore.reset() so a new document does not inherit leftover open flags.
If a tap on a child does nothing, the child has no envelope actions and its inner controls are not calling context.dispatch. Nesting does not invent gestures. WidgetRowView attaches envelope taps on the child the same as anywhere else.
If the whole page fails to decode because one nested payload was wrong, you are not going through AnyWidget / LossyArray. The accordion’s init(from:) already uses a lossy list. A custom container that decodes [SomeStruct] with synthesized Decodable will throw on the first bad child and can fail a larger array. Copy the lossy pattern.
Where this sits#
The accordion docs page is the field list. Starter widgets is the catalogue. Native SwiftUI children are still native: VoiceOver, Dynamic Type, and your theme tokens apply because the child is a WidgetView, not a web island. That is the same native argument as SDUI on iOS: native SwiftUI, not WebViews.
Composition stays data. The registry stays one table. Nested ids stay local to the parent that iterates them.
Read next#
A container is a widget whose data holds widgets. Accordion is the worked example. Copy the loop, not a second engine.
Related documentation
Related articles
- Components6 min read
Overriding a starter widget
Last registration wins. Override a starter widget by registering the same type string with your view. No fork of the package.
- Components8 min read
Starter widgets as worked examples
Starter widgets are examples you can override: title block, image text card, banner, tag rail, stepper, accordion, separator, button row.
- Components9 min read
WidgetView, WidgetContext, and AnyView erasure
The registry is the only AnyView erasure point. WidgetView stays generic. WidgetContext is how a widget talks to state and actions.
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