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

ContentView GeneratorViewModel SkytellsClient HistoryStore Skytells API FileManager GalleryView

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)
    }
}
// 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

  1. Build and run on a simulator or device (iOS 15+)
  2. Type a prompt and tap Generate
  3. The image appears in ~5–15 seconds
  4. Tap the share icon to save to Photos or share
  5. 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/await and handle typed errors
  • Cancel in-flight requests using Swift's Task system
  • Build a full SwiftUI app backed by the Skytells AI API

Next steps:

On this page