← LearnYour first screenBuild a 'Recent Activity' screen from scratch: a header with a language toggle, a hero with welcome copy and a refresh button, and a scrollable collection of activity rows wired to a ViewModel. You will touch every piece of the JsonUI pipeline — spec → layout → cell → ViewModel → build → verify — and end with a working screen you can extend into a real app.~12 min read
Pick the shapeA real screen is three frames: the chrome (header), the greeting (hero), and the content (collection). That trio covers most lists, feeds, dashboards, and home tabs you will ever build. Picking the shape before writing the spec keeps the spec focused — you only declare what these three frames need, nothing more.
Author the specFive uiVariables cover everything: two strings for the hero copy, a CollectionDataSource for the rows, a visibility string for the empty state, plus the standard currentLanguage elsewhere. Three event handlers: onAppear to seed, onRefresh to reload, and onSelectActivity(id) to navigate. One customType, ActivityRow, describes what a row carries. `jui verify --fail-on-diff` validates the spec and catches spec ↔ generated drift in one pass — run it before moving on.
recent-activity.spec.json
{ "metadata": { "name": "RecentActivity", "platforms": ["ios", "android", "web"], "layoutFile": "recent-activity" }, "stateManagement": { "uiVariables": [ { "name": "welcomeKey", "type": "String", "initial": "\"\"" }, { "name": "taglineKey", "type": "String", "initial": "\"\"" }, { "name": "activities", "type": "CollectionDataSource", "initial": "null" }, { "name": "emptyVisibility", "type": "String", "initial": "\"gone\"" } ], "eventHandlers": [ { "name": "onAppear" }, { "name": "onRefresh" }, { "name": "onSelectActivity", "params": [{ "name": "id", "type": "String" }] } ], "displayLogic": [] }, "dataFlow": { "customTypes": [ { "name": "ActivityRow", "properties": [ { "name": "id", "type": "String" }, { "name": "titleKey", "type": "String" }, { "name": "detailKey", "type": "String" }, { "name": "occurredAt","type": "Date" } ] } ] }}Compose the layoutThree sibling children under a vertical root: the header View, the hero View (using the shared `hero_section` style), and a Collection. Every visible text routes through a binding — @{welcomeKey} and @{taglineKey} for the hero, @{emptyVisibility} for the empty state, @{activities} as the items source. Nothing in the layout knows what the data says; it just declares where it goes.
recent-activity.json
{ "type": "View", "orientation": "vertical", "width": "matchParent", "height": "matchParent", "child": [ { "type": "View", "orientation": "horizontal", "paddings": [16, 24, 16, 24], "background": "#0B1220", "child": [ { "type": "Label", "weight": 1, "fontSize": 20, "fontWeight": "semibold", "fontColor": "#F9FAFB", "text": "header_title" }, { "type": "Button", "style": "lang_toggle_button", "text": "lang_toggle", "onClick": "@{onToggleLanguage}" } ]}, { "type": "View", "style": "hero_section", "child": [ { "type": "Label", "fontSize": 32, "fontWeight": "bold", "fontColor": "#F9FAFB", "text": "@{welcomeKey}" }, { "type": "Label", "fontSize": 15, "fontColor": "#CBD5F5", "text": "@{taglineKey}", "topMargin": 8 }, { "type": "Button", "style": "primary_button", "text": "refresh", "onClick": "@{onRefresh}", "topMargin": 16 } ]}, { "type": "Collection", "items": "@{activities}", "cellIdProperty": "id", "lineSpacing": 12, "itemSpacing": 12, "sections": [ { "cell": "cells/activity_row" } ] }, { "type": "Label", "visibility": "@{emptyVisibility}", "text": "empty_state" } ]}Build the cellThe Collection delegates each row to a cell layout at `cells/activity_row.json`. The cell declares its own data block (titleKey / detailKey / url / onNavigate) — independent of the parent — so the generator can emit a typed cell component with exactly those props. Style with the shared `card_surface` so you do not re-invent spacing + border + corner radius.
cells/activity_row.json
{ "type": "View", "orientation": "vertical", "style": "card_surface", "child": [ { "data": [ { "name": "titleKey", "class": "String" }, { "name": "detailKey", "class": "String" }, { "name": "url", "class": "String" }, { "name": "onNavigate","class": "() -> Void" } ]}, { "type": "Label", "fontSize": 16, "fontWeight": "semibold", "fontColor": "#0B1220", "text": "@{titleKey}" }, { "type": "Label", "topMargin": 4, "fontSize": 14, "fontColor": "#475467", "text": "@{detailKey}" } ]}Wire the ViewModelThe ViewModel owns state and events on every platform. onAppear seeds the hero strings, builds the row list, and wraps it in the platform's CollectionDataSource shape. emptyVisibility is derived from rows.length — keep the derivation in the ViewModel, never in the layout. onRefresh re-runs onAppear; the tap handler pushes a per-row route. Same contract on Swift, Kotlin, and TypeScript — only the platform idioms differ. Switch tabs below to see each.
RecentActivityViewModel.swift
import Foundationimport SwiftJsonUI class RecentActivityViewModel: ObservableObject, RecentActivityViewModelProtocol { let jsonFileName = "recent_activity" @Published var data = RecentActivityData() @Published var navigateToDetail: String? = nil func onAppear() { data.welcomeKey = StringManager.RecentActivity.welcome() data.taglineKey = StringManager.RecentActivity.tagline() let rows: [[String: Any]] = [ [ "cellId": "a1", "titleKey": StringManager.RecentActivity.row1Title(), "detailKey": StringManager.RecentActivity.row1Detail(), "onCellTap": { [weak self] in self?.navigateToDetail = "a1" } as () -> Void, ], [ "cellId": "a2", "titleKey": StringManager.RecentActivity.row2Title(), "detailKey": StringManager.RecentActivity.row2Detail(), "onCellTap": { [weak self] in self?.navigateToDetail = "a2" } as () -> Void, ], ] var ds = CollectionDataSource() var section = CollectionDataSection() section.setCells(viewName: "ActivityRowCellView", data: rows) ds.addSection(section) data.activities = ds data.emptyVisibility = rows.isEmpty ? "visible" : "gone" } func onRefresh() { onAppear() }}Ship it`jui generate project --file recent-activity.spec.json` produces the skeleton Layout + ViewModel base. You fill in the hand-authored halves, run `jui build` after each iteration, use `jui hotload listen` to live-reload iOS + Android while you iterate on the layout (web reloads automatically via `npm run dev`), and close with `jui verify --fail-on-diff` before committing. The commit contains only the spec, the hand-authored layout + cell, the hand-authored ViewModel + page route — everything else is regenerated and gitignored. That's a full screen.
shell
# Generate Layout + ViewModel base from the spec.jui generate project --file recent-activity.spec.json # Build the platforms after each hand-edit iteration.jui build # Live-reload while you iterate on the layout (iOS + Android only).# Web reloads automatically via npm run dev / Next.js HMR.jui hotload listen # Before you commit, prove spec ↔ generated has not drifted.jui verify --fail-on-diff # Ship it.git add docs/screens/ jsonui-doc-web/src/git commit -m 'feat(activity): recent activity screen'Keep going
Writing your first specThe task-focused guide that walks through the spec half in more detail./guides/writing-your-first-spec
Data binding basicsThe three kinds of @{binding} you just used, laid out one at a time./learn/data-binding-basics
Six ways to split a specOnce this single-file spec grows past a couple hundred lines, the Spec section shows five ways to carve it up./spec/split-overview