SwiftUI is Apple's future—90% of new iOS projects use it. Apps built with SwiftUI ship 40% faster with 50% less code than UIKit. But many developers struggle with its paradigm shift. This comprehensive guide takes you from SwiftUI basics to advanced patterns used in production apps.
Why SwiftUI
Benefits Over UIKit
- Less code: 50-70% reduction in boilerplate
- Faster development: Live previews, hot reload
- Declarative: Describe UI, not how to build it
- Cross-platform: iOS, macOS, watchOS, tvOS
- Modern: Dark mode, dynamic type, accessibility built-in
- Type-safe: Compile-time error checking
- Composable: Small, reusable components
When to Use SwiftUI
✓ Use SwiftUI:
- New projects (iOS 15+ target)
- Rapid prototyping
- Content-heavy apps
- Standard UI patterns
- Cross-platform apps
- Small teams
✗ Use UIKit:
- Complex custom controls
- iOS 13 or earlier support needed
- Heavy reliance on third-party UIKit libraries
- Performance-critical scrolling (improving in SwiftUI)
- Need mature tooling
Hybrid approach:
- New features in SwiftUI
- Existing features in UIKit
- Bridge with UIViewRepresentable/UIViewControllerRepresentable
- Gradually migrate
SwiftUI Basics
Hello World
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello, SwiftUI!")
.font(.largeTitle)
.foregroundColor(.blue)
}
}
#Preview {
ContentView()
}
Key concepts:
- View protocol: Defines UI
- body property: Returns content
- some View: Opaque return type
- Modifiers: Chain to customize
- Preview: See changes live
Basic Views
import SwiftUI
struct BasicViewsDemo: View {
var body: some View {
VStack(spacing: 20) {
// Text
Text("SwiftUI Basics")
.font(.title)
.fontWeight(.bold)
// Image
Image(systemName: "star.fill")
.font(.system(size: 50))
.foregroundColor(.yellow)
// Button
Button("Tap Me") {
print("Button tapped")
}
.buttonStyle(.borderedProminent)
// TextField
TextField("Enter text", text: .constant(""))
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
// Toggle
Toggle("Enable Feature", isOn: .constant(true))
.padding(.horizontal)
// Slider
Slider(value: .constant(0.5), in: 0...1)
.padding(.horizontal)
// ProgressView
ProgressView(value: 0.75)
.padding(.horizontal)
}
.padding()
}
}
Layout views:
- VStack: Vertical stack
- HStack: Horizontal stack
- ZStack: Overlapping stack
- List: Scrollable list
- ScrollView: Custom scrolling
- LazyVStack/LazyHStack: Lazy loading
- Grid: iOS 16+ grid layout
State Management
import SwiftUI
// @State - Simple local state
struct CounterView: View {
@State private var count = 0
var body: some View {
VStack {
Text("Count: \(count)")
.font(.title)
Button("Increment") {
count += 1
}
}
}
}
// @Binding - Share state with parent
struct ChildView: View {
@Binding var text: String
var body: some View {
TextField("Enter text", text: $text)
}
}
struct ParentView: View {
@State private var text = ""
var body: some View {
VStack {
Text("You typed: \(text)")
ChildView(text: $text)
}
}
}
// @StateObject - Observable object ownership
class ViewModel: ObservableObject {
@Published var items: [String] = []
func loadItems() {
items = ["Item 1", "Item 2", "Item 3"]
}
}
struct MyView: View {
@StateObject private var viewModel = ViewModel()
var body: some View {
List(viewModel.items, id: \.self) { item in
Text(item)
}
.onAppear {
viewModel.loadItems()
}
}
}
// @ObservedObject - Observable object (not owned)
struct DetailView: View {
@ObservedObject var viewModel: ViewModel
var body: some View {
List(viewModel.items, id: \.self) { item in
Text(item)
}
}
}
// @EnvironmentObject - Shared across views
class AppState: ObservableObject {
@Published var isLoggedIn = false
@Published var username = ""
}
@main
struct MyApp: App {
@StateObject private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appState)
}
}
}
struct AnyChildView: View {
@EnvironmentObject var appState: AppState
var body: some View {
if appState.isLoggedIn {
Text("Welcome, \(appState.username)!")
} else {
Text("Please log in")
}
}
}
Property wrapper guide:
@State - Owned simple value
@Binding - Shared with parent
@StateObject - Owned observable object
@ObservedObject - Passed observable object
@EnvironmentObject - Injected global state
@Published - Triggers view updates
@Environment - System environment values
Navigation
NavigationStack (iOS 16+)
import SwiftUI
struct User: Identifiable, Hashable {
let id = UUID()
let name: String
let age: Int
}
struct NavigationDemo: View {
let users = [
User(name: "Alice", age: 25),
User(name: "Bob", age: 30),
User(name: "Charlie", age: 35)
]
var body: some View {
NavigationStack {
List(users) { user in
NavigationLink(value: user) {
Text(user.name)
}
}
.navigationTitle("Users")
.navigationDestination(for: User.self) { user in
UserDetailView(user: user)
}
}
}
}
struct UserDetailView: View {
let user: User
var body: some View {
VStack {
Text(user.name)
.font(.largeTitle)
Text("Age: \(user.age)")
}
.navigationTitle("Details")
}
}
// Programmatic navigation
struct ProgrammaticNav: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack {
Button("Go to Level 1") {
path.append(1)
}
Button("Go to Level 2") {
path.append(2)
}
Button("Go to Root") {
path.removeLast(path.count)
}
}
.navigationDestination(for: Int.self) { level in
Text("Level \(level)")
}
}
}
}
Sheets and Modals
import SwiftUI
struct ModalDemo: View {
@State private var showingSheet = false
@State private var showingFullScreen = false
@State private var showingAlert = false
var body: some View {
VStack(spacing: 20) {
// Sheet (bottom drawer)
Button("Show Sheet") {
showingSheet = true
}
.sheet(isPresented: $showingSheet) {
SheetView()
}
// Full screen cover
Button("Show Full Screen") {
showingFullScreen = true
}
.fullScreenCover(isPresented: $showingFullScreen) {
FullScreenView()
}
// Alert
Button("Show Alert") {
showingAlert = true
}
.alert("Important Message", isPresented: $showingAlert) {
Button("OK", role: .cancel) { }
Button("Delete", role: .destructive) { }
} message: {
Text("Are you sure?")
}
}
}
}
struct SheetView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Text("Sheet Content")
.navigationTitle("Sheet")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
dismiss()
}
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
struct FullScreenView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack {
Color.blue.ignoresSafeArea()
VStack {
Text("Full Screen")
.font(.largeTitle)
.foregroundColor(.white)
Button("Dismiss") {
dismiss()
}
.buttonStyle(.bordered)
}
}
}
}
Lists and Collections
List Basics
import SwiftUI
struct Item: Identifiable {
let id = UUID()
let title: String
let subtitle: String
}
struct ListDemo: View {
let items = [
Item(title: "Item 1", subtitle: "Description 1"),
Item(title: "Item 2", subtitle: "Description 2"),
Item(title: "Item 3", subtitle: "Description 3")
]
var body: some View {
List {
// Simple list
ForEach(items) { item in
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
// Sections
Section("Section 1") {
Text("Row 1")
Text("Row 2")
}
Section("Section 2") {
Text("Row 3")
Text("Row 4")
}
}
.listStyle(.insetGrouped)
}
}
// Swipe actions
struct SwipeActionsDemo: View {
@State private var items = ["Item 1", "Item 2", "Item 3"]
var body: some View {
List {
ForEach(items, id: \.self) { item in
Text(item)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
items.removeAll { $0 == item }
} label: {
Label("Delete", systemImage: "trash")
}
Button {
print("Edit \(item)")
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
.swipeActions(edge: .leading) {
Button {
print("Pin \(item)")
} label: {
Label("Pin", systemImage: "pin")
}
.tint(.orange)
}
}
}
}
}
// Pull to refresh
struct RefreshableList: View {
@State private var items = ["Item 1", "Item 2"]
var body: some View {
List(items, id: \.self) { item in
Text(item)
}
.refreshable {
await loadData()
}
}
func loadData() async {
try? await Task.sleep(for: .seconds(1))
items.append("New Item")
}
}
LazyVGrid and LazyHGrid
import SwiftUI
struct GridDemo: View {
let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(1...100, id: \.self) { number in
Rectangle()
.fill(Color.blue)
.frame(height: 100)
.overlay {
Text("\(number)")
.font(.title)
.foregroundColor(.white)
}
}
}
.padding()
}
}
}
// Adaptive columns
struct AdaptiveGrid: View {
let items = Array(1...100)
var body: some View {
ScrollView {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 100))],
spacing: 16
) {
ForEach(items, id: \.self) { item in
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue)
.frame(height: 100)
.overlay {
Text("\(item)")
.foregroundColor(.white)
}
}
}
.padding()
}
}
}
Data Flow
MVVM Pattern
import SwiftUI
import Combine
// Model
struct Post: Identifiable, Codable {
let id: Int
let title: String
let body: String
}
// ViewModel
@MainActor
class PostsViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var isLoading = false
@Published var errorMessage: String?
func fetchPosts() async {
isLoading = true
errorMessage = nil
do {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
let (data, _) = try await URLSession.shared.data(from: url)
posts = try JSONDecoder().decode([Post].self, from: data)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func deletePost(_ post: Post) {
posts.removeAll { $0.id == post.id }
}
}
// View
struct PostsView: View {
@StateObject private var viewModel = PostsViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView()
} else if let error = viewModel.errorMessage {
VStack {
Text("Error")
.font(.headline)
Text(error)
.foregroundColor(.secondary)
Button("Retry") {
Task {
await viewModel.fetchPosts()
}
}
}
} else {
List {
ForEach(viewModel.posts) { post in
NavigationLink(value: post) {
VStack(alignment: .leading) {
Text(post.title)
.font(.headline)
Text(post.body)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
}
.onDelete { indexSet in
for index in indexSet {
viewModel.deletePost(viewModel.posts[index])
}
}
}
}
}
.navigationTitle("Posts")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
Task {
await viewModel.fetchPosts()
}
} label: {
Image(systemName: "arrow.clockwise")
}
}
}
.navigationDestination(for: Post.self) { post in
PostDetailView(post: post)
}
}
.task {
await viewModel.fetchPosts()
}
}
}
struct PostDetailView: View {
let post: Post
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(post.title)
.font(.title)
Text(post.body)
.font(.body)
}
.padding()
}
.navigationTitle("Post #\(post.id)")
.navigationBarTitleDisplayMode(.inline)
}
}
Animations
Basic Animations
import SwiftUI
struct AnimationDemo: View {
@State private var isExpanded = false
@State private var rotation = 0.0
@State private var scale = 1.0
@State private var offset = CGSize.zero
var body: some View {
VStack(spacing: 40) {
// Implicit animation
RoundedRectangle(cornerRadius: isExpanded ? 50 : 10)
.fill(Color.blue)
.frame(
width: isExpanded ? 200 : 100,
height: isExpanded ? 200 : 100
)
.animation(.spring(response: 0.6), value: isExpanded)
Button("Toggle Size") {
isExpanded.toggle()
}
// Rotation
Image(systemName: "arrow.right")
.font(.largeTitle)
.rotationEffect(.degrees(rotation))
.onTapGesture {
withAnimation(.easeInOut(duration: 1)) {
rotation += 360
}
}
// Scale
Circle()
.fill(Color.green)
.frame(width: 100, height: 100)
.scaleEffect(scale)
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
scale = scale == 1.0 ? 1.5 : 1.0
}
}
// Drag gesture with animation
Circle()
.fill(Color.red)
.frame(width: 80, height: 80)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
}
.onEnded { _ in
withAnimation(.spring()) {
offset = .zero
}
}
)
}
}
}
Custom Transitions
import SwiftUI
struct TransitionDemo: View {
@State private var show = false
var body: some View {
VStack {
Button("Toggle") {
withAnimation {
show.toggle()
}
}
if show {
// Slide transition
Text("Slide")
.transition(.slide)
// Scale transition
Text("Scale")
.transition(.scale)
// Opacity transition
Text("Opacity")
.transition(.opacity)
// Move transition
Text("Move")
.transition(.move(edge: .trailing))
// Combined transition
Text("Combined")
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity),
removal: .move(edge: .trailing)
))
// Custom transition
Text("Custom")
.transition(.customSlide)
}
}
}
}
// Custom transition
extension AnyTransition {
static var customSlide: AnyTransition {
.modifier(
active: CustomSlideModifier(offset: 100),
identity: CustomSlideModifier(offset: 0)
)
}
}
struct CustomSlideModifier: ViewModifier {
let offset: CGFloat
func body(content: Content) -> some View {
content
.offset(x: offset)
.opacity(offset == 0 ? 1 : 0)
}
}
Best Practices
Performance Optimization
1. Use Lazy containers:
✓ LazyVStack instead of VStack in ScrollView
✓ LazyHStack instead of HStack in ScrollView
✓ LazyVGrid/LazyHGrid for grids
2. Avoid expensive operations in body:
✗ Don't compute heavy calculations
✗ Don't perform networking
✗ Don't use Date() multiple times
✓ Cache computed properties
✓ Use @State for derived values
✓ Move logic to ViewModel
3. Use Identifiable:
✓ Conform to Identifiable
✓ Use stable IDs (UUID, database ID)
✗ Don't use array index as ID
4. Minimize view updates:
✓ Split views into smaller components
✓ Use @Published only for UI-relevant properties
✓ Use equatable to prevent unnecessary updates
5. Optimize images:
✓ Use AsyncImage for remote images
✓ Cache images
✓ Resize large images
✓ Use appropriate formats (HEIC, WebP)
Code Organization
Project structure:
MyApp/
├── App/
│ ├── MyApp.swift
│ └── ContentView.swift
├── Models/
│ ├── User.swift
│ └── Post.swift
├── ViewModels/
│ ├── UsersViewModel.swift
│ └── PostsViewModel.swift
├── Views/
│ ├── Users/
│ │ ├── UsersView.swift
│ │ └── UserRow.swift
│ └── Posts/
│ ├── PostsView.swift
│ └── PostDetailView.swift
├── Services/
│ ├── APIService.swift
│ └── AuthService.swift
├── Utilities/
│ ├── Extensions.swift
│ └── Constants.swift
└── Resources/
└── Assets.xcassets
Best practices:
✓ One view per file
✓ Extract reusable components
✓ Keep views small (< 200 lines)
✓ Use view extensions for modifiers
✓ Group related files in folders
Conclusion
SwiftUI represents the future of iOS development with its declarative syntax, powerful state management, and seamless integration with modern Swift features. While there's a learning curve coming from UIKit, the productivity gains and code reduction make it worthwhile. Start with simple views, master state management, and gradually adopt advanced patterns. SwiftUI is mature enough for production—don't wait to adopt it.
Beautiful SwiftUI apps need great support infrastructure. Our support URL generator creates professional support pages that meet App Store requirements, ensuring your modern SwiftUI app has modern support to match.