How to Build a Mixed div Image + div Text Card Feed from AI in SwiftUI

By the end of this tutorial, you’ll have a SwiftUI screen that:

Exploded conveyor mechanism with stepped trays holding mixed image and text cards, symbolizing streaming SwiftUI AI card feed.

How to Build a Mixed div Image + div Text Card Feed from AI in SwiftUI

By the end of this tutorial, you’ll have a SwiftUI screen that:

  • Streams a feed of cards from an AI agent via the Uzori iOS SDK
  • Renders each card as a mix of div image and div text blocks
  • Keeps everything safe with a backend-validated layout schema
  • Shows how to turn LLM responses into real, native SwiftUI UI in real time

This is a practical walkthrough for iOS teams who want to ship AI-native experiences without adding yet another generic chat box.

Prerequisites

Before you start, you should have:

  • Xcode 16+ and Swift 5.9+ installed
  • Basic familiarity with SwiftUI (Lists, View, ObservableObject)
  • An existing iOS app target using SwiftUI
  • A backend with:
    • HTTP or WebSocket access
    • OpenAPI (or similar) descriptions of your APIs
  • Access to Uzori (or a similar AI UI engine) configured with:
    • Your OpenAPI backend schema
    • A server-side validator for card layouts

You do not need to be an LLM expert. The focus is on schema design, validation, and SwiftUI rendering.

For a deeper background on media layout patterns (image, video, text overlays) on iOS, see our related guide: div-based media layout and overlays in native SwiftUI interfaces.

1. Define a div-based Card Schema for Image + Text

The first step is to define a card schema that both your AI agent and client can understand.

1.1. Decide on a minimal div schema

You want a schema that is:

  • Simple enough for an LLM to generate reliably
  • Constrained enough for your backend to validate
  • Expressive enough to cover your main UI patterns

A practical starting point for a mixed image + text card feed:

{
"id": "card_123",
"type": "card",
"role": "feedItem",
"blocks": [
{
"type": "image",
"id": "hero",
"url": "https://example.com/image.jpg",
"aspectRatio": 1.6,
"cornerRadius": 12
},
{
"type": "text",
"id": "title",
"text": "Weekend getaway to Lisbon",
"role": "title"
},
{
"type": "text",
"id": "subtitle",
"text": "3 nights, flexible dates, AI-optimized itinerary",
"role": "subtitle"
}
]
}

Key fields to include for each div:

  • type: image or text (this tutorial focuses on those two)
  • id: stable identifier
  • role: semantic role (e.g. title, subtitle, badge, body)
  • For images: url, optional aspectRatio, optional cornerRadius
  • For text: text, optional style or role

1.2. Represent the schema in Swift

Create a shared model that mirrors this JSON in your iOS app.

enum DivBlockType: String, Codable {
case image
case text
}

struct DivImageBlock: Codable, Identifiable {
let id: String
let url: URL
let aspectRatio: CGFloat?
let cornerRadius: CGFloat?
}

struct DivTextBlock: Codable, Identifiable {
let id: String
let text: String
let role: String?
}

enum DivBlock: Codable, Identifiable {
case image(DivImageBlock)
case text(DivTextBlock)

var id: String {
switch self {
case .image(let img): return img.id
case .text(let txt): return txt.id
}
}
}

struct DivCard: Codable, Identifiable {
let id: String
let role: String?
let blocks: [DivBlock]
}

Common failure at this step:

  • Forgetting to mark enums as Codable or misaligning field names with backend JSON. Always align naming or use CodingKeys.

2. Validate the div Layout on Your Backend

SwiftUI is forgiving but your users aren’t. You must validate the AI-generated card layout on the server before sending it to the app.

This is consistent with modern server-driven UI frameworks like DivKit, which emphasize schema contracts and error logging over free-form layouts.

2.1. Implement server-side schema validation

On your backend, your AI agent should output the same JSON shape you defined above. Then you:

  1. Parse the response into strongly-typed structures.
  2. Validate:
    • Presence of required fields (id, type, blocks)
    • URLs are absolute and HTTPS
    • Layout constraints are sensible, e.g. min_size <= max_size
  3. Sanitize or reject invalid cards.

Example (pseudo-TypeScript, but any backend works):

function validateDivCard(card: DivCard): DivCard | null {
if (!card.id || !card.blocks?.length) return null;

const validBlocks = card.blocks.filter(block => {
if (block.type === 'image') {
return !!block.url && block.url.startsWith('https://');
}
if (block.type === 'text') {
return !!block.text && block.text.length <= 280;
}
return false;
});

if (!validBlocks.length) return null;

return { ...card, blocks: validBlocks };
}

In practice, you’ll enforce this in the same layer that connects your LLM to your Uzori engine.

2.2. Enforce schema contracts for AI

To reduce errors:

  • Provide the schema as a JSON schema or OpenAPI definition to the LLM.
  • Add explicit instructions: "Always return an array of card objects with blocks of type image or text only."
  • Log and inspect rejected layouts for fine-tuning.

This approach directly addresses the trust gap highlighted in Stack Overflow’s 2025 survey, where 46% of developers said they distrust AI tool accuracy. A strong schema and validation layer makes AI output safer.

Common failure at this step:

  • Letting the AI invent new block types (video, button) that your client doesn’t support. Reject or map them explicitly.

3. Connect Uzori’s Streaming SwiftUI SDK

Now that your schema and validation are in place, you can hook up Uzori’s streaming engine to your SwiftUI screen.

Uzori’s iOS SDK is designed as a single-screen SwiftUI integration:

  • You give it: user request + backend APIs (via OpenAPI)
  • It returns: server-validated SwiftUI screen definitions (in our case, a card feed)
  • It streams: updates through a constrained schema into your app

3.1. Install the Uzori iOS SDK

Install the Uzori Swift package in Xcode:

  1. In Xcode, go to File → Add Packages…
  2. Enter the Uzori package URL (from your Uzori account)
  3. Add the package to your app target

Then import it in your SwiftUI view:

import SwiftUI
import Uzori

3.2. Create a ViewModel for the streaming feed

Define a view model that:

  • Starts an Uzori session
  • Listens to streaming updates of DivCard
  • Publishes them to SwiftUI via @Published

final class CardFeedViewModel: ObservableObject {
@Published var cards: [DivCard] = []

private var session: UzoriSession?

func startFeed(for query: String) {
// Example: start a streaming session with Uzori
session = UzoriSession(
intent: .text(query),
onCardReceived: { [weak self] card in
DispatchQueue.main.async {
self?.cards.append(card)
}
},
onError: { error in
print("Uzori error: \(error)")
}
)

session?.start()
}

func stopFeed() {
session?.cancel()
session = nil
}
}

Under the hood, Uzori can use AsyncStream or similar primitives to deliver cards incrementally. This mirrors the streaming-first patterns seen in modern AI products.

Common failure at this step:

  • Updating SwiftUI state (@Published) from a background thread. Always dispatch back to the main queue.

4. Render the Mixed Image + Text Card Feed in SwiftUI

With the schema and streaming in place, you can now build the SwiftUI UI that renders each DivCard.

4.1. Build a generic CardFeedView

Create a view that binds to your CardFeedViewModel:

struct CardFeedView: View {
@StateObject private var viewModel = CardFeedViewModel()

var body: some View {
NavigationStack {
List(viewModel.cards) { card in
DivCardView(card: card)
.listRowInsets(EdgeInsets())
.padding(.vertical, 8)
}
.navigationTitle("AI Card Feed")
.onAppear {
viewModel.startFeed(for: "Show me weekend trips under $500")
}
.onDisappear {
viewModel.stopFeed()
}
}
}
}

4.2. Render each card with a composable DivCardView

Now implement DivCardView that can handle both image and text blocks.

struct DivCardView: View {
let card: DivCard

var body: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(card.blocks) { block in
switch block {
case .image(let img):
DivImageView(block: img)
case .text(let txt):
DivTextView(block: txt)
}
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.background)
.shadow(radius: 3)
)
.padding(.horizontal)
}
}

4.3. Implement DivImageView with AsyncImage

SwiftUI’s AsyncImage is perfect for rendering remote div images natively.

struct DivImageView: View {
let block: DivImageBlock

var body: some View {
AsyncImage(url: block.url) { phase in
switch phase {
case .empty:
ProgressView()
.frame(maxWidth: .infinity)
.aspectRatio(block.aspectRatio ?? 1.6, contentMode: .fit)
case .success(let image):
image
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity)
.aspectRatio(block.aspectRatio ?? 1.6, contentMode: .fit)
.clipShape(
RoundedRectangle(cornerRadius: block.cornerRadius ?? 12)
)
.accessibilityHidden(true)
case .failure(_):
Image(systemName: "photo")
.frame(maxWidth: .infinity)
.aspectRatio(block.aspectRatio ?? 1.6, contentMode: .fit)
@unknown default:
EmptyView()
}
}
.clipped()
}
}

4.4. Implement DivTextView with role-based styling

Use the role field to style text consistently:

struct DivTextView: View {
let block: DivTextBlock

var body: some View {
switch block.role {
case "title":
Text(block.text)
.font(.headline)
.lineLimit(2)
case "subtitle":
Text(block.text)
.font(.subheadline)
.foregroundStyle(.secondary)
case "badge":
Text(block.text.uppercased())
.font(.caption2)
.bold()
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(.thinMaterial))
default:
Text(block.text)
.font(.body)
}
}
}

Common failure at this step:

  • Hard-coding layout assumptions that conflict with what the AI generates (e.g. assuming the first block is always an image). Always rely on type and role instead of array position.

5. Orchestrate AI Flows: From Intent to Feed

At this point, you have the basic pipeline:

  1. User provides a request (e.g., “Find wedding guest dresses with photos and fit notes”).
  2. Uzori sends this to your backend and LLM.
  3. Backend returns a validated array of DivCards.
  4. Uzori streams the cards into your CardFeedViewModel.
  5. SwiftUI renders them as mixed image + text cards.

Let’s add a simple input surface to let users change the feed.

5.1. Add a query field and trigger button

Extend CardFeedView with a query text field:

struct CardFeedView: View {
@StateObject private var viewModel = CardFeedViewModel()
@State private var query: String = "Show me weekend trips under $500"

var body: some View {
NavigationStack {
VStack(spacing: 0) {
queryBar
List(viewModel.cards) { card in
DivCardView(card: card)
.listRowInsets(EdgeInsets())
.padding(.vertical, 8)
}
.listStyle(.plain)
}
.navigationTitle("AI Card Feed")
.onAppear {
viewModel.startFeed(for: query)
}
.onDisappear {
viewModel.stopFeed()
}
}
}

private var queryBar: some View {
HStack {
TextField("Ask the concierge…", text: $query)
.textFieldStyle(.roundedBorder)

Button("Go") {
viewModel.cards.removeAll()
viewModel.startFeed(for: query)
}
}
.padding()
}
}

This turns your feed into a simple AI concierge UI that feels fully native: no chat transcript, just task-specific cards.

5.2. Why this pattern is powerful

  • It compresses weeks of UI iteration into an AI-driven interface that designs itself.
  • It uses your existing backend APIs, documented via OpenAPI.
  • It keeps everything native, in SwiftUI, with your typography and components.

This aligns with broader industry trends:

  • Comscore found mobile AI assistant reach grew 5.3% in three months, from 69.7M to 73.4M users, while PC usage dropped. AI UX is increasingly mobile-first.
  • Gartner reports 85% of customer service leaders will explore or pilot conversational GenAI in 2025—exactly the kind of concierge flows this pattern supports.

Common failure at this step:

  • Treating the feed like a static list. Remember that cards can arrive incrementally; design for streaming and partial results.

6. Hardening and Extending Your Card Feed

Once your basic feed is working, there are a few steps to productionize it.

6.1. Add basic analytics

Track:

  • Number of cards streamed per query
  • Time to first card
  • Card types or roles generated most often

This helps you and product teams compare AI-generated flows against hand-built ones.

6.2. Enforce stricter layout rules

As you grow, you can:

  • Add maximum card counts per feed (e.g. 50)
  • Disallow certain roles (e.g. no badge until design approves)
  • Add layout templates that the AI must match (similar to DivKit templates)

6.3. Experiment with more block types (carefully)

When you’re ready, you can expand beyond image + text:

  • Buttons for quick actions
  • Chips or tags for filters
  • Progress indicators for multi-step flows

Each new block type must be:

  • Added to the schema
  • Validated on the backend
  • Implemented as a SwiftUI view

Common failure at this step:

  • Letting experiment-only blocks leak into production without full validation and UI support. Use feature flags and schema versioning.

FAQ / Troubleshooting

1. How do I prevent the AI from returning invalid image URLs?

Add explicit validation on the backend:

  • Require https:// URLs
  • Optionally, whitelist domains
  • Strip or reject any cards whose image URLs fail these checks

Uzori will only stream cards that pass validation, so the client never sees bad URLs.

2. What if the AI returns too many cards and my feed feels overloaded?

Enforce limits server-side:

  • Cap the number of cards per response (e.g. max 20)
  • Truncate long feeds before they’re sent to the client
  • Add a priority or rank field and only keep top results

3. How do I handle schema changes without breaking older app versions?

Use:

  • Schema versioning (schemaVersion in each payload)
  • Backward-compatible defaults in the client (e.g. unknown roles render as body text)
  • Feature flags on the backend to roll new block types out gradually

4. Can I use this approach with other SDUI frameworks like DivKit?

Yes, conceptually. DivKit already uses JSON-described layouts with validation and templates. Uzori focuses specifically on AI-native SwiftUI and server-validated generative UI. You can:

  • Reuse schema concepts (templates, typed actions)
  • Combine a DivKit-like contract with Uzori’s AI generation and streaming engine

5. Does this work offline?

This pattern is primarily online-only, since it depends on:

  • AI generation via your backend
  • Streaming via Uzori

For offline fallback, you can:

  • Cache recent feeds locally
  • Show last-known cards when the network is unavailable
  • Offer a static, hand-built flow when streaming fails

With this setup, your AI doesn’t just answer—it designs and streams the right SwiftUI screens for the task. You’ve built a mixed div image + text card feed that’s safe, native, and ready for AI-native UX on iOS.

← All posts