Intermediate50 minModule 3 of 3
Build a SwiftUI App
Build a complete AI image generation app in SwiftUI — prompt input, live generation, image history, and share sheet.
What we're building
A production-quality SwiftUI app called ArtGen that:
- Accepts a text prompt and generates an AI image
- Shows a loading state with progress indicator
- Displays the result with a share/save button
- Maintains a history of generated images (persisted to disk)
App architecture
1. Project setup
Create a new SwiftUI App in Xcode, then add the Skytells Swift SDK as a package dependency (see Module 1).
Add SKYTELLS_API_KEY to your .xcconfig and reference it from Info.plist:
<!-- Info.plist -->
<key>SkytellsAPIKey</key>
<string>$(SKYTELLS_API_KEY)</string>2. The ViewModel
// GeneratorViewModel.swift
import SwiftUI
import Skytells
@MainActor
class GeneratorViewModel: ObservableObject {
@Published var prompt = ""
@Published var isGenerating = false
@Published var generatedImage: GeneratedImage?
@Published var errorMessage: String?
private let client: SkytellsClient
private var currentTask: Task<Void, Never>?
init() {
let apiKey = Bundle.main.infoDictionary?["SkytellsAPIKey"] as? String ?? ""
self.client = SkytellsClient(apiKey: apiKey)
}
func generate() {
guard !prompt.trimmingCharacters(in: .whitespaces).isEmpty else { return }
currentTask?.cancel()
currentTask = Task {
isGenerating = true
errorMessage = nil
do {
let prediction = try await client.predictions.create(
model: "truefusion-pro",
input: [
"prompt": prompt,
"width": 1024,
"height": 1024,
"guidance_scale": 7.5,
"num_inference_steps": 30,
]
)
guard !Task.isCancelled else { return }
if let urlString = prediction.output?.first,
let url = URL(string: urlString) {
let image = GeneratedImage(
id: prediction.id,
prompt: prompt,
url: url,
createdAt: Date()
)
generatedImage = image
HistoryStore.shared.add(image)
}
} catch is CancellationError {
// User cancelled
} catch let error as SkytellsError {
errorMessage = error.userFacingMessage
} catch {
errorMessage = "Something went wrong. Please try again."
}
isGenerating = false
}
}
func cancel() {
currentTask?.cancel()
isGenerating = false
}
}
extension SkytellsError {
var userFacingMessage: String {
switch self {
case .unauthorized: return "Invalid API key."
case .rateLimitExceeded: return "Too many requests. Please wait a moment."
case .invalidInput(let d): return "Invalid prompt: \(d)"
default: return "Generation failed. Please try again."
}
}
}3. Data model
// GeneratedImage.swift
import Foundation
struct GeneratedImage: Identifiable, Codable {
let id: String
let prompt: String
let url: URL
let createdAt: Date
}4. History store
// HistoryStore.swift
import Foundation
import Combine
@MainActor
class HistoryStore: ObservableObject {
static let shared = HistoryStore()
@Published private(set) var images: [GeneratedImage] = []
private let saveURL: URL = {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return docs.appendingPathComponent("artgen-history.json")
}()
init() {
load()
}
func add(_ image: GeneratedImage) {
images.insert(image, at: 0)
save()
}
func delete(at offsets: IndexSet) {
images.remove(atOffsets: offsets)
save()
}
private func save() {
try? JSONEncoder().encode(images).write(to: saveURL)
}
private func load() {
guard let data = try? Data(contentsOf: saveURL) else { return }
images = (try? JSONDecoder().decode([GeneratedImage].self, from: data)) ?? []
}
}5. Main content view
// ContentView.swift
import SwiftUI
struct ContentView: View {
@StateObject private var vm = GeneratorViewModel()
@State private var showingGallery = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Prompt input
VStack(alignment: .leading, spacing: 8) {
Text("Describe your image")
.font(.headline)
TextField(
"A photorealistic landscape at golden hour...",
text: $vm.prompt,
axis: .vertical
)
.lineLimit(3...6)
.textFieldStyle(.roundedBorder)
}
.padding(.horizontal)
// Generate / Cancel button
if vm.isGenerating {
VStack(spacing: 12) {
ProgressView("Generating...")
.progressViewStyle(.circular)
Button("Cancel", action: vm.cancel)
.foregroundStyle(.secondary)
}
} else {
Button(action: vm.generate) {
Label("Generate", systemImage: "sparkles")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(vm.prompt.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal)
}
// Error
if let error = vm.errorMessage {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
.font(.footnote)
}
// Result
if let image = vm.generatedImage {
GeneratedImageView(image: image)
.padding(.horizontal)
}
}
.padding(.vertical)
}
.navigationTitle("ArtGen")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showingGallery = true
} label: {
Label("History", systemImage: "photo.on.rectangle")
}
}
}
.sheet(isPresented: $showingGallery) {
GalleryView()
}
}
}
}6. Image result view
// GeneratedImageView.swift
import SwiftUI
struct GeneratedImageView: View {
let image: GeneratedImage
@State private var loadedImage: Image?
@State private var showingShare = false
var body: some View {
VStack(spacing: 12) {
if let loaded = loadedImage {
loaded
.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
RoundedRectangle(cornerRadius: 12)
.fill(Color.secondary.opacity(0.2))
.aspectRatio(1, contentMode: .fit)
.overlay { ProgressView() }
}
HStack {
Text(image.prompt)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
Spacer()
Button {
showingShare = true
} label: {
Image(systemName: "square.and.arrow.up")
}
}
}
.task {
await loadImage()
}
.sheet(isPresented: $showingShare) {
if let loaded = loadedImage {
ShareSheet(items: [loaded])
}
}
}
private func loadImage() async {
guard let (data, _) = try? await URLSession.shared.data(from: image.url),
let uiImage = UIImage(data: data) else { return }
loadedImage = Image(uiImage: uiImage)
}
}7. Gallery view
// GalleryView.swift
import SwiftUI
struct GalleryView: View {
@StateObject private var store = HistoryStore.shared
@Environment(\.dismiss) private var dismiss
let columns = [GridItem(.adaptive(minimum: 150))]
var body: some View {
NavigationStack {
if store.images.isEmpty {
ContentUnavailableView(
"No images yet",
systemImage: "photo",
description: Text("Generate some images and they'll appear here.")
)
} else {
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(store.images) { image in
AsyncImage(url: image.url) { img in
img.resizable().aspectRatio(1, contentMode: .fill)
} placeholder: {
Color.secondary.opacity(0.2)
}
.frame(height: 150)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding()
}
.navigationTitle("History")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
}
}8. ShareSheet helper (UIKit bridge)
// ShareSheet.swift
import SwiftUI
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}Running the app
- Build and run on a simulator or device (iOS 15+)
- Type a prompt and tap Generate
- The image appears in ~5–15 seconds
- Tap the share icon to save to Photos or share
- Tap the History button to browse all previous generations
Congratulations
You've completed the Swift SDK path and built a production-quality iOS app. You now know how to:
- Install and configure the Skytells Swift SDK
- Make predictions with
async/awaitand handle typed errors - Cancel in-flight requests using Swift's
Tasksystem - Build a full SwiftUI app backed by the Skytells AI API
Next steps:
- Building Production Apps — authentication, rate limits, and webhooks
- Image Generation path — prompt engineering for better outputs
- Swift SDK reference — full API reference