iOS UI Development
SwiftUI provides declarative, type-safe UI development with compile-time guarantees. State management with @State, @Binding, and @StateObject creates predictable data flow where UI automatically updates when state changes. Navigation with NavigationStack provides type-safe routing and deep linking support. Reusable components reduce duplication and ensure consistent behavior across screens.
Overview
This guide covers SwiftUI best practices for building robust, accessible user interfaces. We focus on declarative UI patterns, state management, navigation strategies, reusable component design, and implementing consistent design systems.
What This Guide Covers
- SwiftUI Basics: Declarative syntax, view composition, modifiers
- State Management:
@State,@Binding,@StateObject,@ObservedObject,@EnvironmentObject - Navigation:
NavigationStack,NavigationLink, deep linking, programmatic navigation - Reusable Components: Building modular, testable UI components
- Design Systems: Colors, typography, spacing, brand consistency
- Accessibility: VoiceOver, Dynamic Type, color contrast
SwiftUI State Management
SwiftUI uses a declarative approach where the UI is a function of state. When state changes, SwiftUI automatically recomputes affected views and updates the UI. Each state property wrapper has different ownership semantics: @State creates view-owned storage, @StateObject creates and owns an ObservableObject instance, @ObservedObject references an externally-owned ObservableObject, and @Binding creates a two-way connection to a parent's state. Using @StateObject in a child view that gets recreated will instantiate a new ViewModel on every re-render, losing state. Using @State for an object type will not trigger view updates when the object's properties change.
State Property Wrappers
State Management Rules
| Property Wrapper | Use Case | Ownership | When to Use |
|---|---|---|---|
@State | View-local simple state | View owns | Toggle states, text input, selection |
@Binding | Two-way binding to parent | Parent owns | Child views modifying parent state |
@StateObject | View owns ViewModel | View owns | Root view creating ViewModel |
@ObservedObject | View references ViewModel | Parent owns | Child views receiving ViewModel |
@EnvironmentObject | Shared services | Ancestor owns | DI container, user session, theme |
SwiftUI Basics
Declarative View Composition
// Presentation/Screens/Payments/PaymentListView.swift
import SwiftUI
struct PaymentListView: View {
@StateObject private var viewModel = PaymentListViewModel()
@State private var showingCreatePayment = false
var body: some View {
NavigationStack {
contentView
.navigationTitle("Payments")
.toolbar { toolbarContent }
.sheet(isPresented: $showingCreatePayment) {
CreatePaymentView()
}
.task { await viewModel.loadPayments() }
.refreshable { await viewModel.refresh() }
}
}
@ViewBuilder
private var contentView: some View {
switch viewModel.state {
case .loading:
ProgressView("Loading payments...")
case .loaded(let payments):
if payments.isEmpty {
emptyStateView
} else {
paymentList(payments)
}
case .error(let message):
errorView(message)
}
}
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .primaryAction) {
Button {
showingCreatePayment = true
} label: {
Image(systemName: "plus.circle.fill")
}
.accessibilityLabel("Create new payment")
}
}
private func paymentList(_ payments: [Payment]) -> some View {
List(payments) { payment in
NavigationLink(value: payment) {
PaymentCard(payment: payment)
}
}
.listStyle(.plain)
.navigationDestination(for: Payment.self) { payment in
PaymentDetailView(payment: payment)
}
}
private var emptyStateView: some View {
EmptyStateView(
icon: "doc.text.magnifyingglass",
title: "No payments yet",
message: "Create your first payment to get started",
action: EmptyStateAction(
title: "Create Payment",
action: { showingCreatePayment = true }
)
)
}
private func errorView(_ message: String) -> some View {
ErrorStateView(
message: message,
retryAction: {
Task { await viewModel.loadPayments() }
}
)
}
}
#Preview {
PaymentListView()
}
SwiftUI View Lifecycle
Reusable Components
Payment Card Component
// Presentation/Components/PaymentCard.swift
import SwiftUI
struct PaymentCard: View {
let payment: Payment
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
amountView
dateView
if let description = payment.description {
descriptionView(description)
}
}
.padding()
.background(Color(.systemBackground))
.cornerRadius(12)
.shadow(color: .black.opacity(0.05), radius: 4, y: 2)
}
// MARK: - Subviews
private var header: some View {
HStack {
Text(payment.recipientName)
.font(.headline)
.foregroundColor(.primary)
Spacer()
statusBadge
}
}
private var amountView: some View {
Text(payment.amount.formatted(.currency(code: payment.currency)))
.font(.title2)
.fontWeight(.bold)
.foregroundColor(.blue)
}
private var dateView: some View {
Text(payment.createdAt.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundColor(.secondary)
}
private func descriptionView(_ text: String) -> some View {
Text(text)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
@ViewBuilder
private var statusBadge: some View {
let (text, color) = statusInfo(for: payment.status)
Text(text)
.font(.caption)
.fontWeight(.semibold)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.2))
.foregroundColor(color)
.cornerRadius(6)
.accessibilityLabel("Payment status: \(text)")
}
private func statusInfo(for status: PaymentStatus) -> (String, Color) {
switch status {
case .completed:
return ("Completed", .green)
case .pending:
return ("Pending", .orange)
case .processing:
return ("Processing", .blue)
case .failed:
return ("Failed", .red)
case .cancelled:
return ("Cancelled", .gray)
case .scheduled:
return ("Scheduled", .purple)
}
}
}
#Preview("Completed Payment") {
PaymentCard(
payment: Payment(
id: "1",
accountId: "acc123",
recipientName: "John Doe",
recipientAccountNumber: "12345678",
amount: 125.50,
currency: "USD",
status: .completed,
description: "Monthly subscription payment",
reference: "REF123",
createdAt: Date(),
completedAt: Date()
)
)
.padding()
}
#Preview("Pending Payment") {
PaymentCard(
payment: Payment(
id: "2",
accountId: "acc123",
recipientName: "Jane Smith",
recipientAccountNumber: "87654321",
amount: 500.00,
currency: "USD",
status: .pending,
description: nil,
reference: "REF456",
createdAt: Date(),
completedAt: nil
)
)
.padding()
}
Empty State Component
// Presentation/Components/EmptyStateView.swift
import SwiftUI
struct EmptyStateAction {
let title: String
let action: () -> Void
}
struct EmptyStateView: View {
let icon: String
let title: String
let message: String
let action: EmptyStateAction?
init(
icon: String,
title: String,
message: String,
action: EmptyStateAction? = nil
) {
self.icon = icon
self.title = title
self.message = message
self.action = action
}
var body: some View {
VStack(spacing: 16) {
Image(systemName: icon)
.font(.system(size: 64))
.foregroundColor(.secondary)
.accessibilityHidden(true)
Text(title)
.font(.title2)
.fontWeight(.semibold)
Text(message)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
if let action {
Button(action.title, action: action.action)
.buttonStyle(.borderedProminent)
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
#Preview {
EmptyStateView(
icon: "doc.text.magnifyingglass",
title: "No payments yet",
message: "Create your first payment to get started",
action: EmptyStateAction(
title: "Create Payment",
action: { print("Create payment") }
)
)
}
List and Interaction Patterns
List Virtualization with LazyVStack
SwiftUI's List and LazyVStack automatically virtualize content, rendering only visible items.
// Presentation/Screens/Payments/PaymentListView.swift
struct PaymentListView: View {
@StateObject private var viewModel = PaymentListViewModel()
var body: some View {
List(viewModel.payments) { payment in
NavigationLink(value: payment) {
PaymentCard(payment: payment)
}
}
.listStyle(.plain)
.refreshable {
await viewModel.refresh()
}
.task {
await viewModel.loadPayments()
}
}
}
Why List Over ScrollView: List automatically handles virtualization, recycling views for optimal memory usage and smooth scrolling even with thousands of items.
Pull-to-Refresh
The .refreshable modifier adds pull-to-refresh functionality:
struct TransactionListView: View {
@StateObject private var viewModel = TransactionListViewModel()
var body: some View {
List(viewModel.transactions) { transaction in
TransactionRow(transaction: transaction)
}
.refreshable {
await viewModel.fetchTransactions()
}
}
}
Gesture Handling
SwiftUI provides gesture modifiers for common touch interactions:
struct DraggableCard: View {
@State private var offset = CGSize.zero
var body: some View {
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue)
.frame(width: 200, height: 100)
.offset(offset)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded { _ in
withAnimation(.spring()) {
offset = .zero
}
}
)
}
}
Common Gestures:
.onTapGesture: Single tap.onLongPressGesture: Long pressDragGesture(): Pan/dragMagnificationGesture(): Pinch to zoomRotationGesture(): Two-finger rotation
// Example: Swipe-to-delete
struct SwipeableRow: View {
@State private var offset: CGFloat = 0
let item: Item
let onDelete: () -> Void
var body: some View {
HStack {
Text(item.name)
Spacer()
}
.padding()
.background(Color.white)
.offset(x: offset)
.gesture(
DragGesture()
.onChanged { gesture in
if gesture.translation.width < 0 {
offset = gesture.translation.width
}
}
.onEnded { gesture in
if gesture.translation.width < -100 {
onDelete()
} else {
withAnimation {
offset = 0
}
}
}
)
.background(
Color.red
.frame(width: -offset)
.frame(maxWidth: .infinity, alignment: .trailing)
)
}
}
Navigation
For foundational navigation concepts (stack, tab, modal patterns), see Mobile Navigation. This section covers SwiftUI-specific navigation implementation with NavigationStack and the Coordinator pattern.
NavigationStack Architecture
Coordinator Pattern for Navigation
// Presentation/Navigation/AppCoordinator.swift
import SwiftUI
enum AppRoute: Hashable {
case paymentList
case paymentDetail(Payment)
case createPayment
case accountDetail(Account)
case profile
case settings
}
@MainActor
final class AppCoordinator: ObservableObject {
@Published var path = NavigationPath()
@Published var presentedSheet: AppSheet?
func navigate(to route: AppRoute) {
path.append(route)
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
func popToRoot() {
path.removeLast(path.count)
}
func presentSheet(_ sheet: AppSheet) {
presentedSheet = sheet
}
func dismissSheet() {
presentedSheet = nil
}
}
enum AppSheet: Identifiable {
case createPayment
case filterPayments
case accountSelector
var id: String {
switch self {
case .createPayment: return "createPayment"
case .filterPayments: return "filterPayments"
case .accountSelector: return "accountSelector"
}
}
}
Coordinator View
// Presentation/Navigation/AppCoordinatorView.swift
import SwiftUI
struct AppCoordinatorView: View {
@StateObject private var coordinator = AppCoordinator()
@EnvironmentObject var container: DIContainer
var body: some View {
NavigationStack(path: $coordinator.path) {
PaymentListView()
.navigationDestination(for: AppRoute.self) { route in
destinationView(for: route)
}
}
.sheet(item: $coordinator.presentedSheet) { sheet in
sheetView(for: sheet)
}
.environmentObject(coordinator)
}
@ViewBuilder
private func destinationView(for route: AppRoute) -> some View {
switch route {
case .paymentList:
PaymentListView()
case .paymentDetail(let payment):
PaymentDetailView(payment: payment)
case .accountDetail(let account):
AccountDetailView(account: account)
case .profile:
ProfileView()
case .settings:
SettingsView()
case .createPayment:
CreatePaymentView()
}
}
@ViewBuilder
private func sheetView(for sheet: AppSheet) -> some View {
switch sheet {
case .createPayment:
NavigationStack {
CreatePaymentView()
}
case .filterPayments:
PaymentFilterView()
case .accountSelector:
AccountSelectorView()
}
}
}
Navigation Flow Diagram
Deep Linking
For deep linking concepts and URL structure, see Mobile Navigation - Deep Linking. This section covers iOS-specific Universal Links implementation.
// App/BankingApp.swift
import SwiftUI
@main
struct BankingApp: App {
@StateObject private var coordinator = AppCoordinator()
var body: some Scene {
WindowGroup {
AppCoordinatorView()
.environmentObject(coordinator)
.onOpenURL { url in
handleDeepLink(url)
}
}
}
private func handleDeepLink(_ url: URL) {
// bankapp://payment/123
guard url.scheme == "bankapp" else { return }
let path = url.pathComponents.filter { $0 != "/" }
switch path.first {
case "payment":
if let paymentId = path.dropFirst().first {
loadAndNavigateToPayment(id: paymentId)
}
case "account":
if let accountId = path.dropFirst().first {
loadAndNavigateToAccount(id: accountId)
}
default:
break
}
}
private func loadAndNavigateToPayment(id: String) {
Task {
do {
let payment = try await DIContainer.shared.getPaymentUseCase.execute(id: id)
await MainActor.run {
coordinator.navigate(to: .paymentDetail(payment))
}
} catch {
print("Failed to load payment: \(error)")
}
}
}
private func loadAndNavigateToAccount(id: String) {
// Similar implementation
}
}
Design System
Design System Structure
Colors
// Presentation/Theme/Colors.swift
import SwiftUI
extension Color {
// MARK: - Brand Colors
static let brandPrimary = Color("BrandPrimary") // #0066CC
static let brandSecondary = Color("BrandSecondary") // #00CC66
static let brandAccent = Color("BrandAccent") // #FF6600
// MARK: - Semantic Colors
static let success = Color.green
static let warning = Color.orange
static let error = Color.red
static let info = Color.blue
// MARK: - Payment Status Colors
static let paymentCompleted = Color.green
static let paymentPending = Color.orange
static let paymentFailed = Color.red
static let paymentCancelled = Color.gray
static let paymentProcessing = Color.blue
// MARK: - Background Colors
static let backgroundPrimary = Color(.systemBackground)
static let backgroundSecondary = Color(.secondarySystemBackground)
static let backgroundTertiary = Color(.tertiarySystemBackground)
// MARK: - Text Colors
static let textPrimary = Color(.label)
static let textSecondary = Color(.secondaryLabel)
static let textTertiary = Color(.tertiaryLabel)
}
Typography
// Presentation/Theme/Typography.swift
import SwiftUI
extension Font {
// MARK: - Display
static let displayLarge = Font.system(size: 57, weight: .bold)
static let displayMedium = Font.system(size: 45, weight: .bold)
static let displaySmall = Font.system(size: 36, weight: .bold)
// MARK: - Headline
static let headlineLarge = Font.system(size: 32, weight: .semibold)
static let headlineMedium = Font.system(size: 28, weight: .semibold)
static let headlineSmall = Font.system(size: 24, weight: .semibold)
// MARK: - Title
static let titleLarge = Font.system(size: 22, weight: .medium)
static let titleMedium = Font.system(size: 16, weight: .medium)
static let titleSmall = Font.system(size: 14, weight: .medium)
// MARK: - Body
static let bodyLarge = Font.system(size: 16, weight: .regular)
static let bodyMedium = Font.system(size: 14, weight: .regular)
static let bodySmall = Font.system(size: 12, weight: .regular)
// MARK: - Label
static let labelLarge = Font.system(size: 14, weight: .semibold)
static let labelMedium = Font.system(size: 12, weight: .semibold)
static let labelSmall = Font.system(size: 11, weight: .semibold)
}
Spacing
// Presentation/Theme/Spacing.swift
import SwiftUI
enum Spacing {
static let xxxs: CGFloat = 2
static let xxs: CGFloat = 4
static let xs: CGFloat = 8
static let sm: CGFloat = 12
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
static let xxl: CGFloat = 48
static let xxxl: CGFloat = 64
}
extension View {
func padding(_ spacing: CGFloat) -> some View {
self.padding(spacing)
}
func horizontalPadding(_ spacing: CGFloat = Spacing.md) -> some View {
self.padding(.horizontal, spacing)
}
func verticalPadding(_ spacing: CGFloat = Spacing.md) -> some View {
self.padding(.vertical, spacing)
}
}
Accessibility
VoiceOver Support
// Payment card with accessibility
struct AccessiblePaymentCard: View {
let payment: Payment
var body: some View {
PaymentCard(payment: payment)
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityText)
.accessibilityHint("Double tap to view payment details")
}
private var accessibilityText: String {
"""
Payment to \(payment.recipientName), \
\(payment.amount.formatted(.currency(code: payment.currency))), \
Status: \(payment.status.rawValue), \
Date: \(payment.createdAt.formatted(date: .long, time: .shortened))
"""
}
}
Dynamic Type
// Support Dynamic Type for accessibility
struct DynamicTypeExample: View {
var body: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Text("Payment Amount")
.font(.bodyLarge) // Automatically scales
Text("$125.50")
.font(.displayMedium)
.minimumScaleFactor(0.5) // Prevent excessive scaling
.lineLimit(1)
}
}
}
Accessibility Checklist
Common Mistakes
Don't: Create View State Inside body
// BAD: Creates new state on every render
struct BadView: View {
var body: some View {
let isSelected = false // This is recreated every render!
Text("Hello")
.foregroundColor(isSelected ? .blue : .gray)
}
}
// GOOD: Use @State for view state
struct GoodView: View {
@State private var isSelected = false
var body: some View {
Text("Hello")
.foregroundColor(isSelected ? .blue : .gray)
}
}
Don't: Use @StateObject in Child Views
// BAD: Child view creates @StateObject (will reset on parent re-render)
struct ChildView: View {
@StateObject private var viewModel = PaymentViewModel() // WRONG!
var body: some View {
// ...
}
}
// GOOD: Parent owns @StateObject, child receives via @ObservedObject
struct ParentView: View {
@StateObject private var viewModel = PaymentViewModel()
var body: some View {
ChildView(viewModel: viewModel)
}
}
struct ChildView: View {
@ObservedObject var viewModel: PaymentViewModel
var body: some View {
// ...
}
}
Don't: Mutate State Directly in body
// BAD: Mutating state in body causes infinite loop
struct BadView: View {
@State private var count = 0
var body: some View {
count += 1 // INFINITE LOOP!
return Text("Count: \(count)")
}
}
// GOOD: Mutate state in response to events
struct GoodView: View {
@State private var count = 0
var body: some View {
Button("Increment") {
count += 1
}
}
}
Code Review Checklist
Security (Block PR)
- No sensitive data displayed in plain text - Mask account numbers, card numbers
- Prevent screenshots on sensitive screens - Use
secureFieldmodifier - Biometric auth before sensitive actions - Payments, transfers require authentication
Watch For (Request Changes)
- @StateObject only in root views - Child views use @ObservedObject
- No state mutation in body - Causes infinite re-render loops
- Accessibility labels present - All interactive elements have labels
- Dynamic Type support - Fonts scale with user preferences
- Touch targets 44x44 pt minimum - Apple HIG requirement for accessibility
- Loading states - Show ProgressView while data loads
- Error states - Display user-friendly error messages
Best Practices
- Reusable components - Extract common UI into components
- #Preview provided - All views have SwiftUI previews
- Design system used - Colors, fonts, spacing from design system
- Navigation via coordinator - Centralized navigation logic
- Empty states - Helpful guidance when no data present
- Proper modifier order - Layout → Styling → Behavior → Accessibility
Further Reading
iOS Framework Guidelines
- iOS Overview - Project setup, architecture, dependency injection
- iOS Data & Networking - ViewModels consuming repository data
- iOS Security - Secure UI practices and screenshot protection
- iOS Architecture - MVVM presentation layer with SwiftUI
- iOS Testing - Testing SwiftUI views and ViewModels
- iOS Performance - SwiftUI performance optimization
UI and Mobile Guidelines
- Mobile Navigation - Universal mobile navigation patterns and concepts
- Mobile Overview - Cross-platform mobile UI patterns
- Web State Management - Universal state management patterns and principles
- Web Accessibility - Accessibility principles (applicable to mobile)
- Web Components - Component design patterns
Language Guidelines
- Swift Best Practices - Swift language patterns and idioms
- Swift Concurrency - Async/await in SwiftUI
External Resources
- SwiftUI Documentation
- Human Interface Guidelines
- Accessibility Programming Guide
- WWDC SwiftUI Sessions
Summary
Key Takeaways
- Declarative UI - SwiftUI views are functions of state
- State management - Use correct property wrappers (@State, @StateObject, etc.)
- Navigation - Coordinator pattern for centralized navigation logic
- Reusable components - Build modular, testable UI components
- Design systems - Consistent colors, typography, spacing
- Accessibility - VoiceOver, Dynamic Type, color contrast
- Performance - Avoid state mutations in body, use @ViewBuilder
- Deep linking - Handle push notification navigation
- Previews - Every view has #Preview for rapid development
- Error handling - Show loading, error, and empty states
Next Steps: Explore iOS Data & Networking for integrating UI with backend APIs and local storage.