Skip to content
Flow-UILive
On this page

Adding a custom widget

Teach Flow-UI a new widget in three steps from your app: model the payload, write the SwiftUI view, register it.

Ayush MishraPublished 6 min read

Teach Flow-UI a new widget in three steps from your app: model the payload, write the SwiftUI view, register it. You never edit framework source. The Adding a widget guide is the canonical checklist. This article is the same three steps with the reasons tutorials skip, plus where interactions, ephemeral state, and skeletons fit.

The registry article explained the table. The open registry comparison explained why that table is not an enum. Here you add one row.

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

1. Model the payload#

Agree a type string and a data shape with the backend first. Paste a sample in the playground before you write Swift. If the JSON is awkward to read, the model will be awkward to decode.

OrderCardContent.swiftswift
import FlowCore
 
struct OrderCardContent: WidgetContent, Hashable {
    static let widgetType = "order_card"
 
    let orderNumber: TextData
    let status: TagData?
    let items: [TextData]
    let total: TextData?
 
    private enum CodingKeys: String, CodingKey {
        case orderNumber = "order_number"
        case status, items, total
    }
}

WidgetContent is Decodable and Sendable with a static widgetType. That string must match JSON type exactly. A typo looks like an unknown widget: debug placeholder, release skip, unknownType in diagnostics.

Reuse atoms#

TextData, TagData, ColorData, ImageData, FontData buy fonts, colors, dark hex, and theming. A raw String is for identifiers, not copy. Prefer decodeIfPresent for new optional fields. A required key without a default becomes malformedPayload on old documents. That is contained, but users see a hole. Keep the contract young and optional.

2. Write the view#

WidgetView is @MainActor. It requires init(content:context:). Stored properties named content and context satisfy it. Forgetting context when you do not use it still requires the parameter. Keep it. You will need dispatch later.

Do not apply margin, padding, background, or corners. The backend sends those on layout. The renderer applies WidgetLayoutModifier around your body: padding, width, background or gradient, corner clip, border, margin. Pad inside the view and every page double-spaces.

Use FlowText, FlowTag, FlowButton, FlowImage so atoms resolve through ThemeProvider. Raw Text(content.orderNumber.text) throws away font and color.

Envelope tap and long_press attach in WidgetRowView. You do not add onTapGesture for those. Controls inside the payload dispatch explicitly: context.dispatch(content.cta.action) on a FlowButton. Named events: context.dispatch(event: "change") fires whatever the backend put under that name. Built-in action types are toast, dismiss, refresh_page, open_bottom_sheet, and api. Deeplinks are host handlers. There is no router in the package.

Ephemeral state#

Steppers and accordions store counts and expansion in WidgetStateStore, not in @State. Scroll recycling would wipe @State. Use context.state.binding(widgetID:key:default:). Refresh resets the store. Pagination does not. See widget state.

Previews: WidgetContext.inert().

3. Register it#

One generic call. Last write to entries[V.Content.widgetType] wins. Register after FlowWidgets.register(on:) if you are overriding a starter type string. Starters are title_block, image_text_card, banner, button_row, tag_rail, stepper_row, accordion, separator.

swift
struct OrderCardWidget: WidgetView {
    let content: OrderCardContent
    let context: WidgetContext
 
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            HStack {
                FlowText(content.orderNumber)
                if let status = content.status { FlowTag(status) }
            }
            ForEach(Array(content.items.enumerated()), id: \.offset) { _, item in
                FlowText(item)
            }
            if let total = content.total {
                Divider()
                FlowText(total)
            }
        }
    }
}
 
registry.register(OrderCardWidget.self)

Done. The widget works in vertical sections, carousels, grids, headers, footers, sheets, and nested inside accordion. Envelope actions, layout chrome, diagnostics, and the debug overlay apply automatically. You do not teach SectionRenderer a new case.

Skeletons, tests, modules#

Conform to WidgetSkeletonProviding when loading should resemble the real row. The registry stores a skeleton thunk at register time. Skip it and loading uses a generic block.

Decode fixtures with FlowDecoder.make(widgetDecoding:diagnostics:) or decode OrderCardContent directly. FlowCore does not import SwiftUI. Catch backend drift in CI, not in TestFlight. UI tests still belong on FlowPageView for gestures.

Widgets can live in a module. Export the view type and call register from the app or from the module's register(on:). Build one WidgetRegistry at launch. Pass it into every PageStore. A second registry that forgot order_card looks like a backend bug.

When not to add a widget#

If layout and copy changes are enough, use an existing type. New types are binaries. Maps, heavy gestures, and pages that change once a quarter should stay handwritten SwiftUI. When not to use SDUI is that boundary. JSON to SwiftUI is the pipeline this widget joins.

After register, what you get for free#

Layout chrome. Envelope gestures. Diagnostics. Debug overlay. Sheets and headers and footers. Nested accordion slots. You do not implement SectionRenderer cases. You do not add AnyView yourself. The registry is the erasure point.

UnknownWidgetPolicy still applies to everyone else's types, not yours, once you registered. If your payload is malformed, you get malformedPayload and a skip or placeholder. Fix the JSON or the CodingKeys. Do not catch the error in the view. The view never sees a half-decoded model.

Stable id values matter as soon as you bind state or accept replaceWidget. Send id on order_card rows. Positional fallbacks work for static headings. They are the floor.

Three steps: model, view, register. The framework stays closed. Your app's catalogue stays open.

Match the docs guide when you review a PR: atoms in the model, no chrome in the view, one register call, dispatch for inner buttons, bindings for ephemeral keys, optional skeleton. If the PR edits FlowRender, it is the wrong PR. If it adds a switch on type, it is the wrong PR. Three steps. Then stop.

A worked order row, without extra types#

Suppose checkout needs a row with an order number, a status tag, line items, and a total. That is order_card, not a new section type and not a new page type. Put it in a vertical section on a checkout page. Send id so a later api response can replaceWidget. Bind nothing unless the user can edit quantity: quantity is stepper_row or a field on your payload plus WidgetStateStore.

If the backend also wants a banner above the row, that is a second widget in the same section, type banner, already registered by FlowWidgets.register(on:). If they want the row inside an FAQ, nest it in accordion items. You already registered. Nested decode uses the same table.

If two teams register order_card, last write wins. Coordinate in the app target. Do not fork FlowRender to win an argument. If you need a skeleton, add WidgetSkeletonProviding and a shimmering shape that resembles the real row. Loading then looks like checkout instead of a generic bar.

Host networking stays PageLoader. Your widget never calls URLSession. Taps dispatch api or a host deeplink handler. The view stays a view.

This is the whole job of adding a widget: three files of intent, one table write, native SwiftUI from then on. Match the docs guide in review: atoms in the model, no chrome in the view, one register call. If the PR edits FlowRender or adds a switch on type, it is the wrong PR. The catalogue lives in the app. That is the point of an open registry. Model, view, register. Then ship the binary only when the type is new.

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