How to use this guide: Each section builds on the last. Code examples are production-oriented — the patterns shown here reflect real-world usage, not toy demos. Where Swift nuances matter, they're explained in full.
Before writing a single line of SwiftUI, you need to rewire how you think about UI construction. UIKit is imperative: you tell the system how to build the UI step-by-step.
// UIKit — imperative
let label = UILabel()
label.text = "Hello"
label.textColor = .red
view.addSubview(label)
SwiftUI is declarative: you describe what the UI should look like given the current state. The framework figures out how to get there.
// SwiftUI — declarative
Text("Hello")
.foregroundColor(.red)
The critical insight: SwiftUI is a function of state. Your UI is not a persistent tree of objects you mutate — it's a description that gets regenerated every time state changes. SwiftUI then diffs that description against the previous one and applies the minimum changes needed to the actual render layer.
UI = f(State)
SwiftUI tracks views across updates using identity. There are two kinds:
Text views in the same position of the same branch are treated as the same view across re-renders..id(_:) to give a view a stable, explicit identity. SwiftUI will treat a view with a new .id() value as a completely new view, destroying the old one and creating a fresh one.// Explicit identity forces re-creation when id changes
Text("Hello")
.id(userID) // new userID = new Text view entirely
Understanding identity is critical for animations and for avoiding subtle bugs where SwiftUI reuses view state when you expect a fresh view.
When state changes:
body on affected views.body must be pure — same state in, same view description out, every time. Side effects in body (network calls, mutations) are bugs waiting to happen.
Every piece of UI in SwiftUI conforms to the View protocol:
public protocol View {
associatedtype Body: View
@ViewBuilder var body: Self.Body { get }
}
The associatedtype Body is what makes SwiftUI's type system work. When you write a custom view, Swift infers the concrete type of Body from your implementation — no AnyView erasure required (though that exists too, at a performance cost).
SwiftUI ships with a set of primitive views. Everything else is composed from these:
// Text — renders a string
Text("Hello, World!")
Text("Formatted: \(Date(), style: .date)")
Text(attributedString) // NSAttributedString / AttributedString
// Image
Image("launchScreen") // from asset catalog
Image(systemName: "heart.fill") // SF Symbol
Image(decorative: "background") // accessibility: not announced
// Shapes
Rectangle()
Circle()
RoundedRectangle(cornerRadius: 12)
Capsule()
Ellipse()
// Controls
Button("Tap me") { print("tapped") }
Toggle("Enabled", isOn: $isEnabled)
Slider(value: $progress, in: 0...1)
Stepper("Count: \(count)", value: $count, in: 0...10)
TextField("Placeholder", text: $inputText)
SecureField("Password", text: $password)
DatePicker("Date", selection: $date)
Picker("Flavor", selection: $flavor) { ... }
// Spacers & Dividers
Spacer() // flexible, fills available space
Divider() // horizontal or vertical line
Views are composed by nesting them inside container views. SwiftUI encourages extracting subviews aggressively — this is free from a performance standpoint because SwiftUI only re-renders views whose state changed.
struct ProfileCard: View {
let user: User
var body: some View {
VStack(alignment: .leading, spacing: 12) {
AvatarView(imageURL: user.avatarURL)
UserInfoView(name: user.name, bio: user.bio)
FollowButton(userID: user.id)
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
struct AvatarView: View {
let imageURL: URL
var body: some View {
AsyncImage(url: imageURL) { image in
image.resizable().scaledToFill()
} placeholder: {
ProgressView()
}
.frame(width: 60, height: 60)
.clipShape(Circle())
}
}
some View vs AnyViewsome View is an opaque return type. The compiler knows the concrete type but you don't have to spell it out. This preserves type information, allowing SwiftUI's diffing to work efficiently.
// ✅ Preferred — type is known at compile time
var body: some View {
Text("Hello")
}
// ⚠️ Type erasure — use sparingly
func makeView() -> AnyView {
AnyView(Text("Hello"))
}
AnyView erases the type, which means SwiftUI loses structural information and can't diff as efficiently. It also prevents certain optimizations. The two main legitimate uses: heterogeneous collections, or returning different concrete types from a function (which @ViewBuilder usually solves better).
Modifiers transform a view, returning a new View wrapping the original. The order matters significantly.
Text("Hello")
.padding() // adds padding inside the background
.background(.blue) // background covers padded area
.cornerRadius(8)
// vs.
Text("Hello")
.background(.blue) // background hugs the text
.padding() // padding added outside the background
.cornerRadius(8)
Modifiers are not mutating the view — they're building a chain of wrapper types. Text("Hello").padding() returns a ModifiedContent<Text, _PaddingLayout>. Every modifier wraps the previous result in another generic type. This is why some View is essential — spelling out those types manually would be unreadable.
SwiftUI's layout works through a recursive negotiation:
A child always has final say over its own size. A parent can propose but cannot force.
// Text takes exactly the space it needs (ignores proposal if unconstrained)
Text("Hello") // hugs content
// Spacer takes all available space
Spacer()
// Rectangle fills proposed space by default
Rectangle()
.fill(.blue)
VStack, HStack, ZStack are the workhorses:
// HStack — horizontal, with alignment and spacing
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "star")
Text("Rating")
Spacer()
Text("4.8")
.bold()
}
// VStack — vertical
VStack(alignment: .leading, spacing: 4) {
Text("Title").font(.headline)
Text("Subtitle").font(.caption).foregroundStyle(.secondary)
}
// ZStack — layered (back to front)
ZStack(alignment: .bottomTrailing) {
Image("photo")
BadgeView()
.offset(x: 8, y: 8)
}
Alignment across a stack dimension uses alignment guides. The default HStack alignment is .center — this means SwiftUI aligns the centers of children vertically. .firstTextBaseline aligns based on where the first text baseline sits, which is usually more typographically correct when mixing text sizes.
LazyVStack and LazyHStack defer view creation until a view is about to be visible. Use inside a ScrollView when you have many items.
ScrollView {
LazyVStack(spacing: 16, pinnedViews: [.sectionHeaders]) {
ForEach(sections) { section in
Section {
ForEach(section.items) { item in
ItemRow(item: item)
}
} header: {
SectionHeader(title: section.title)
.background(.regularMaterial) // sticky header looks good with material
}
}
}
}
Important: Don't use LazyVStack everywhere. Regular VStack is faster when you have a small, fixed number of children because lazy stacks have overhead for managing view recycling. Prefer List or LazyVStack only when your content is scrollable and large.
Grid (iOS 16+) is a two-dimensional layout container:
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("Name").bold()
Text("Age").bold()
Text("Role").bold()
}
Divider().gridCellUnsizedAxes(.horizontal)
ForEach(users) { user in
GridRow {
Text(user.name)
Text("\(user.age)")
Text(user.role)
}
}
}
GridRow defines a row. Views in a Grid align along their column. Use .gridCellColumns(_:) to span a view across multiple columns.
.frame() is one of the most-used and most-misunderstood modifiers:
// Fixed size — view always this size regardless of content
Text("Hello")
.frame(width: 200, height: 50)
// Flexible with limits
Text("Hello")
.frame(minWidth: 100, maxWidth: .infinity, minHeight: 44)
// Fill all available width
Text("Hello")
.frame(maxWidth: .infinity, alignment: .leading)
When you set maxWidth: .infinity, the frame expands to fill the parent's proposal, but the content inside is positioned using the alignment parameter. The text itself doesn't stretch — it sits at the leading edge because that's what alignment: .leading specifies.
Custom alignment guides let you align views across container boundaries:
extension HorizontalAlignment {
enum MyCustomAlignment: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[HorizontalAlignment.center]
}
}
static let myCustom = HorizontalAlignment(MyCustomAlignment.self)
}
VStack(alignment: .myCustom) {
Text("Short")
.alignmentGuide(.myCustom) { d in d[.leading] }
Text("A longer line of text")
.alignmentGuide(.myCustom) { d in d[.trailing] }
}
Alignment guides are powerful for creating complex, precisely aligned layouts without resorting to GeometryReader or hardcoded offsets.
GeometryReader gives you access to the proposed size and safe area insets from the parent:
GeometryReader { proxy in
let width = proxy.size.width
let height = proxy.size.height
let safeArea = proxy.safeAreaInsets
Circle()
.fill(.blue)
.frame(width: width * 0.8) // 80% of available width
.position(x: width / 2, y: height / 2)
}
GeometryReader takes up all proposed space — it doesn't size itself to its content. Be careful using it inside VStacks; wrap it in a .frame(height:) if needed. Overusing GeometryReader is a code smell — SwiftUI's layout system can handle most cases without it. Reach for it when you genuinely need proportional sizing or need to read the available space.
State management is where SwiftUI lives and breathes. Getting this right is the difference between a smooth app and a buggy, unpredictable one.
@State is for view-local, value-type state. It lives inside SwiftUI's managed storage — not inside your view struct itself (which gets recreated frequently).
struct CounterView: View {
@State private var count = 0
@State private var isExpanded = false
var body: some View {
VStack {
Button("Increment") {
count += 1
}
if isExpanded {
Text("Count is \(count)")
.transition(.opacity)
}
Button("Toggle Detail") {
withAnimation {
isExpanded.toggle()
}
}
}
}
}
Key rules for @State:
private: State should never be accessed externally. If a parent needs to know about it, it should be lifted to the parent.@State works beautifully with Int, Bool, String, struct, enum. Avoid using reference types.@State should be initialized with a default value inline. Don't initialize it from parameters (use @Binding for that pattern).@State works with any value type, including your own structs:
struct FilterState {
var searchText = ""
var selectedCategory: Category = .all
var sortOrder: SortOrder = .ascending
var showFavoritesOnly = false
}
struct ContentView: View {
@State private var filters = FilterState()
var body: some View {
ProductList(filters: filters)
.searchable(text: $filters.searchText)
}
}
Using a struct to group related state is a great pattern — it reduces the number of @State properties and keeps related state cohesive.
SwiftUI views are structs, not classes. This is intentional:
The "real" persistent state lives in SwiftUI's internal storage, keyed by the view's identity in the hierarchy. Your struct is a transient description.
@Binding creates a two-way reference to state owned elsewhere. It's a read-write handle to state that lives in a parent.
struct ToggleRow: View {
let title: String
@Binding var isOn: Bool // doesn't own the value
var body: some View {
Toggle(title, isOn: $isOn)
}
}
struct SettingsView: View {
@State private var notificationsEnabled = true // owns the value
var body: some View {
ToggleRow(title: "Notifications", isOn: $notificationsEnabled)
}
}
The $ prefix creates a Binding<T> from a @State or @StateObject property. Passing $notificationsEnabled gives ToggleRow a handle to mutate the parent's state.
You can derive bindings from existing ones using subscripts or transformations:
// Binding to an array element
ForEach($items) { $item in
ItemEditor(item: $item) // $item is Binding<Item>
}
// Binding to an optional (map)
let nameBinding = Binding<String>(
get: { user?.name ?? "" },
set: { user?.name = $0 }
)
// Binding with transformation
let doubledBinding = Binding<Double>(
get: { value * 2 },
set: { value = $0 / 2 }
)
You can create bindings from scratch using the Binding initializer:
struct SearchBar: View {
@Binding var text: String
// Custom binding that trims whitespace on set
var trimmedBinding: Binding<String> {
Binding(
get: { text },
set: { text = $0.trimmingCharacters(in: .whitespaces) }
)
}
var body: some View {
TextField("Search", text: trimmedBinding)
}
}
This pattern is useful for adding validation, transformation, or side effects to a binding without changing the source data model.
// ❌ WRONG: Mutating @Binding in a computed property getter
var displayName: String {
text = text.uppercased() // side effect in getter — undefined behavior
return text
}
// ✅ Mutation should happen in response to events
Button("Uppercase") {
text = text.uppercased()
}
The environment is a dictionary-like storage that flows down the view hierarchy. SwiftUI uses it for system values, and you can inject your own.
struct ContentView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.locale) var locale
@Environment(\.dynamicTypeSize) var typeSize
@Environment(\.horizontalSizeClass) var sizeClass
@Environment(\.dismiss) var dismiss // action
@Environment(\.openURL) var openURL // action
@Environment(\.isEnabled) var isEnabled
var body: some View {
VStack {
if colorScheme == .dark {
Text("Dark mode!")
}
Button("Dismiss") {
dismiss()
}
}
}
}
// 1. Define the key
struct CardStyleEnvironmentKey: EnvironmentKey {
static let defaultValue: CardStyle = .default
}
// 2. Extend EnvironmentValues
extension EnvironmentValues {
var cardStyle: CardStyle {
get { self[CardStyleEnvironmentKey.self] }
set { self[CardStyleEnvironmentKey.self] = newValue }
}
}
// 3. Inject via .environment
ParentView()
.environment(\.cardStyle, .compact)
// 4. Read anywhere in the subtree
struct CardView: View {
@Environment(\.cardStyle) var cardStyle
// ...
}
Custom environment values are great for theme systems, feature flags, and configuration that needs to flow down a deep hierarchy without prop-drilling.
@EnvironmentObject injects a reference-type observable object into the environment:
// Define the object
class AppRouter: ObservableObject {
@Published var currentRoute: Route = .home
}
// Inject at the root
@main
struct MyApp: App {
@StateObject private var router = AppRouter()
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(router)
}
}
}
// Read anywhere in the tree
struct SomeDeepView: View {
@EnvironmentObject var router: AppRouter
var body: some View {
Button("Go Home") {
router.currentRoute = .home
}
}
}
If you use @EnvironmentObject in a view but forget to inject it, you'll get a runtime crash — not a compile-time error. Prefer the newer @Environment(SomeClass.self) pattern available in iOS 17+ with the @Observable macro, which is type-safe.
Preferences flow up the hierarchy — the reverse of environment. They let child views communicate values to ancestors:
struct HeightPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue()) // take the maximum height
}
}
// Child reports its height
struct MeasurableRow: View {
var body: some View {
Text("Row Content")
.background(
GeometryReader { geo in
Color.clear
.preference(key: HeightPreferenceKey.self,
value: geo.size.height)
}
)
}
}
// Parent reads heights of all children
struct ParentView: View {
@State private var maxHeight: CGFloat = 0
var body: some View {
VStack {
ForEach(rows) { row in
MeasurableRow()
}
}
.onPreferenceChange(HeightPreferenceKey.self) { height in
maxHeight = height
}
}
}
A common use case is making all cells in a list the same height (the tallest one's height), or scroll-tracking for custom navigation effects.
For reference type state that persists beyond a single view's lifetime, you use ObservableObject:
class UserViewModel: ObservableObject {
@Published var name: String = ""
@Published var isLoading: Bool = false
@Published var error: Error? = nil
private var cancellables = Set<AnyCancellable>()
func loadUser(id: String) {
isLoading = true
UserService.shared.fetchUser(id: id)
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.error = error
}
},
receiveValue: { [weak self] user in
self?.name = user.name
}
)
.store(in: &cancellables)
}
}
@StateObject vs @ObservedObject: this is one of the most important distinctions in SwiftUI.
// ✅ @StateObject — view OWNS the object
// Created once; survives view re-renders
struct ProfileView: View {
@StateObject private var viewModel = UserViewModel()
// ...
}
// ✅ @ObservedObject — view receives the object from outside
// Must be created elsewhere (@StateObject in a parent)
struct NameField: View {
@ObservedObject var viewModel: UserViewModel
// ...
}
// ❌ WRONG: using @ObservedObject for a locally created object
struct ProfileView: View {
@ObservedObject private var viewModel = UserViewModel()
// This recreates the viewModel every time ProfileView's body is re-evaluated!
}
The rule: the view that creates an object should use @StateObject. Views that receive an existing object use @ObservedObject.
@Published is a Combine property wrapper. When you write @Published var name: String, it creates a Publisher for name that fires whenever the value changes. ObservableObject synthesizes an objectWillChange: ObservableObjectPublisher publisher. @Published automatically calls objectWillChange.send() before each mutation, which triggers SwiftUI's rendering cycle.
class ViewModel: ObservableObject {
@Published var count = 0
// This is roughly what @Published does:
var count: Int = 0 {
willSet {
objectWillChange.send()
}
}
}
ViewModels are the right place for Combine pipelines:
class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published var results: [Item] = []
@Published var isLoading = false
private var cancellables = Set<AnyCancellable>()
init() {
// Debounce search input, avoid searching on every keystroke
$query
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.handleEvents(receiveOutput: { [weak self] _ in
self?.isLoading = true
})
.flatMap { query in
SearchService.search(query: query)
.catch { _ in Just([]) }
}
.receive(on: DispatchQueue.main)
.assign(to: &$results) // assign to @Published directly!
// isLoading cleanup when results arrive
$results
.map { _ in false }
.assign(to: &$isLoading)
}
}
.assign(to: &$results) is a Combine operator that wires a publisher directly to a @Published property, handling lifecycle correctly without leaking.
iOS 17+ brings the @Observable macro, which replaces ObservableObject with a cleaner, more performant system.
// Before (ObservableObject)
class UserStore: ObservableObject {
@Published var name: String = ""
@Published var age: Int = 0
}
// After (@Observable) — no @Published needed!
@Observable
class UserStore {
var name: String = ""
var age: Int = 0
var profileImage: UIImage? = nil
}
The @Observable macro tracks per-property access. A view that reads only name will only re-render when name changes — not when age changes. With ObservableObject, any @Published change invalidates all observing views. @Observable is strictly more efficient.
// Create and own with @State
struct ProfileView: View {
@State private var store = UserStore()
var body: some View {
TextField("Name", text: $store.name) // $ works on @Observable properties via @State
}
}
// Inject into environment
WindowGroup {
ContentView()
.environment(store) // No .environmentObject — just .environment
}
// Read from environment
struct DeepView: View {
@Environment(UserStore.self) var store // type-safe, no runtime crash
}
With @Observable, use @Bindable to create bindings:
struct EditView: View {
@Bindable var store: UserStore // received from outside, creates bindings
var body: some View {
TextField("Name", text: $store.name) // $store.name is a Binding<String>
}
}
Migration rule: For iOS 17+ projects, prefer @Observable over ObservableObject. For iOS 16 and below, use ObservableObject.
Navigation evolved dramatically with iOS 16. Here's the full picture.
NavigationStack is the replacement for NavigationView. It's programmatically driven with a path:
struct AppView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
HomeView()
.navigationDestination(for: User.self) { user in
UserDetailView(user: user)
}
.navigationDestination(for: Post.self) { post in
PostDetailView(post: post)
}
}
}
}
struct HomeView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
List(users) { user in
NavigationLink(value: user) {
UserRow(user: user)
}
}
}
}
NavigationPath is a type-erased stack. You can path.append(someUser) to push programmatically, path.removeLast() to pop, or path.removeLast(path.count) to pop to root.
// Value-based (preferred, iOS 16+)
NavigationLink(value: item) {
Text(item.name)
}
// Label-based (legacy, iOS 13+)
NavigationLink("Item") {
ItemDetailView(item: item)
}
// isActive binding (legacy, avoid in new code)
NavigationLink("Item", isActive: $isDetailShowing) {
ItemDetailView()
}
For iPad and Mac, use NavigationSplitView:
struct ContentView: View {
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
// Sidebar
List(items, selection: $selectedItem) { item in
NavigationLink(value: item) {
Text(item.name)
}
}
} detail: {
// Detail
if let item = selectedItem {
DetailView(item: item)
} else {
Text("Select an item")
}
}
}
}
struct ContentView: View {
@State private var showingSheet = false
@State private var selectedUser: User?
@State private var showingPopover = false
var body: some View {
VStack {
// Boolean-driven sheet
Button("Show Sheet") { showingSheet = true }
.sheet(isPresented: $showingSheet) {
SheetView()
.presentationDetents([.medium, .large]) // iOS 16+
.presentationDragIndicator(.visible)
}
// Item-driven sheet (cleaner pattern)
Button("Show User") { selectedUser = someUser }
.sheet(item: $selectedUser) { user in
UserDetailSheet(user: user)
}
// Full screen
Button("Full Screen") { showingSheet = true }
.fullScreenCover(isPresented: $showingSheet) {
FullScreenView()
}
// Popover (shows as sheet on iPhone)
Button("Popover") { showingPopover = true }
.popover(isPresented: $showingPopover,
attachmentAnchor: .point(.bottom),
arrowEdge: .top) {
PopoverContent()
.frame(width: 300, height: 200)
}
// Confirmation dialog
Button("Delete", role: .destructive) { showingSheet = true }
.confirmationDialog("Are you sure?", isPresented: $showingSheet) {
Button("Delete", role: .destructive) { performDelete() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This cannot be undone.")
}
}
}
}
// From a sheet
struct SheetView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
Button("Close") { dismiss() }
}
}
// Programmatic navigation pop
struct DetailView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
Button("Back") { dismiss() }
}
}
List is the most feature-rich scrolling container in SwiftUI. It handles cell recycling, swipe actions, edit mode, and section headers automatically.
struct UserListView: View {
@State private var users: [User]
@State private var selection = Set<User.ID>() // multi-selection
var body: some View {
List(users, selection: $selection) { user in
HStack {
AsyncImage(url: user.avatarURL)
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(user.name).font(.headline)
Text(user.email).font(.caption).foregroundStyle(.secondary)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
deleteUser(user)
} label: {
Label("Delete", systemImage: "trash")
}
Button {
archiveUser(user)
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
}
}
.listStyle(.insetGrouped)
.toolbar {
EditButton()
}
}
}
List {
Section {
ForEach(pinnedItems) { item in
ItemRow(item: item)
}
} header: {
Text("Pinned").font(.caption).foregroundStyle(.secondary)
} footer: {
Text("\(pinnedItems.count) pinned items")
.font(.caption2)
}
Section("Recent") {
ForEach(recentItems) { item in
ItemRow(item: item)
}
}
}
struct TaskListView: View {
@State private var tasks: [Task] = Task.samples
var body: some View {
List {
ForEach(tasks) { task in
TaskRow(task: task)
}
.onDelete { indexSet in
tasks.remove(atOffsets: indexSet)
}
.onMove { source, destination in
tasks.move(fromOffsets: source, toOffset: destination)
}
}
.toolbar {
EditButton() // toggles list edit mode for move/delete handles
}
}
}
ForEach is not a loop — it's a view that generates multiple views. The id parameter tells SwiftUI how to identify each element:
// Identifiable — preferred
ForEach(items) { item in ... } // uses item.id automatically
// Custom keypath
ForEach(strings, id: \.self) { string in ... }
// Range
ForEach(0..<5) { i in Text("Item \(i)") }
// Binding iteration (iOS 15+)
ForEach($items) { $item in
ItemEditor(item: $item) // $item is Binding<Item>
}
If id values aren't stable or unique, you'll get animation glitches and re-render bugs. Always use stable, unique identifiers.
struct MessageListView: View {
@State private var messages: [Message]
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
ForEach(messages) { message in
MessageBubble(message: message)
.id(message.id) // needed for scrollTo
}
}
}
.onChange(of: messages.count) { _, newCount in
// Auto-scroll to latest message
if let lastID = messages.last?.id {
withAnimation {
proxy.scrollTo(lastID, anchor: .bottom)
}
}
}
}
}
}
struct AnimatedView: View {
@State private var isLarge = false
var body: some View {
Circle()
.fill(.blue)
.frame(width: isLarge ? 200 : 100,
height: isLarge ? 200 : 100)
.animation(.spring(response: 0.4, dampingFraction: 0.6),
value: isLarge)
.onTapGesture { isLarge.toggle() }
}
}
The value: parameter is critical — it scopes the animation to changes in that specific value. Without it, all state changes to that view animate, which is usually too broad and causes animation leakage.
Button("Toggle") {
withAnimation(.easeInOut(duration: 0.3)) {
isExpanded.toggle()
selectedItem = nil
progress = 1.0
}
}
withAnimation wraps a block of mutations. All state changes inside will animate. This is explicit and clear about what's animated.
// Built-in
.animation(.linear, value: progress)
.animation(.easeIn, value: progress)
.animation(.easeOut, value: progress)
.animation(.easeInOut, value: progress)
.animation(.default, value: progress) // system default (easeInOut)
// Spring — physics-based
.animation(.spring(), value: isOn)
.animation(.spring(response: 0.5, dampingFraction: 0.7, blendDuration: 0), value: isOn)
// Bouncy / Smooth (iOS 17+)
.animation(.bouncy, value: isOn)
.animation(.smooth, value: isOn)
.animation(.snappy, value: isOn)
// Custom bezier
.animation(.timingCurve(0.2, 0.8, 0.2, 1.0, duration: 0.4), value: isOn)
// Delayed or repeated
.animation(.easeOut.delay(0.2), value: isOn)
.animation(.easeOut.repeatCount(3, autoreverses: true), value: isOn)
.animation(.easeOut.repeatForever(autoreverses: false), value: isOn)
Transitions define how a view enters/exits when it's conditionally shown:
if isVisible {
Text("Hello!")
.transition(.opacity) // fade
.transition(.move(edge: .bottom)) // slide from bottom
.transition(.slide) // slides from leading
.transition(.scale) // scale from center
.transition(.asymmetric(
insertion: .move(edge: .top),
removal: .move(edge: .bottom)
)) // different in/out
}
Transitions are combined with animation on the parent or via withAnimation:
withAnimation(.spring()) {
isVisible.toggle()
}
extension AnyTransition {
static var flipFromLeft: AnyTransition {
AnyTransition.modifier(
active: FlipEffect(angle: -90),
identity: FlipEffect(angle: 0)
)
}
}
struct FlipEffect: ViewModifier {
let angle: Double
func body(content: Content) -> some View {
content.rotation3DEffect(.degrees(angle), axis: (x: 0, y: 1, z: 0))
}
}
Animates a view from one position to another, even across different parent containers:
struct HeroAnimationView: View {
@Namespace private var namespace
@State private var isExpanded = false
var body: some View {
if isExpanded {
// Detail view
VStack {
Image("hero")
.resizable()
.matchedGeometryEffect(id: "heroImage", in: namespace)
.frame(maxWidth: .infinity)
.frame(height: 300)
Text("Detail Content")
}
.onTapGesture { withAnimation { isExpanded = false } }
} else {
// Card view
HStack {
Image("hero")
.resizable()
.matchedGeometryEffect(id: "heroImage", in: namespace)
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 8))
Text("Tap to expand")
}
.onTapGesture { withAnimation(.spring()) { isExpanded = true } }
}
}
}
The @Namespace creates a shared coordinate space. Views with the same id and in: namespace will animate from one to the other. Both source and destination must exist in the same rendering pass — typically managed by if/else.
// PhaseAnimator — cycle through phases
PhaseAnimator([false, true]) { isHighlighted in
Circle()
.fill(isHighlighted ? .yellow : .orange)
.scaleEffect(isHighlighted ? 1.2 : 1.0)
} animation: { phase in
.spring(duration: 0.3)
}
// KeyframeAnimator — precise multi-step animation
KeyframeAnimator(initialValue: AnimationValues()) { values in
RocketView()
.rotationEffect(values.angle)
.offset(y: values.verticalOffset)
} keyframes: { _ in
KeyframeTrack(\.angle) {
LinearKeyframe(.degrees(0), duration: 0.1)
CubicKeyframe(.degrees(-15), duration: 0.2)
CubicKeyframe(.degrees(15), duration: 0.2)
LinearKeyframe(.degrees(0), duration: 0.1)
}
KeyframeTrack(\.verticalOffset) {
LinearKeyframe(0, duration: 0.1)
SpringKeyframe(-50, duration: 0.3)
LinearKeyframe(0, duration: 0.2)
}
}
// Tap
view.onTapGesture { print("tapped") }
view.onTapGesture(count: 2) { print("double tapped") }
// Long press
view.onLongPressGesture(minimumDuration: 0.5) { print("long pressed") }
// Gesture modifier (more control)
view.gesture(TapGesture().onEnded { print("tapped") })
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: 120)
.offset(offset)
.scaleEffect(isDragging ? 1.05 : 1.0)
.shadow(radius: isDragging ? 20 : 5)
.animation(.spring(), value: isDragging)
.gesture(
DragGesture()
.onChanged { value in
offset = value.translation
isDragging = true
}
.onEnded { value in
// Snap back if not thrown far enough
let speed = sqrt(pow(value.velocity.width, 2) +
pow(value.velocity.height, 2))
if speed < 500 {
withAnimation(.spring()) { offset = .zero }
}
isDragging = false
}
)
}
}
struct ZoomableImage: View {
@State private var scale: CGFloat = 1.0
@State private var rotation: Angle = .zero
@State private var baseScale: CGFloat = 1.0
var body: some View {
Image("photo")
.resizable()
.scaledToFit()
.scaleEffect(scale)
.rotationEffect(rotation)
.gesture(
MagnifyGesture()
.onChanged { value in
scale = baseScale * value.magnification
}
.onEnded { value in
baseScale = scale
}
)
.gesture(
RotateGesture()
.onChanged { value in
rotation = value.rotation
}
)
}
}
// Both gestures active at the same time
view.gesture(
SimultaneousGesture(
DragGesture(),
MagnifyGesture()
)
)
// Second gesture only begins after first completes
view.gesture(
SequenceGesture(
LongPressGesture(minimumDuration: 0.5),
DragGesture()
)
)
// Exclusive — first one to recognize wins
view.gesture(
ExclusiveGesture(
TapGesture(count: 2),
TapGesture(count: 1)
)
)
struct CardStyle: ViewModifier {
var isHighlighted: Bool
func body(content: Content) -> some View {
content
.padding()
.background(isHighlighted ? Color.yellow.opacity(0.3) : Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: isHighlighted ? 8 : 4, y: 2)
.animation(.easeInOut, value: isHighlighted)
}
}
// Convenience extension
extension View {
func cardStyle(highlighted: Bool = false) -> some View {
modifier(CardStyle(isHighlighted: highlighted))
}
}
// Usage
Text("Hello")
.cardStyle(highlighted: true)
Both work, but ViewModifier is preferable when:
animation(_:value:) applied to the modifier itself// ViewModifier with internal state
struct ShakeEffect: ViewModifier {
@State private var shakes = 0
func body(content: Content) -> some View {
content
.offset(x: CGFloat(shakes) % 2 == 0 ? -5 : 5)
.animation(.default, value: shakes)
.onAppear { shakes = 5 }
}
}
struct AdaptiveTextStyle: ViewModifier {
@Environment(\.colorScheme) var colorScheme
@Environment(\.dynamicTypeSize) var typeSize
func body(content: Content) -> some View {
content
.foregroundStyle(colorScheme == .dark ? .white : .black)
.font(typeSize >= .accessibility1 ? .title : .body)
}
}
@ViewBuilder is what makes SwiftUI's closure-based API work. It transforms a block of view declarations into a single View type using result builder magic.
// You've been using @ViewBuilder all along — this is what VStack does
struct VStack<Content: View>: View {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View { content }
}
You can use @ViewBuilder in your own views to accept generic view content:
struct Card<Content: View>: View {
let title: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
Divider()
content
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
// Usage
Card(title: "Profile") {
Text("Name: Simran")
Text("Role: iOS Developer")
HStack { /* ... */ }
}
Building reusable containers with generic content:
struct AsyncContentView<T, Content: View, Loading: View, Failure: View>: View {
let state: LoadingState<T>
@ViewBuilder let content: (T) -> Content
@ViewBuilder let loading: () -> Loading
@ViewBuilder let failure: (Error) -> Failure
var body: some View {
switch state {
case .idle, .loading:
loading()
case .loaded(let value):
content(value)
case .failed(let error):
failure(error)
}
}
}
// Usage
AsyncContentView(state: viewModel.state) { users in
UserList(users: users)
} loading: {
ProgressView("Loading...")
} failure: { error in
ErrorView(error: error)
}
@ViewBuilder supports if, if/else, and switch:
@ViewBuilder
var buttonContent: some View {
if isLoading {
ProgressView()
.tint(.white)
} else if isSuccess {
Image(systemName: "checkmark")
.foregroundStyle(.green)
} else {
Text("Submit")
}
}
Implement Shape by defining a path(in:) function:
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.closeSubpath()
return path
}
}
struct Wave: Shape {
var amplitude: Double = 20
var frequency: Double = 2
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: 0, y: rect.midY))
for x in stride(from: 0, to: rect.width, by: 1) {
let angle = (x / rect.width) * frequency * .pi * 2
let y = rect.midY + amplitude * sin(angle)
path.addLine(to: CGPoint(x: x, y: y))
}
return path
}
}
// Shapes are Animatable — animate their properties
struct AnimatedWave: View {
@State private var amplitude = 10.0
var body: some View {
Wave(amplitude: amplitude)
.stroke(.blue, lineWidth: 2)
.animation(.easeInOut(duration: 1).repeatForever(), value: amplitude)
.onAppear { amplitude = 40 }
}
}
To animate custom shape properties, conform to Animatable:
struct AnimatableWave: Shape {
var amplitude: Double
var animatableData: Double {
get { amplitude }
set { amplitude = newValue }
}
func path(in rect: CGRect) -> Path { /* ... */ }
}
Canvas gives you a direct drawing context — ideal for particle systems, charts, or high-performance graphics:
struct ConstellationView: View {
let stars: [Star]
var body: some View {
Canvas { context, size in
// Draw connection lines
for (i, star) in stars.enumerated() {
guard i + 1 < stars.count else { break }
let next = stars[i + 1]
var path = Path()
path.move(to: CGPoint(x: star.x * size.width, y: star.y * size.height))
path.addLine(to: CGPoint(x: next.x * size.width, y: next.y * size.height))
context.stroke(path, with: .color(.white.opacity(0.3)), lineWidth: 0.5)
}
// Draw stars
for star in stars {
let point = CGPoint(x: star.x * size.width, y: star.y * size.height)
let radius = star.magnitude * 3
let starRect = CGRect(
x: point.x - radius, y: point.y - radius,
width: radius * 2, height: radius * 2
)
context.fill(Path(ellipseIn: starRect), with: .color(.white))
}
}
.background(.black)
}
}
Canvas resolves all views to bitmaps at draw time, making it extremely fast for rendering many elements.
TimelineView redraws its content at specific intervals — perfect for clocks, animations, or live data:
struct ClockView: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 1.0)) { context in
AnalogClockFace(date: context.date)
}
}
}
// Combine with Canvas for animated particle systems
TimelineView(.animation) { context in
Canvas { drawContext, size in
let elapsed = context.date.timeIntervalSinceReferenceDate
for particle in particles {
let position = particle.position(at: elapsed)
// draw particle...
}
}
}
.task is SwiftUI's bridge to async code. It launches an async task when the view appears and cancels it when the view disappears:
struct UserListView: View {
@State private var users: [User] = []
@State private var isLoading = false
@State private var error: Error?
var body: some View {
Group {
if isLoading {
ProgressView()
} else if let error {
ErrorView(error: error)
} else {
List(users) { user in UserRow(user: user) }
}
}
.task {
await loadUsers()
}
}
private func loadUsers() async {
isLoading = true
defer { isLoading = false }
do {
users = try await UserService.fetchAll()
} catch {
self.error = error
}
}
}
When you pass an id parameter, the task is cancelled and restarted whenever id changes:
struct SearchView: View {
@State private var query = ""
@State private var results: [Item] = []
var body: some View {
VStack {
TextField("Search", text: $query)
List(results) { item in ItemRow(item: item) }
}
.task(id: query) {
// Cancelled and restarted every time query changes
guard !query.isEmpty else { results = []; return }
try? await Task.sleep(for: .milliseconds(300)) // debounce
guard !Task.isCancelled else { return }
results = try await SearchService.search(query: query)
}
}
}
AsyncImage(url: URL(string: "https://example.com/image.jpg")) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
Image(systemName: "photo")
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 8))
UI updates must happen on the main thread. With async/await:
// @MainActor ensures methods run on main thread
@MainActor
class ViewModel: ObservableObject {
@Published var data: [Item] = []
func load() async {
// This runs on main actor — safe to update @Published
let fetched = await fetchFromServer() // suspends, other work can happen
data = fetched // back on main — safe
}
}
// Or switch explicitly
func loadInBackground() async {
let data = await Task.detached(priority: .background) {
await expensiveComputation()
}.value
await MainActor.run {
self.data = data
}
}
struct TrackableView: View {
var body: some View {
Text("Hello")
.onAppear {
Analytics.track(.viewAppeared("TrackableView"))
}
.onDisappear {
Analytics.track(.viewDisappeared("TrackableView"))
}
}
}
onAppear fires when the view is inserted into the hierarchy. In a List, this means as the cell scrolls into view. .task is generally preferred over .onAppear for async work because it handles cancellation automatically.
struct FilteredList: View {
@State private var searchText = ""
@State private var filteredItems: [Item] = items
var body: some View {
List(filteredItems) { item in ItemRow(item: item) }
.searchable(text: $searchText)
.onChange(of: searchText) { _, newValue in
// iOS 17 syntax: oldValue, newValue
filteredItems = newValue.isEmpty
? items
: items.filter { $0.name.contains(newValue) }
}
}
}
In iOS 17+, onChange receives both old and new values. In iOS 16, it receives only the new value.
init() → View struct created (happens frequently — don't put logic here)
onAppear() → View inserted into hierarchy
task() → Async task launched
body → Re-evaluated when state/environment changes
onChange() → Specific value changed
onDisappear() → View removed from hierarchy
task cancelled → Async task cancelled when view disappears
struct DataDashboard: View {
@State private var users: [User] = []
@State private var posts: [Post] = []
var body: some View {
DashboardContent(users: users, posts: posts)
.task {
// Fetch both concurrently
async let fetchedUsers = UserService.fetchAll()
async let fetchedPosts = PostService.fetchRecent()
do {
(users, posts) = try await (fetchedUsers, fetchedPosts)
} catch {
// handle
}
}
}
}
iOS 16+ exposes Layout, letting you implement completely custom layout algorithms:
struct RadialLayout: Layout {
var radius: CGFloat = 100
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
// Return the size this layout needs
let diameter = radius * 2
return CGSize(width: diameter + 50, height: diameter + 50)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let count = subviews.count
let angleStep = (2 * Double.pi) / Double(count)
for (index, subview) in subviews.enumerated() {
let angle = angleStep * Double(index) - (.pi / 2)
let x = center.x + radius * cos(angle)
let y = center.y + radius * sin(angle)
let size = subview.sizeThatFits(.unspecified)
subview.place(
at: CGPoint(x: x - size.width / 2, y: y - size.height / 2),
proposal: .unspecified
)
}
}
}
// Usage
RadialLayout(radius: 120) {
ForEach(menuItems) { item in
MenuButton(item: item)
}
}
Custom layouts support AnyLayout for smooth animated transitions between layout types:
@State private var isCircular = false
var layout: AnyLayout {
isCircular ? AnyLayout(RadialLayout()) : AnyLayout(HStackLayout())
}
layout {
ForEach(items) { item in ItemView(item: item) }
}
Good accessibility is not optional — it's a mark of quality engineering. SwiftUI provides powerful built-in support.
Button(action: deleteItem) {
Image(systemName: "trash")
}
.accessibilityLabel("Delete item")
.accessibilityHint("Removes this item from your list")
.accessibilityIdentifier("deleteButton") // for UI testing
// Hide decorative elements from VoiceOver
Image("decorativeDivider")
.accessibilityHidden(true)
// Group related elements
VStack {
Text("3.5 stars")
StarRatingView(rating: 3.5)
}
.accessibilityElement(children: .combine) // read as one element
// Custom value for sliders/steppers
Slider(value: $volume, in: 0...1)
.accessibilityValue("\(Int(volume * 100)) percent")
MessageBubble(message: message)
.accessibilityAction(named: "Reply") {
replyToMessage(message)
}
.accessibilityAction(named: "Delete") {
deleteMessage(message)
}
.accessibilityAction(.magicTap) {
// Magic tap — two-finger double tap
togglePlayback()
}
Always support Dynamic Type:
// ✅ Uses dynamic scaling
Text("Hello").font(.body)
Text("Title").font(.headline)
// ✅ Custom font with dynamic scaling
Text("Custom")
.font(.custom("Georgia", size: 17, relativeTo: .body))
// ✅ Adaptive layout for large text
@Environment(\.dynamicTypeSize) var typeSize
HStack {
if typeSize.isAccessibilitySize {
VStack { content } // stack vertically for large text
} else {
HStack { content } // stack horizontally normally
}
}
@Environment(\.accessibilityReduceMotion) var reduceMotion
Button("Animate") {
if reduceMotion {
isExpanded.toggle() // instant change
} else {
withAnimation(.spring()) {
isExpanded.toggle() // animated change
}
}
}
SwiftUI re-renders a view whenever any of its dependencies change. Keep views small and focused:
// ❌ Bad: monolithic view re-renders on any property change
struct BadView: View {
@ObservedObject var viewModel: HeavyViewModel // many @Published properties
var body: some View {
// This re-renders when ANY viewModel property changes
VStack {
NameView(name: viewModel.name)
StatisticsView(stats: viewModel.stats)
ChartView(data: viewModel.chartData)
}
}
}
// ✅ Better: Extract subviews with only the data they need
struct BetterView: View {
@ObservedObject var viewModel: HeavyViewModel
var body: some View {
VStack {
NameView(name: viewModel.name) // only re-renders when name changes
StatisticsView(stats: viewModel.stats) // only when stats change
ChartView(data: viewModel.chartData) // only when chartData changes
}
}
}
With @Observable (iOS 17+), SwiftUI tracks per-property access, so this optimization is more automatic. With ObservableObject, extracting views is critical.
Mark views as Equatable to give SwiftUI explicit equality checking:
struct ExpensiveRowView: View, Equatable {
let item: Item
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.item.id == rhs.item.id &&
lhs.item.updatedAt == rhs.item.updatedAt
}
var body: some View { /* expensive computation */ }
}
// Wrap with .equatable() modifier
ExpensiveRowView(item: item)
.equatable()
MVVM is the conventional SwiftUI pattern:
// Model
struct User: Identifiable, Codable {
let id: UUID
var name: String
var email: String
}
// ViewModel
@Observable
class UserListViewModel {
var users: [User] = []
var isLoading = false
var error: Error?
private let service: UserService
init(service: UserService = .shared) {
self.service = service
}
func loadUsers() async {
isLoading = true
defer { isLoading = false }
do {
users = try await service.fetchAll()
} catch {
self.error = error
}
}
}
// View
struct UserListView: View {
@State private var viewModel = UserListViewModel()
var body: some View {
List(viewModel.users) { user in
UserRow(user: user)
}
.overlay {
if viewModel.isLoading { ProgressView() }
}
.task { await viewModel.loadUsers() }
}
}
// ❌ Side effects in body
var body: some View {
let _ = analytics.track("view rendered") // DON'T DO THIS
return Text("Hello")
}
// ✅ Side effects in lifecycle events
Text("Hello")
.onAppear { analytics.track("view appeared") }
// ❌ Creating objects in body
var body: some View {
let service = NetworkService() // recreated every render!
return Text(service.baseURL)
}
// ✅ Objects as properties or @State
@State private var service = NetworkService()
// ❌ Using AnyView unnecessarily
var content: AnyView {
AnyView(Text("Hello"))
}
// ✅ Use @ViewBuilder or opaque return type
@ViewBuilder var content: some View {
Text("Hello")
}
// ❌ Putting logic in view init
struct BadView: View {
let data: [Item]
let filtered: [Item] // computed in init
init(data: [Item], filter: String) {
self.data = data
self.filtered = data.filter { $0.name.contains(filter) } // runs on every render
}
}
// ✅ Use computed property or ViewModel
var filteredItems: [Item] {
data.filter { $0.name.contains(filter) }
}
Use SwiftUI's debugging tools:
// Print when body evaluates (debugging only)
var body: some View {
let _ = print("MyView rendered")
return Text("Hello")
}
// Self._printChanges() — prints which property caused a re-render
var body: some View {
let _ = Self._printChanges()
return Text("Hello")
}
In Instruments, use the SwiftUI template to see:
Here's a complete, intermediate-level feature demonstrating many of these concepts working together:
// MARK: - Model
struct Post: Identifiable, Codable, Hashable {
let id: UUID
var title: String
var content: String
var author: String
var likes: Int
var createdAt: Date
}
// MARK: - ViewModel
@Observable
class PostFeedViewModel {
var posts: [Post] = []
var isLoading = false
var error: Error?
var searchText = ""
var sortOrder: SortOrder = .newest
enum SortOrder: String, CaseIterable {
case newest = "Newest"
case mostLiked = "Most Liked"
}
var filteredPosts: [Post] {
let base = searchText.isEmpty ? posts : posts.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
$0.author.localizedCaseInsensitiveContains(searchText)
}
return base.sorted {
switch sortOrder {
case .newest: return $0.createdAt > $1.createdAt
case .mostLiked: return $0.likes > $1.likes
}
}
}
func load() async {
isLoading = true
defer { isLoading = false }
do {
posts = try await PostService.fetchFeed()
} catch {
self.error = error
}
}
func like(post: Post) {
guard let index = posts.firstIndex(where: { $0.id == post.id }) else { return }
posts[index].likes += 1
}
}
// MARK: - Views
struct PostFeedView: View {
@State private var viewModel = PostFeedViewModel()
@State private var selectedPost: Post?
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
Group {
if viewModel.isLoading && viewModel.posts.isEmpty {
ProgressView("Loading posts...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
postList
}
}
.navigationTitle("Feed")
.navigationDestination(for: Post.self) { post in
PostDetailView(post: post)
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Picker("Sort", selection: $viewModel.sortOrder) {
ForEach(PostFeedViewModel.SortOrder.allCases, id: \.self) { order in
Text(order.rawValue).tag(order)
}
}
} label: {
Image(systemName: "arrow.up.arrow.down")
}
}
}
}
.searchable(text: $viewModel.searchText, prompt: "Search posts")
.task { await viewModel.load() }
.refreshable { await viewModel.load() }
}
private var postList: some View {
List {
ForEach(viewModel.filteredPosts) { post in
PostCard(post: post) {
viewModel.like(post: post)
}
.onTapGesture {
path.append(post)
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
}
.listStyle(.plain)
.animation(.default, value: viewModel.filteredPosts.map(\.id))
}
}
struct PostCard: View {
let post: Post
let onLike: () -> Void
@State private var isLiked = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Circle()
.fill(.blue.gradient)
.frame(width: 36, height: 36)
.overlay(
Text(String(post.author.prefix(1)))
.foregroundStyle(.white)
.font(.headline)
)
VStack(alignment: .leading, spacing: 2) {
Text(post.author).font(.subheadline).bold()
Text(post.createdAt, style: .relative).font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
Text(post.title).font(.headline)
Text(post.content)
.font(.body)
.foregroundStyle(.secondary)
.lineLimit(3)
HStack {
Button {
withAnimation(.spring(response: 0.3)) {
isLiked.toggle()
onLike()
}
} label: {
Label("\(post.likes)", systemImage: isLiked ? "heart.fill" : "heart")
.foregroundStyle(isLiked ? .red : .secondary)
.symbolEffect(.bounce, value: isLiked)
}
.buttonStyle(.plain)
Spacer()
Text(post.content.split(separator: " ").count.formatted() + " words")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
}
}
After mastering these intermediate topics, your next steps:
Advanced SwiftUI: matchedGeometryEffect mastery, ScrollView with scrollPosition, scrollTargetBehavior, building custom UIViewRepresentable wrappers, Metal shaders in SwiftUI (iOS 17+).
Architecture at Scale: The Composable Architecture (TCA), Clean Architecture with SwiftUI, coordinator patterns with NavigationStack.
SwiftUI + UIKit Interop: UIViewRepresentable, UIViewControllerRepresentable, UIHostingController, mixing SwiftUI into legacy UIKit apps.
Testing: ViewInspector for unit-testing SwiftUI views, snapshot testing, async test patterns for ViewModels.
Widgets & Extensions: Building widgets with WidgetKit (a SwiftUI-only environment), App Clips, Live Activities.
Guide compiled for SwiftUI iOS 17 / Swift 5.9+. Most concepts apply to iOS 15+; iOS 16+ and iOS 17+ specific features are noted inline.