swiftui
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
bodyinto private computed properties at 30+ lines - Use
// MARK: - Subviewsto 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@ViewBuilderfor 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
@ObservedObjectfor state the view owns - Never create
@StateObjectin a computed property - Never mutate state during a view update cycle
Naming Conventions
- Screen views:
PascalCase+Viewsuffix (ProfileView,SettingsView) - Reusable components:
PascalCasewithout suffix (StatCard,AvatarImage) - View modifiers:
camelCase(cardStyle(),loading(_:)) - View models:
PascalCasedescriptive name (UserViewModel,AuthManager)
Performance Rules
- Use
LazyVStack/LazyHStackfor scrollable lists,LazyVGrid/LazyHGridfor grids - Use
.taskfor async data loading (auto-cancels on disappear) - Avoid
AnyViewtype erasure; use@ViewBuilderorGroupinstead - Use
AsyncImagewith placeholder and error states for remote images - Use
Equatableconformance 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, ComponentsShared/for cross-feature reusable views and custom modifiersCore/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
XCTestfor unit and UI tests - Test view models with
async throwstest 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