Georgii EmelianovEngineering

From LLM Tool Calls to Native SwiftUI Screens: A Step‑by‑Step Tutorial for Vertical Story Layouts

LLM tool calls are finally reliable enough to drive real UI. But turning those JSON payloads into native SwiftUI screens is an architecture problem, not a…

Terraced quarry landscape symbolizing layered vertical story layouts in native iOS UI.

LLM tool calls are finally reliable enough to drive real UI. But turning those JSON payloads into native SwiftUI screens is an architecture problem, not a prompt trick.

This tutorial walks you through a practical pattern Uzori uses: vertical story layouts that stack images, explanatory text, and data blocks, and stay flexible even when content length or ordering changes at runtime.

You’ll see how to:

  • Take structured LLM responses (tool calls) and validate them server‑side
  • Map them into a constrained SwiftUI schema
  • Render vertical, streaming story layouts that adapt to images, text, and data blocks

1. Prerequisites and Mental Model

Before writing any SwiftUI code, you need a clear mental model: LLM → structured output → server validation → SwiftUI renderer.

1.1 What you’ll need

  • An iOS app using SwiftUI (iOS 17+ is ideal)
  • A backend that can:
    • Call an LLM with tool/function schemas (OpenAI, Anthropic, etc.)
    • Validate JSON against a schema
    • Serve validated payloads to the app via HTTP or WebSockets
  • Basic familiarity with:
    • VStack / LazyVStack
    • AsyncImage
    • Codable models in Swift

If you’re using Uzori, the iOS SDK already wires most of this together:

  • The SDK renders SwiftUI from a constrained schema
  • The Uzori engine calls LLM tools and validates outputs server‑side
  • Screens stream into your app in real time

1.2 Why vertical story layouts?

Vertical story layouts are ideal for disruption flows like rebooking and refunds, which we explore in depth in the related guide: “What are my options now? — rebooking, refunds and the shape of a disruption answer.”

They work well because:

  • Users naturally scroll through a narrative: problem → options → decisions
  • You can stack:
    • Hero image or illustration
    • Short explanatory blocks
    • Data tables or cards (e.g., fare rules, flight alternatives)
  • You can insert or remove blocks at runtime without breaking the layout

2. Step 1 — Design the Server‑Side Story Schema

The most important step is not in SwiftUI. It’s defining the schema the LLM must follow.

2.1 Define content blocks

Model the vertical story as a list of blocks:

{
"story_id": "disruption_123",
"title": "Your trip was disrupted — here are your options",
"blocks": [
{
"type": "image",
"id": "hero",
"url": "https://cdn.example.com/disruption/hero.jpg",
"caption": "Weather-related disruption on your outbound flight."
},
{
"type": "text",
"id": "summary",
"role": "explanatory",
"body": "You can rebook for free, request a refund, or choose travel credit."
},
{
"type": "data",
"id": "options_table",
"title": "Your options at a glance",
"rows": [
{
"label": "Free rebooking",
"value": "Any date within 30 days, subject to availability"
},
{
"label": "Refund",
"value": "Full refund to original payment method"
}
]
}
]
}

Each block has:

  • A type constrained to a known set: image, text, data, action, etc.
  • A unique id for tracking and analytics
  • Type‑specific fields (e.g., url, body, rows)

2.2 Tie schema to LLM tool calls

In your LLM setup (e.g., OpenAI function/tool calling):

  • Define a generate_disruption_story tool that returns exactly this schema
  • Use structured outputs or function calling so the model must obey the schema

OpenAI reports that gpt-4o-2024-08-06 hit 100% JSON‑schema adherence on its own evals, while older gpt-4-0613 was below 40%. Even with strong models, independent research like StructEval shows visual/Renderable formats are still tricky, so server validation is non‑optional.

3. Step 2 — Validate and Normalize on the Server

Never render LLM output directly in your app. The server must become your safety gate.

3.1 Schema validation

On the backend:

  • Use a JSON schema validator or hand‑rolled checks to enforce:
    • Allowed type values
    • Required fields per type
    • URL formats for images
    • Max lengths for text
  • Reject or repair any payload that doesn’t conform

This matches the industry trend:

  • Google’s A2UI uses a trusted component catalog and declarative payloads
  • Shopify’s remote rendering decouples UI definition from UI rendering
  • Digia sends JSON “blueprints” that the device renders as native widgets

3.2 Normalizing for SwiftUI

Before sending to the client, normalize:

  • Convert external IDs into stable internal identifiers
  • Enforce ordering rules where necessary (e.g., always show summary before options)
  • Add any server‑side metadata your app needs:
    • Tracking tags
    • Feature flags
    • Experiments (e.g., different layout variants)

The final payload the app sees should be guaranteed renderable. No surprises.

4. Step 3 — Define Swift Models and a Block Renderer

Now you translate the validated payload into Swift.

4.1 Codable models for blocks

Create Swift types that mirror the server schema:

enum StoryBlockType: String, Codable {
case image
case text
case data
}

struct Story: Codable, Identifiable {
let id: String
let title: String
let blocks: [StoryBlock]
}

struct StoryBlock: Codable, Identifiable {
let id: String
let type: StoryBlockType
let image: ImageBlock?
let text: TextBlock?
let data: DataBlock?
}

struct ImageBlock: Codable {
let url: URL
let caption: String?
}

struct TextBlock: Codable {
let role: String
let body: String
}

struct DataRow: Codable, Identifiable {
let id: String
let label: String
let value: String
}

struct DataBlock: Codable {
let title: String?
let rows: [DataRow]
}

You’re encoding only what you support. The LLM can’t invent new types without backend and client updates.

4.2 A SwiftUI block renderer

Create a single entry point that turns StoryBlock into real views:

struct StoryBlockView: View {
let block: StoryBlock

var body: some View {
switch block.type {
case .image:
if let image = block.image {
ImageBlockView(block: image)
}

case .text:
if let text = block.text {
TextBlockView(block: text)
}

case .data:
if let data = block.data {
DataBlockView(block: data)
}
}
}
}

This is the native renderer part of the pattern: structured payload → typed models → component catalog.

5. Step 4 — Build the Vertical Story Layout in SwiftUI

With the block renderer ready, you can now build the vertical layout.

5.1 Use LazyVStack for performance

Apple’s docs highlight LazyVStack as ideal for long lists: it creates child views only as they appear on screen. That’s exactly what you want for a vertical story with many blocks.

struct StoryView: View {
let story: Story

var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
Text(story.title)
.font(.title2)
.bold()
.padding(.bottom, 8)

ForEach(story.blocks) { block in
StoryBlockView(block: block)
.padding(.horizontal)
}
}
.padding(.vertical)
}
}
}

This pattern:

  • Handles variable content length
  • Keeps performance acceptable, even if the LLM generates dozens of blocks
  • Feels like any other native screen in your app

5.2 Handle streaming updates

If you stream blocks from the server (Uzori does this), maintain them in state:

struct StreamingStoryView: View {
@State private var blocks: [StoryBlock] = []
let title: String

var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
Text(title)
.font(.title2)
.bold()

ForEach(blocks) { block in
StoryBlockView(block: block)
}
}
.padding()
}
.task {
await subscribeToStoryStream()
}
}

func subscribeToStoryStream() async {
// Pseudocode: listen to WebSocket and append blocks as they arrive
// for await newBlock in storyStream {
// blocks.append(newBlock)
// }
}
}

This gives you a conversational, AI‑driven screen without a chat UI. The interface builds itself as blocks arrive.

6. Step 5 — Implement Flexible Block Views for Images, Text, and Data

The vertical layout’s resilience depends on how you implement each block.

6.1 Image blocks with AsyncImage

Use AsyncImage so remote media doesn’t freeze the UI.

struct ImageBlockView: View {
let block: ImageBlock

var body: some View {
VStack(alignment: .leading, spacing: 8) {
AsyncImage(url: block.url) { phase in
switch phase {
case .empty:
ProgressView()
.frame(maxWidth: .infinity)
.frame(height: 200)

case .success(let image):
image
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))

case .failure:
RoundedRectangle(cornerRadius: 12)
.overlay(Text("Image unavailable"))
.frame(height: 200)
}
}

if let caption = block.caption, !caption.isEmpty {
Text(caption)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}

This block:

  • Stacks image and caption vertically
  • Handles loading and failure states gracefully
  • Expands to full width, which works across device sizes

6.2 Text blocks with layout priority

Use layoutPriority to keep important explanatory text from being truncated in tight layouts.

struct TextBlockView: View {
let block: TextBlock

var body: some View {
Text(block.body)
.font(fontForRole(block.role))
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1) // Prefer full text over neighboring decorative elements
}

private func fontForRole(_ role: String) -> Font {
switch role {
case "title": return .title3.bold()
case "explanatory": return .body
case "disclaimer": return .footnote
default: return .body
}
}
}

This helps when:

  • The LLM generates longer text than expected
  • The story appears inside a more constrained parent layout

6.3 Data blocks with dynamic rows

Data blocks summarize structured options (e.g., rebooking windows, refund eligibility).

struct DataBlockView: View {
let block: DataBlock

var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let title = block.title {
Text(title)
.font(.headline)
}

VStack(alignment: .leading, spacing: 4) {
ForEach(block.rows) { row in
HStack(alignment: .top, spacing: 8) {
Text(row.label)
.font(.subheadline)
.bold()
.frame(width: 120, alignment: .leading)

Text(row.value)
.font(.subheadline)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(.gray.opacity(0.3))
)
}
}

This pattern:

  • Handles variable numbers of rows without layout changes
  • Keeps labels and values aligned, even when values are multi‑line

When applied to disruption flows, it gives users a clean, scrollable summary of “What are my options now?” without forcing them to parse long prose.

7. Step 6 — Keeping Layout Flexible When Media Types and Order Change

LLM‑driven stories will vary. Some answers may be mostly text; others may lean on images and data blocks. You need patterns that survive these variations.

7.1 Treat blocks as a sequence, not a fixed template

Instead of designing for a fixed layout (image → text → data), design for:

  • Any block order the server gives you
  • Optional presence or absence of each type

Because you:

  • Use ForEach(story.blocks)
  • Switch on block.type in StoryBlockView

…you automatically adapt to different sequences.

7.2 Use ViewThatFits for conditional layouts

You can refine key sections with ViewThatFits to adjust under constraints.

struct ResponsiveStoryHeader: View {
let title: String
let subtitle: String?

var body: some View {
ViewThatFits {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title2).bold()
if let subtitle { Text(subtitle).font(.subheadline) }
}

Text(title)
.font(.title3).bold()
}
}
}

ViewThatFits picks the first child that fits the available space. This keeps headers readable on smaller devices or embedded surfaces.

7.3 Use containerRelativeFrame in images

For image blocks inside more complex parents (e.g., carousels), containerRelativeFrame helps maintain visual balance.

image
.containerRelativeFrame(.vertical) { size, axis in
size * 0.4
}

This ensures the image consumes a reasonable fraction of its container, regardless of device height.

8. Step 7 — Integrating Uzori’s SDK for Generative + Server‑Driven Safety

If you’d rather not build all of this from scratch, Uzori’s iOS SwiftUI SDK gives you a ready‑made path from LLM tool calls to validated SwiftUI screens.

Uzori’s engine:

  • Takes user requests plus your backend APIs (usually described via OpenAPI)
  • Calls tools with strict schemas tied to your components
  • Validates all generated screens server‑side
  • Streams SwiftUI views into a single integrated screen in your app

You get:

  • The interface that builds itself for complex flows
  • Generative UI with server‑driven safety
  • Native SwiftUI blocks, not WebViews or arbitrary remote code

9. Why This Pattern Matters (Reliability, UX, and Scale)

Turning LLM tool calls into native iOS screens isn’t just a novelty. It aligns with broader industry trends.

  • Sensor Tower’s State of Mobile 2025 reports 4.2 trillion hours spent in apps and $150B in consumer spend. Native UX quality directly impacts your share of that time and revenue.
  • Stack Overflow’s 2025 survey shows 84% of developers are using or planning to use AI tools, but positive sentiment dropped to 60%. Poorly integrated AI (especially chatboxes that don’t feel native) is a big reason.
  • Shopify’s move to faster native modules cut some WebView loads by 6× (P75 from 6s to 1.4s), reinforcing that native-first still sets the UX bar.
Infographic comparing AI schema adherence, developer sentiment, and mobile app usage stats.

Key benchmarks show why native, structured AI interfaces matter: mobile users spend trillions of hours in apps, while modern AI models only hit full schema reliability under strong constraints.

By using structured outputs, server validation, and native SwiftUI renderers, you:

  • Convert AI from “answer generator” into interface orchestrator
  • Keep safety and performance under control
  • Ship AI‑native experiences that feel like your app, not someone else’s chatbot

10. Putting It All Together: A Checklist

Here’s a quick checklist you can copy into your issue tracker.

  1. Define your story schema
    • Block types: image, text, data, action
    • Required fields and constraints
  2. Wire up LLM tool calling
    • Use structured outputs / function calls
    • Bind tools to your schema
  3. Validate on the server
    • Enforce types, lengths, and URL formats
    • Normalize IDs and ordering
  4. Create Swift models and block renderer
    • Codable types for story and blocks
    • StoryBlockView switching on block.type
  5. Implement vertical layout
    • LazyVStack inside ScrollView
    • Streaming support via @State and async updates
  6. Refine block components
    • AsyncImage for remote media
    • layoutPriority for critical text
    • Flexible data tables/cards
  7. Optional: integrate Uzori
    • Single-screen SDK integration
    • Server‑validated, generative SwiftUI screens

FAQ: Turning LLM Tool Calls into Native SwiftUI Screens

Q1. Why can’t I just have the LLM emit SwiftUI code directly?

Running arbitrary generated code on devices is a major security and reliability risk.

A safer pattern is:

  • LLM emits structured JSON matching a constrained schema
  • Server validates and may correct the payload
  • App renders native SwiftUI components from that schema

This keeps your UI within a trusted component catalog and avoids remote code execution.

Q2. How do I handle model mistakes in the UI schema?

You catch them on the server:

  • Run schema validation against every tool call
  • If validation fails:
    • Either repair the payload (e.g., strip unknown block types)
    • Or ask the model to regenerate with stricter guidance
  • Log failures to monitor reliability across models and prompts

Never let unvalidated LLM output reach the renderer.

Q3. How does this differ from traditional server‑driven UI?

Traditional SDUI:

  • Server authors layouts manually or via config
  • Client renders them with a runtime

LLM‑driven SDUI (with Uzori‑style patterns):

  • Agents compose layouts dynamically based on user intent and backend data
  • Server still validates and owns safety
  • Client uses the same runtime, but now fed by generative logic

It’s the convergence of generative UI and SDUI.

Q4. Does this work for non‑story flows, like wizards or comparison views?

Yes.

The same pattern—blocks + schema + renderer—can drive:

  • Multi‑step wizards for complex configuration
  • Comparison layouts for products or itineraries
  • Explorers and concierges that mix images, forms, and data tables

Vertical story layouts are just one specialization that works particularly well for disruption answers.

Q5. How hard is it to try this in an existing app?

You can start with a single screen behind a feature flag:

  • Implement the story schema and block renderer
  • Connect to a test endpoint that returns LLM‑generated stories
  • Route a small cohort of users to the AI‑driven disruption screen

With Uzori’s SDK, this often reduces to one SwiftUI screen integration, and you can expand to richer flows once you’re confident in reliability.

If you’re ready to move beyond chatboxes and let AI actually build native interfaces, start with a single vertical story layout. Tie it to a disruption flow—“What are my options now?”—and let structured LLM tool calls orchestrate images, explanations, and data blocks. Once that works, the rest of your AI UX roadmap becomes much easier to reason about and ship.

← All posts