Guides / Tutorials

Breed images for a pet profile in SwiftUI

By the Dog Image API team. Updated .

Fetch GET /api/v1/breeds once, decode it with Codable, let the user pick a breed, then load the head portrait with AsyncImage from the preview_512 URL. No key is involved. Show the credit link under the image, because previews are free with a visible credit.

The images are AI-generated illustrations of one adult dog, drawn to one standard on a transparent background. That makes them a good fit for an avatar circle on a pet profile, where a real photo is not available yet.

What the app does and does not hold

The app never holds an API key. Previews are keyless, and the catalog calls are keyless too. Full-size PNG files need a key and are meant for a server. If your app needs a 2048 pixel file, your backend downloads it once, stores it and serves it to the app. Cache and self-host dog breed images shows that job.

Platform note

WebP decoding in AsyncImage needs iOS 14 or later. AsyncImage is a SwiftUI view that arrived in iOS 15, so the practical floor for this guide is iOS 15. On older systems, show a placeholder.

Decode the catalog

The catalog lists every breed with its variants and the nine view ids. The breed detail call returns the preview URLs. Both responses use snake case keys, so the models name them with CodingKeys.

import SwiftUI

struct Catalog: Decodable {
    let breeds: [BreedSummary]
}

struct BreedSummary: Decodable, Identifiable, Hashable {
    let slug: String
    let name: String
    let defaultVariant: String

    var id: String { slug }

    enum CodingKeys: String, CodingKey {
        case slug, name
        case defaultVariant = "default_variant"
    }
}

struct BreedDetail: Decodable {
    let variants: [Variant]

    struct Variant: Decodable {
        let slug: String
        let views: [String: ViewImages]
    }

    struct ViewImages: Decodable {
        let preview512: URL
        let preview1024: URL

        enum CodingKeys: String, CodingKey {
            case preview512 = "preview_512"
            case preview1024 = "preview_1024"
        }
    }
}

Unknown keys such as crossbreed, summary and full are ignored by Decodable, which is what you want here. The full field points at the keyed endpoint, and the app never calls it.

A small loader

One ObservableObject owns the two requests. The breed list comes from the response, so the picker grows with the catalog and no breed is hardcoded.

@MainActor
final class BreedStore: ObservableObject {
    @Published var breeds: [BreedSummary] = []
    @Published var avatarURL: URL?
    @Published var errorMessage: String?

    private let base = URL(string: "https://dogimage.dev/api/v1")!

    func loadCatalog() async {
        do {
            let (data, _) = try await URLSession.shared.data(from: base.appendingPathComponent("breeds"))
            breeds = try JSONDecoder().decode(Catalog.self, from: data).breeds
        } catch {
            errorMessage = "Could not load breeds."
        }
    }

    func loadAvatar(for breed: BreedSummary) async {
        do {
            let url = base.appendingPathComponent("breeds/\(breed.slug)")
            let (data, _) = try await URLSession.shared.data(from: url)
            let detail = try JSONDecoder().decode(BreedDetail.self, from: data)
            let variant = detail.variants.first { $0.slug == breed.defaultVariant } ?? detail.variants.first
            avatarURL = variant?.views["head"]?.preview512
        } catch {
            avatarURL = nil
            errorMessage = "Could not load that breed."
        }
    }
}

The breed's default_variant is the main color set, so it is a sensible choice for a profile. A breed such as the German Shepherd has several variants, and your screen can offer them in a second picker later.

The profile screen

The screen shows a picker, the avatar and the credit link. AsyncImage takes a phase closure, so you handle loading and failure in one place.

struct PetProfileView: View {
    @StateObject private var store = BreedStore()
    @State private var selected: BreedSummary?

    var body: some View {
        Form {
            Section("Breed") {
                Picker("Breed", selection: $selected) {
                    Text("Choose a breed").tag(BreedSummary?.none)
                    ForEach(store.breeds) { breed in
                        Text(breed.name).tag(BreedSummary?.some(breed))
                    }
                }
            }

            if let breed = selected {
                Section {
                    VStack(spacing: 8) {
                        AsyncImage(url: store.avatarURL) { phase in
                            switch phase {
                            case .success(let image):
                                image.resizable().scaledToFit()
                            case .failure:
                                Image(systemName: "pawprint").font(.largeTitle)
                            default:
                                ProgressView()
                            }
                        }
                        .frame(width: 160, height: 160)
                        .background(Color(.secondarySystemBackground), in: Circle())

                        Link(
                            "\(breed.name) image: Dog Image API",
                            destination: URL(string: "https://dogimage.dev/dogs/\(breed.slug)")!
                        )
                        .font(.footnote)
                    }
                    .frame(maxWidth: .infinity)
                }
            }
        }
        .task { await store.loadCatalog() }
        .task(id: selected) {
            if let breed = selected { await store.loadAvatar(for: breed) }
        }
    }
}

Two details matter. The background is transparent, so the circle behind the image is what gives the avatar its shape. And the credit link uses the form each breed page gives: the breed name, "image: Dog Image API", linking to that breed's page on the site.

Choosing views

The head view is a portrait and suits an avatar. For a profile header, use a side view such as left at the 1024 pixel size, and keep the views consistent across your app so every pet looks like it belongs to the same set. The left side gallery shows how every breed looks from that angle.

Before you ship

  • Store the breed slug on the pet record, not the image URL. Resolve the URL when the screen opens.
  • Keep the credit link visible next to the image, not hidden in a settings screen.
  • Say "AI-generated" in your own copy if users might read the image as their own pet.
  • Read the license once. Previews allow any use with the credit link.

Questions

Do I need an API key in my SwiftUI app?

No. The breed list, the breed detail and the 512 and 1024 pixel previews are keyless. Full-size PNG files need a key, so a server of yours should fetch them. Never put the key in the app.

Which iOS version do I need to show the WebP previews?

WebP decoding needs iOS 14 or later. AsyncImage itself needs iOS 15, so a SwiftUI app that uses it should target iOS 15 or later.

Which preview size should a profile screen use?

Use the 512 pixel preview for an avatar or a list row and the 1024 pixel preview for a full-width header. Both are WebP files with a transparent background.

Are the dogs in the images real?

No. The images are AI-generated illustrations. They show no real dog and are not a breed standard.

Next steps