Files
agents/plugins/ui-design/skills/mobile-ios-design/references/swiftui-components.md
2026-01-19 17:07:03 -05:00

14 KiB

SwiftUI Component Library

Lists and Collections

Basic List

struct ItemListView: View {
    @State private var items: [Item] = []

    var body: some View {
        List {
            ForEach(items) { item in
                ItemRow(item: item)
            }
            .onDelete(perform: deleteItems)
            .onMove(perform: moveItems)
        }
        .listStyle(.insetGrouped)
        .refreshable {
            await loadItems()
        }
    }

    private func deleteItems(at offsets: IndexSet) {
        items.remove(atOffsets: offsets)
    }

    private func moveItems(from source: IndexSet, to destination: Int) {
        items.move(fromOffsets: source, toOffset: destination)
    }
}

Sectioned List

struct SectionedListView: View {
    let groupedItems: [String: [Item]]

    var body: some View {
        List {
            ForEach(groupedItems.keys.sorted(), id: \.self) { key in
                Section(header: Text(key)) {
                    ForEach(groupedItems[key] ?? []) { item in
                        ItemRow(item: item)
                    }
                }
            }
        }
        .listStyle(.sidebar)
    }
}

Search Integration

struct SearchableListView: View {
    @State private var searchText = ""
    @State private var items: [Item] = []

    var filteredItems: [Item] {
        if searchText.isEmpty {
            return items
        }
        return items.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
    }

    var body: some View {
        NavigationStack {
            List(filteredItems) { item in
                ItemRow(item: item)
            }
            .searchable(text: $searchText, prompt: "Search items")
            .searchSuggestions {
                ForEach(searchSuggestions, id: \.self) { suggestion in
                    Text(suggestion)
                        .searchCompletion(suggestion)
                }
            }
            .navigationTitle("Items")
        }
    }
}

Forms and Input

Settings Form

struct SettingsView: View {
    @AppStorage("notifications") private var notificationsEnabled = true
    @AppStorage("soundEnabled") private var soundEnabled = true
    @State private var selectedTheme = Theme.system
    @State private var username = ""

    var body: some View {
        Form {
            Section("Account") {
                TextField("Username", text: $username)
                    .textContentType(.username)
                    .autocorrectionDisabled()
            }

            Section("Preferences") {
                Toggle("Enable Notifications", isOn: $notificationsEnabled)
                Toggle("Sound Effects", isOn: $soundEnabled)

                Picker("Theme", selection: $selectedTheme) {
                    ForEach(Theme.allCases) { theme in
                        Text(theme.rawValue).tag(theme)
                    }
                }
            }

            Section("About") {
                LabeledContent("Version", value: "1.0.0")

                Link(destination: URL(string: "https://example.com/privacy")!) {
                    Text("Privacy Policy")
                }
            }
        }
        .navigationTitle("Settings")
    }
}

Custom Input Fields

struct ValidatedTextField: View {
    let title: String
    @Binding var text: String
    let validation: (String) -> Bool

    @State private var isValid = true
    @FocusState private var isFocused: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(title)
                .font(.caption)
                .foregroundStyle(.secondary)

            TextField(title, text: $text)
                .textFieldStyle(.roundedBorder)
                .focused($isFocused)
                .overlay(
                    RoundedRectangle(cornerRadius: 8)
                        .stroke(borderColor, lineWidth: 1)
                )
                .onChange(of: text) { _, newValue in
                    isValid = validation(newValue)
                }

            if !isValid && !text.isEmpty {
                Text("Invalid input")
                    .font(.caption)
                    .foregroundStyle(.red)
            }
        }
    }

    private var borderColor: Color {
        if isFocused {
            return isValid ? .blue : .red
        }
        return .clear
    }
}

Buttons and Actions

Button Styles

// Primary filled button
Button("Continue") {
    // action
}
.buttonStyle(.borderedProminent)
.controlSize(.large)

// Secondary button
Button("Cancel") {
    // action
}
.buttonStyle(.bordered)

// Destructive button
Button("Delete", role: .destructive) {
    // action
}
.buttonStyle(.bordered)

// Custom button style
struct ScaleButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
            .animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
    }
}

Menu and Context Menu

// Menu button
Menu {
    Button("Edit", systemImage: "pencil") { }
    Button("Duplicate", systemImage: "doc.on.doc") { }
    Divider()
    Button("Delete", systemImage: "trash", role: .destructive) { }
} label: {
    Image(systemName: "ellipsis.circle")
}

// Context menu on any view
Text("Long press me")
    .contextMenu {
        Button("Copy", systemImage: "doc.on.doc") { }
        Button("Share", systemImage: "square.and.arrow.up") { }
    } preview: {
        ItemPreviewView()
    }

Sheets and Modals

Sheet Presentation

struct ParentView: View {
    @State private var showSettings = false
    @State private var selectedItem: Item?

    var body: some View {
        VStack {
            Button("Settings") {
                showSettings = true
            }
        }
        .sheet(isPresented: $showSettings) {
            SettingsSheet()
                .presentationDetents([.medium, .large])
                .presentationDragIndicator(.visible)
        }
        .sheet(item: $selectedItem) { item in
            ItemDetailSheet(item: item)
                .presentationDetents([.height(300), .large])
                .presentationCornerRadius(24)
        }
    }
}

struct SettingsSheet: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            SettingsContent()
                .toolbar {
                    ToolbarItem(placement: .confirmationAction) {
                        Button("Done") {
                            dismiss()
                        }
                    }
                }
        }
    }
}

Confirmation Dialog

struct DeleteConfirmationView: View {
    @State private var showConfirmation = false

    var body: some View {
        Button("Delete Account", role: .destructive) {
            showConfirmation = true
        }
        .confirmationDialog(
            "Delete Account",
            isPresented: $showConfirmation,
            titleVisibility: .visible
        ) {
            Button("Delete", role: .destructive) {
                deleteAccount()
            }
            Button("Cancel", role: .cancel) { }
        } message: {
            Text("This action cannot be undone.")
        }
    }
}

Loading and Progress

Progress Indicators

// Indeterminate spinner
ProgressView()
    .progressViewStyle(.circular)

// Determinate progress
ProgressView(value: downloadProgress, total: 1.0) {
    Text("Downloading...")
} currentValueLabel: {
    Text("\(Int(downloadProgress * 100))%")
}

// Custom loading view
struct LoadingOverlay: View {
    let message: String

    var body: some View {
        ZStack {
            Color.black.opacity(0.4)
                .ignoresSafeArea()

            VStack(spacing: 16) {
                ProgressView()
                    .scaleEffect(1.5)
                    .tint(.white)

                Text(message)
                    .font(.subheadline)
                    .foregroundStyle(.white)
            }
            .padding(24)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
        }
    }
}

Skeleton Loading

struct SkeletonRow: View {
    @State private var isAnimating = false

    var body: some View {
        HStack(spacing: 12) {
            Circle()
                .fill(.gray.opacity(0.3))
                .frame(width: 44, height: 44)

            VStack(alignment: .leading, spacing: 8) {
                RoundedRectangle(cornerRadius: 4)
                    .fill(.gray.opacity(0.3))
                    .frame(height: 14)
                    .frame(maxWidth: 200)

                RoundedRectangle(cornerRadius: 4)
                    .fill(.gray.opacity(0.2))
                    .frame(height: 12)
                    .frame(maxWidth: 150)
            }
        }
        .opacity(isAnimating ? 0.5 : 1.0)
        .animation(.easeInOut(duration: 0.8).repeatForever(), value: isAnimating)
        .onAppear { isAnimating = true }
    }
}

Async Content Loading

AsyncImage

AsyncImage(url: imageURL) { phase in
    switch phase {
    case .empty:
        ProgressView()
    case .success(let image):
        image
            .resizable()
            .aspectRatio(contentMode: .fill)
    case .failure:
        Image(systemName: "photo")
            .foregroundStyle(.secondary)
    @unknown default:
        EmptyView()
    }
}
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 8))

Task-Based Loading

struct AsyncContentView: View {
    @State private var items: [Item] = []
    @State private var isLoading = true
    @State private var error: Error?

    var body: some View {
        Group {
            if isLoading {
                ProgressView("Loading...")
            } else if let error {
                ContentUnavailableView(
                    "Failed to Load",
                    systemImage: "exclamationmark.triangle",
                    description: Text(error.localizedDescription)
                )
            } else if items.isEmpty {
                ContentUnavailableView(
                    "No Items",
                    systemImage: "tray",
                    description: Text("Add your first item to get started.")
                )
            } else {
                List(items) { item in
                    ItemRow(item: item)
                }
            }
        }
        .task {
            await loadItems()
        }
    }

    private func loadItems() async {
        do {
            items = try await api.fetchItems()
            isLoading = false
        } catch {
            self.error = error
            isLoading = false
        }
    }
}

Animations

Implicit Animations

struct AnimatedCard: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Text("Tap to expand")

            if isExpanded {
                Text("Additional content here")
                    .transition(.move(edge: .top).combined(with: .opacity))
            }
        }
        .padding()
        .frame(maxWidth: .infinity)
        .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
        .onTapGesture {
            withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
                isExpanded.toggle()
            }
        }
    }
}

Custom Transitions

extension AnyTransition {
    static var slideAndFade: AnyTransition {
        .asymmetric(
            insertion: .move(edge: .trailing).combined(with: .opacity),
            removal: .move(edge: .leading).combined(with: .opacity)
        )
    }

    static var scaleAndFade: AnyTransition {
        .scale(scale: 0.8).combined(with: .opacity)
    }
}

Phase Animator (iOS 17+)

struct PulsingButton: View {
    var body: some View {
        Button("Tap Me") { }
            .buttonStyle(.borderedProminent)
            .phaseAnimator([false, true]) { content, phase in
                content
                    .scaleEffect(phase ? 1.05 : 1.0)
            } animation: { _ in
                .easeInOut(duration: 0.5)
            }
    }
}

Gestures

Drag Gesture

struct DraggableCard: View {
    @State private var offset = CGSize.zero
    @State private var isDragging = false

    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .fill(.blue)
            .frame(width: 200, height: 150)
            .offset(offset)
            .scaleEffect(isDragging ? 1.05 : 1.0)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        offset = value.translation
                        isDragging = true
                    }
                    .onEnded { _ in
                        withAnimation(.spring()) {
                            offset = .zero
                            isDragging = false
                        }
                    }
            )
    }
}

Simultaneous Gestures

struct ZoomableImage: View {
    @State private var scale: CGFloat = 1.0
    @State private var lastScale: CGFloat = 1.0

    var body: some View {
        Image("photo")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .scaleEffect(scale)
            .gesture(
                MagnificationGesture()
                    .onChanged { value in
                        scale = lastScale * value
                    }
                    .onEnded { _ in
                        lastScale = scale
                    }
            )
            .gesture(
                TapGesture(count: 2)
                    .onEnded {
                        withAnimation {
                            scale = 1.0
                            lastScale = 1.0
                        }
                    }
            )
    }
}