SwiftUI Essentials
SwiftUI Essentials View Protocol & Composition Every SwiftUI screen is built from types conforming to the View protocol, whose single requirement is a computed …
SwiftUI Essentials
View Protocol & Composition
Every SwiftUI screen is built from types conforming to the View protocol, whose single requirement is a computed body property describing what to render. Views are structs, not classes — they are cheap, immutable value types that SwiftUI recreates and diffs on every state change rather than mutating in place, which is why view initializers should stay side-effect free.
Composition is the core building block: instead of one large view, you break UI into small, focused views and combine them. SwiftUI recomputes only the parts of the view tree affected by a state change, so smaller subviews also help performance by narrowing what has to re-render.
struct ProfileScreen: View {
let user: User
var body: some View {
VStack(alignment: .leading, spacing: 12) {
AvatarView(imageURL: user.avatarURL)
NameAndTitle(name: user.name, title: user.title)
StatsRow(followers: user.followers, following: user.following)
}
.padding()
}
}
struct AvatarView: View {
let imageURL: URL?
var body: some View {
AsyncImage(url: imageURL) { image in
image.resizable().scaledToFill()
} placeholder: {
Circle().fill(.gray.opacity(0.2))
}
.frame(width: 64, height: 64)
.clipShape(Circle())
}
}
struct StatsRow: View {
let followers: Int
let following: Int
var body: some View {
HStack(spacing: 24) {
Label("\(followers) followers", systemImage: "person.2")
Label("\(following) following", systemImage: "person.crop.circle")
}
.font(.footnote)
.foregroundStyle(.secondary)
}
}State Management
@State owns a small piece of local, view-private state and drives re-rendering when it changes — use it for things like a toggle or text field value that belongs to a single view. @Binding is not storage itself; it is a two-way reference to state owned somewhere else, letting a child view read and write a parent's @State without owning a copy.
For reference-type view models, @StateObject creates and owns an ObservableObject for the lifetime of the view (use it where the object is created), while @ObservedObject references one passed in from outside without owning its lifecycle — using @ObservedObject where @StateObject was needed can cause the object to be recreated unexpectedly on every parent re-render. @EnvironmentObject injects a shared object down an entire view subtree without threading it through every initializer, at the cost of a runtime crash if no ancestor actually provided one.
final class SessionViewModel: ObservableObject {
@Published var isLoggedIn = false
@Published var username = ""
func logIn(username: String) {
self.username = username
isLoggedIn = true
}
}
struct RootView: View {
@StateObject private var session = SessionViewModel()
var body: some View {
ContentView()
.environmentObject(session)
}
}
struct ContentView: View {
@EnvironmentObject var session: SessionViewModel
@State private var searchText = ""
var body: some View {
if session.isLoggedIn {
SearchField(text: $searchText)
} else {
LoginView()
}
}
}
struct SearchField: View {
@Binding var text: String
var body: some View {
TextField("Search", text: $text)
.textFieldStyle(.roundedBorder)
}
}Layout System
VStack, HStack, and ZStack are the primary layout containers — vertical, horizontal, and depth (overlapping) stacking respectively. Layout in SwiftUI flows top-down: a parent proposes a size to its children, each child chooses its own size within that proposal, and the parent then positions its children — modifiers like .frame() and .padding() participate in this negotiation rather than setting absolute pixel positions.
The order modifiers are applied in matters, because each one wraps the view in a new view with its own layout behavior. .padding().background(.blue) pads first, then draws the background around the padded area; .background(.blue).padding() draws the background first at the original size, then pads outside it — visually different results from the same two modifiers in a different order. GeometryReader gives access to the exact size and coordinate space a view was given, useful for proportional or coordinate-based layouts, but it always fills the space it's offered, which can surprise you if used carelessly inside a stack.
struct DashboardCard: View {
var body: some View {
ZStack(alignment: .bottomTrailing) {
RoundedRectangle(cornerRadius: 16)
.fill(.blue.gradient)
GeometryReader { proxy in
VStack(alignment: .leading, spacing: 8) {
Text("Revenue")
.font(.headline)
Text("$42,300")
.font(.system(size: proxy.size.width * 0.12, weight: .bold))
}
.padding()
.foregroundStyle(.white)
}
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.title)
.padding()
.foregroundStyle(.white.opacity(0.6))
}
.frame(height: 160)
.padding(.horizontal)
}
}Navigation
NavigationStack (replacing the older NavigationView) manages a stack of views tied to a path — either an implicit stack built from NavigationLink pushes, or an explicit, bindable array of values you control programmatically. Driving the path with your own @State array of typed route values, rather than only relying on NavigationLink destinations, is what makes deep linking and programmatic 'pop to root' or 'push three screens at once' straightforward.
enum Route: Hashable {
case productDetail(id: String)
case checkout
}
struct StoreView: View {
@State private var path: [Route] = []
var body: some View {
NavigationStack(path: $path) {
List(products) { product in
NavigationLink(product.name, value: Route.productDetail(id: product.id))
}
.navigationDestination(for: Route.self) { route in
switch route {
case .productDetail(let id):
ProductDetailView(id: id) {
path.append(.checkout)
}
case .checkout:
CheckoutView(onComplete: { path.removeAll() })
}
}
.navigationTitle("Store")
}
}
}Animations
Implicit animation via .animation(_:value:) tells SwiftUI to animate any visual change caused by that one value, while withAnimation wraps a state mutation so every resulting visual change across the affected views animates together — the more common choice once more than a single property is involved. Views entering or leaving the hierarchy (e.g. behind an if) use .transition() to describe how, but a transition only takes effect when the change itself happens inside a withAnimation block or an implicitly animated context.
struct LikeButton: View {
@State private var isLiked = false
var body: some View {
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) {
isLiked.toggle()
}
} label: {
Image(systemName: isLiked ? "heart.fill" : "heart")
.foregroundStyle(isLiked ? .red : .gray)
.scaleEffect(isLiked ? 1.2 : 1.0)
}
}
}
struct ToastBanner: View {
let message: String?
var body: some View {
VStack {
if let message {
Text(message)
.padding()
.background(.black.opacity(0.85), in: Capsule())
.foregroundStyle(.white)
.transition(.move(edge: .top).combined(with: .opacity))
}
Spacer()
}
.animation(.easeOut(duration: 0.25), value: message)
}
}Common Pitfalls
Using @ObservedObject for a view model the view itself creates — that recreates the object (losing its state) on every parent re-render; use @StateObject at the point of creation instead.
Reaching for @EnvironmentObject as a default instead of explicit parameters or @Binding — it hides a view's real dependencies and crashes at runtime with 'No ObservableObject found' if an ancestor forgets to provide it.
Forgetting that modifier order changes behavior — .padding().background(...) and .background(...).padding() produce visually different results, since each modifier wraps the view in a new layout node.
Wrapping a GeometryReader around content that does not need it — GeometryReader always expands to fill the space offered to it, which can silently break the sizing of a stack it's placed inside.
Mutating @Published properties off the main thread — UI-driving state should be updated on the main actor, or SwiftUI's rendering can behave unpredictably.
Applying .transition() to a view without wrapping the state change that adds/removes it in withAnimation (or an implicit .animation modifier) — the transition is defined but never actually animates.