swiftui

SKILL.md

SwiftUI Framework Guide

Applies to: SwiftUI 5.0+ (iOS 17+, macOS 14+, watchOS 10+, tvOS 17+, visionOS 1.0+) Language Guide: @.claude/skills/swift-guide/SKILL.md

Overview

SwiftUI is Apple's declarative UI framework for building native apps across all Apple platforms. It uses a reactive, data-driven approach where the UI automatically updates when state changes.

Use SwiftUI when:

  • Building new Apple platform apps (iOS, macOS, watchOS, tvOS, visionOS)
  • Cross-platform Apple development from a single codebase
  • Declarative, reactive UI with minimal boilerplate
  • Leveraging latest platform features (widgets, App Intents, Live Activities)

Consider alternatives when:

  • Supporting iOS < 14 (use UIKit)
  • Need fine-grained UIKit control (embed via UIViewRepresentable)
  • Existing large UIKit codebase (migrate incrementally)

Guardrails

View Rules

  • Keep views under 200 lines; decompose body into private computed properties at 30+ lines
  • Use // MARK: - Subviews to group computed subview properties
  • Prefer composition over deep nesting; extract reusable components as separate structs
  • Never perform heavy computation inside body (use view models or .task)
  • Mark subview properties as private; use @ViewBuilder for conditional helpers

State Management Rules

  • @State: View-local value-type state (booleans, strings, simple structs)
  • @Binding: Two-way connection to parent state
  • @Observable (iOS 17+): Preferred for view models; replaces ObservableObject
  • @StateObject: Owned ObservableObject (iOS 14-16; created once, survives re-renders)
  • @ObservedObject: Non-owned ObservableObject passed from parent
  • @EnvironmentObject / @Environment: Shared state and system values
  • Never use @ObservedObject for state the view owns
  • Never create @StateObject in a computed property
  • Never mutate state during a view update cycle

Naming Conventions

  • Screen views: PascalCase + View suffix (ProfileView, SettingsView)
  • Reusable components: PascalCase without suffix (StatCard, AvatarImage)
  • View modifiers: camelCase (cardStyle(), loading(_:))
  • View models: PascalCase descriptive name (UserViewModel, AuthManager)

Performance Rules

  • Use LazyVStack/LazyHStack for scrollable lists, LazyVGrid/LazyHGrid for grids
  • Use .task for async data loading (auto-cancels on disappear)
  • Avoid AnyView type erasure; use @ViewBuilder or Group instead
  • Use AsyncImage with placeholder and error states for remote images
  • Use Equatable conformance with .equatable() for expensive views

Project Structure

MyApp/
├── MyApp/
│   ├── App/
│   │   └── MyApp.swift              # @main entry point
│   ├── Features/
│   │   ├── Authentication/
│   │   │   ├── Views/               # LoginView.swift, SignUpView.swift
│   │   │   ├── ViewModels/          # AuthViewModel.swift
│   │   │   └── Models/              # User.swift
│   │   ├── Home/
│   │   │   ├── Views/ | ViewModels/ | Components/
│   │   └── Settings/
│   ├── Core/
│   │   ├── Network/                 # APIClient.swift, Endpoints.swift
│   │   ├── Storage/
│   │   └── Extensions/              # View+Extensions.swift
│   ├── Shared/
│   │   ├── Components/              # LoadingView, ErrorView, PrimaryButton
│   │   └── Modifiers/               # CardModifier.swift
│   ├── Resources/                   # Assets.xcassets, Localizable.strings
│   └── Preview Content/
├── MyAppTests/
└── MyAppUITests/
  • Features/ groups by domain with Views, ViewModels, Models, Components
  • Shared/ for cross-feature reusable views and custom modifiers
  • Core/ for non-UI infrastructure (networking, storage, extensions)

App Entry Point

@main
struct MyApp: App {
    @State private var appState = AppState()
    @State private var authManager = AuthManager()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appState)
                .environment(authManager)
        }
    }
}

struct ContentView: View {
    @Environment(AuthManager.self) private var authManager

    var body: some View {
        Group {
            if authManager.isAuthenticated { MainTabView() }
            else { AuthenticationView() }
        }
        .animation(.easeInOut, value: authManager.isAuthenticated)
    }
}

Use @Environment(\.scenePhase) with .onChange(of:) for active/inactive/background transitions.

View Composition

Decompose views with computed properties; extract reusable pieces as separate structs.

struct UserProfileView: View {
    let user: User
    @State private var isEditing = false

    var body: some View {
        VStack(spacing: 16) { profileHeader; statsSection; actionButtons }
            .padding()
            .navigationTitle("Profile")
            .sheet(isPresented: $isEditing) { EditProfileView(user: user) }
    }

    // MARK: - Subviews
    private var profileHeader: some View {
        VStack(spacing: 8) {
            AsyncImage(url: URL(string: user.avatarURL)) { image in
                image.resizable().aspectRatio(contentMode: .fill)
            } placeholder: { Circle().fill(Color.gray.opacity(0.3)) }
            .frame(width: 100, height: 100).clipShape(Circle())
            Text(user.name).font(.title2).fontWeight(.semibold)
            Text(user.email).font(.subheadline).foregroundStyle(.secondary)
        }
    }

    private var statsSection: some View {
        HStack(spacing: 32) {
            StatView(title: "Posts", value: user.postCount)
            StatView(title: "Followers", value: user.followerCount)
        }.padding(.vertical)
    }

    private var actionButtons: some View {
        HStack(spacing: 16) {
            Button("Edit Profile") { isEditing = true }.buttonStyle(.borderedProminent)
            Button("Share") { }.buttonStyle(.bordered)
        }
    }
}

Custom View Modifiers

struct CardModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .background(Color(.systemBackground))
            .cornerRadius(12)
            .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
    }
}

extension View {
    func cardStyle() -> some View { modifier(CardModifier()) }
    func loading(_ isLoading: Bool) -> some View { modifier(LoadingModifier(isLoading: isLoading)) }
}

State Management with @Observable

@Observable
final class UserViewModel {
    var user: User?
    var isLoading = false
    var errorMessage: String?
    private let userService: UserServiceProtocol

    init(userService: UserServiceProtocol = UserService()) {
        self.userService = userService
    }

    func loadUser(id: String) async {
        isLoading = true; errorMessage = nil
        do { user = try await userService.fetchUser(id: id) }
        catch { errorMessage = error.localizedDescription }
        isLoading = false
    }
}

struct UserView: View {
    @State private var viewModel = UserViewModel()
    let userId: String

    var body: some View {
        Group {
            if viewModel.isLoading { ProgressView() }
            else if let user = viewModel.user { UserProfileView(user: user) }
            else if let error = viewModel.errorMessage {
                ErrorView(message: error) { Task { await viewModel.loadUser(id: userId) } }
            }
        }
        .task { await viewModel.loadUser(id: userId) }
    }
}

Environment-Based Dependency Injection

struct APIClientKey: EnvironmentKey {
    static let defaultValue: APIClientProtocol = APIClient()
}
extension EnvironmentValues {
    var apiClient: APIClientProtocol {
        get { self[APIClientKey.self] }
        set { self[APIClientKey.self] = newValue }
    }
}

// Usage
struct PostListView: View {
    @Environment(\.apiClient) private var apiClient
    @State private var posts: [Post] = []
    var body: some View {
        List(posts) { post in PostRowView(post: post) }
            .task { posts = (try? await apiClient.fetchPosts()) ?? [] }
    }
}

// Mock in previews
#Preview { PostListView().environment(\.apiClient, MockAPIClient()) }

Navigation

NavigationStack with Typed Routes

enum Route: Hashable {
    case settings
    case profile(userId: String)
    case notifications
}

struct MainView: View {
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            HomeView(navigationPath: $navigationPath)
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .settings: SettingsView()
                    case .profile(let id): ProfileView(userId: id)
                    case .notifications: NotificationsView()
                    }
                }
        }
    }
}

TabView with NavigationStack per Tab

struct MainTabView: View {
    @State private var selectedTab = Tab.home

    enum Tab: String, CaseIterable {
        case home, search, profile
        var icon: String {
            switch self { case .home: "house"; case .search: "magnifyingglass"; case .profile: "person" }
        }
        var title: String { rawValue.capitalized }
    }

    var body: some View {
        TabView(selection: $selectedTab) {
            ForEach(Tab.allCases, id: \.self) { tab in
                NavigationStack { tabContent(for: tab) }
                    .tabItem { Label(tab.title, systemImage: tab.icon) }
                    .tag(tab)
            }
        }
    }

    @ViewBuilder
    private func tabContent(for tab: Tab) -> some View {
        switch tab { case .home: HomeView(); case .search: SearchView(); case .profile: ProfileView() }
    }
}

Lists and Grids

struct PostListView: View {
    @State private var posts: [Post] = []
    @State private var searchText = ""

    var filteredPosts: [Post] {
        guard !searchText.isEmpty else { return posts }
        return posts.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
    }

    var body: some View {
        List {
            ForEach(filteredPosts) { post in
                PostRowView(post: post)
                    .swipeActions(edge: .trailing) {
                        Button(role: .destructive) { deletePost(post) } label: {
                            Label("Delete", systemImage: "trash")
                        }
                    }
            }
        }
        .searchable(text: $searchText, prompt: "Search posts")
        .refreshable { await loadPosts() }
        .overlay {
            if filteredPosts.isEmpty { ContentUnavailableView.search(text: searchText) }
        }
        .task { await loadPosts() }
    }

    private func loadPosts() async { /* fetch */ }
    private func deletePost(_ post: Post) { posts.removeAll { $0.id == post.id } }
}

For photo grids, use LazyVGrid with GridItem(.adaptive(minimum:maximum:)). See references/patterns.md for grid and horizontal scroll patterns.

Forms

struct RegistrationView: View {
    @State private var form = RegistrationForm()
    @State private var isSubmitting = false

    var body: some View {
        Form {
            Section("Personal Information") {
                TextField("Full Name", text: $form.name).textContentType(.name)
                TextField("Email", text: $form.email).textContentType(.emailAddress).autocapitalization(.none)
            }
            Section("Account") {
                SecureField("Password", text: $form.password)
                SecureField("Confirm", text: $form.confirmPassword)
                if !form.passwordsMatch && !form.confirmPassword.isEmpty {
                    Text("Passwords do not match").font(.caption).foregroundStyle(.red)
                }
            }
            Section {
                Button("Create Account") { Task { await submit() } }
                    .disabled(!form.isValid || isSubmitting)
            }
        }
        .loading(isSubmitting)
    }

    private func submit() async { /* validate and submit */ }
}

Previews

#Preview("User Profile") {
    NavigationStack { UserProfileView(user: .preview) }
}

#Preview("Dark Mode") {
    NavigationStack { UserProfileView(user: .preview) }.preferredColorScheme(.dark)
}

extension User {
    static var preview: User { User(id: "preview", name: "John Doe", email: "john@example.com") }
}

Testing

Standards

  • Use XCTest for unit and UI tests
  • Test view models with async throws test methods
  • Use protocol-based mocks injected via initializers
  • Coverage target: >80% for business logic, >60% overall
  • Test names describe behavior: test_loadUser_success_updatesUser()

View Model Test

final class UserViewModelTests: XCTestCase {
    var sut: UserViewModel!
    var mockService: MockUserService!

    override func setUp() {
        super.setUp(); mockService = MockUserService()
        sut = UserViewModel(userService: mockService)
    }

    func test_loadUser_success_updatesUser() async {
        mockService.userToReturn = User(id: "1", name: "John")
        await sut.loadUser(id: "1")
        XCTAssertEqual(sut.user?.name, "John")
        XCTAssertFalse(sut.isLoading)
    }

    func test_loadUser_failure_setsErrorMessage() async {
        mockService.errorToThrow = APIError.invalidResponse
        await sut.loadUser(id: "1")
        XCTAssertNil(sut.user)
        XCTAssertNotNil(sut.errorMessage)
    }
}

Commands Reference

xcodebuild -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'  # Build
xcodebuild test -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15'  # Test
swift build                          # SPM build
swift test                           # SPM test
swift format .                       # Format
swiftlint                            # Lint
swiftlint --fix                      # Auto-fix

Best Practices

Do: Decompose views with computed properties | Use @Observable (iOS 17+) | Inject via @Environment | Use .task for async | Use NavigationStack with typed routes | Provide multiple #Preview configs | Use LazyVStack/LazyHStack for scrollable content

Don't: Mutate state during view updates | Create @StateObject in computed properties | Use @ObservedObject for owned state | Put heavy computation in body | Force-unwrap outside tests | Use AnyView (use @ViewBuilder) | Nest views 4+ levels deep without extraction

Advanced Topics

For detailed patterns and examples, see:

  • references/patterns.md -- Animation, Combine, Core Data/SwiftData, accessibility, platform adaptations, networking, ObservableObject migration, advanced testing

External References

Weekly Installs
6
Repository
ar4mirez/samuel
GitHub Stars
3
First Seen
14 days ago
Installed on
opencode6
gemini-cli6
github-copilot6
codex6
amp6
cline6