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:

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
divimage anddivtext 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:imageortext(this tutorial focuses on those two)id: stable identifierrole: semantic role (e.g.title,subtitle,badge,body)- For images:
url, optionalaspectRatio, optionalcornerRadius - For text:
text, optionalstyleorrole
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
Codableor misaligning field names with backend JSON. Always align naming or useCodingKeys.
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:
- Parse the response into strongly-typed structures.
- Validate:
- Presence of required fields (
id,type,blocks) - URLs are absolute and HTTPS
- Layout constraints are sensible, e.g.
min_size <= max_size
- Presence of required fields (
- 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
cardobjects withblocksof typeimageortextonly." - 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:
- In Xcode, go to File → Add Packages…
- Enter the Uzori package URL (from your Uzori account)
- 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
typeandroleinstead of array position.
5. Orchestrate AI Flows: From Intent to Feed
At this point, you have the basic pipeline:
- User provides a request (e.g., “Find wedding guest dresses with photos and fit notes”).
- Uzori sends this to your backend and LLM.
- Backend returns a validated array of
DivCards. - Uzori streams the cards into your
CardFeedViewModel. - 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
badgeuntil 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
priorityorrankfield and only keep top results
3. How do I handle schema changes without breaking older app versions?
Use:
- Schema versioning (
schemaVersionin 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.