SwiftUI is Apple's declarative UI framework: you describe what the screen looks like for the current state, and the framework works out which changes to apply. UIKit is imperative; you create views and then mutate them yourself.
Advantages
Less code, less drift: the UI is a function of state, so there is no reloadData() or manual label update to forget.
Live previews: #Preview renders a view in Xcode without launching the app.
One framework for all Apple platforms: iOS, iPadOS, macOS, watchOS, tvOS and visionOS; widgets and Live Activities are SwiftUI-only.
Composition: small views and modifiers combine instead of subclassing.
Drawbacks
Less control: precise scroll handling, complex text input and some collection layouts are still easier in UIKit, which you embed through UIViewRepresentable.
Features follow the OS version: many APIs need iOS 17 or 18, so a low deployment target means if #available branches.
Harder debugging: when a view redraws too often or loses state, the cause hides in the framework's identity and dependency tracking.
In 2026 most new screens are written in SwiftUI, while UIKit remains for fine-grained control and in older code; the two mix freely in one app.
iOSJunior
SwiftUI Fundamentals
View protocol, modifiers, layout containers, navigation, lists
Questions closed
0 / 25
- 0Review
- 6Asked often
Questions
Declarative means you describe the result (what the screen looks like for a given state) and the framework performs the updates. A SwiftUI view is a cheap struct that describes a piece of UI; it is not the object drawn on screen.
A view is a description: SwiftUI creates and discards view structs constantly; the real rendering objects and the @State storage live inside the framework and survive across those copies.
Structs are cheap: a struct of plain values is stored inline, with no heap allocation and no reference counting for itself, and comparing the old and new value tells SwiftUI what changed.
No shared mutable state: a value cannot be changed behind your back by another owner, so the only way to change the UI is to change state.
No inheritance baggage: a UIView carries many stored properties from its superclasses; a SwiftUI view holds only what you declare and is reused through composition.
The practical consequence: a view's init is not a lifecycle event, and data that must survive belongs in @State or a model, not in a plain stored property.
View is the protocol every SwiftUI view conforms to, and its one requirement is a computed body that returns other views. some View is an opaque return type: body returns one concrete type that the compiler knows, but you never have to spell it out.
body is implicitly @ViewBuilder, so it can list several views and use if or switch; the builder folds them into one type.
The opaque type keeps that concrete type visible to SwiftUI, which uses it to tell which parts of the tree stayed the same between updates.
any View cannot be a body: an existential does not itself conform to View, and it would hide the structure SwiftUI diffs.
body must be fast and free of side effects: SwiftUI calls it whenever a dependency changes, possibly many times a second.
Leaf views such as Text and Image have Never as their Body; the framework draws them itself.
@main marks the app's entry point, the App protocol describes the app as a whole, and its body returns one or more scenes: containers such as WindowGroup that the system turns into windows.
**App**: a struct like a view, with body: some Scene. App-wide state lives here in @State and is handed down with .environment(...).
**WindowGroup**: the standard scene; on iPad and Mac the user can open several windows from it, each with its own view state. Others include DocumentGroup, Settings and MenuBarExtra on macOS and ImmersiveSpace on visionOS.
**@Environment(\.scenePhase)**: reports .active, .inactive or .background and replaces most app-delegate lifecycle callbacks.
**@UIApplicationDelegateAdaptor**: still plugs in an AppDelegate for push registration or SDKs that require one.
VStack places children top to bottom, HStack from leading to trailing, and ZStack layers them back to front. LazyVStack and LazyHStack lay out the same way but create children only as they come near the screen, which matters for long content in a ScrollView.
**alignment and spacing**: a VStack aligns children horizontally (.leading, .center, .trailing), an HStack vertically (.top, .firstTextBaseline); a ZStack takes a two-axis Alignment such as .topTrailing.
**Spacer(): a flexible view that takes the free space along the stack's axis and pushes siblings apart.
Eager vs lazy**: a plain stack builds and measures every child up front, so a VStack of 5,000 rows creates 5,000 views before the first frame. A lazy stack builds only what is near the visible area.
Lazy stacks do not scroll by themselves: put them inside a ScrollView.
One layer over another: for a badge or a backdrop, .overlay and .background are often better than a ZStack, because they take the size of the main view.
Badge A is a red box with the text inside default padding, B is a tight red strip with invisible padding around it, and C is red only behind the text inside a wider, empty frame. Each modifier wraps the view it is called on, so it only affects what came before it in the chain.
A: .padding() produces a bigger view (text plus padding), and .background fills that bigger view.
B: .background fills the bare text; the padding is added outside, where nothing is drawn.
C: .frame(width: 200) creates a 200-point container around a view that is already red, so the colour stays text-sized. Put .background after .frame to colour the full width.
Read a chain top-down as "take this view, then wrap it in…". The same rule explains why .clipShape before .shadow works, while .clipShape after .shadow cuts the shadow off.
Use a ViewModifier to bundle modifiers you apply to any view, such as a card look, and a ButtonStyle to style buttons, because it also receives the pressed state and the button's role.
**ViewModifier**: implement body(content:) and wrap it in a View extension so call sites read .cardStyle(). A modifier can hold its own @State and read @Environment.
Plain extension: if you only chain existing modifiers and need no state, extension View { func cardStyle() -> some View } is enough.
**ButtonStyle**: implement makeBody(configuration:); configuration.label is the button's content and configuration.isPressed gives a pressed state without gesture code. Tap handling, accessibility traits and hit testing stay the system's.
Applied through the environment: .buttonStyle(.primary) on a container styles every button inside it. The same pattern exists as ToggleStyle, LabelStyle, TextFieldStyle and ProgressViewStyle.
.frame centres its child by default, so you need .frame(maxWidth: .infinity, alignment: .leading). A stack's alignment lines children up with each other, a frame's alignment places the child inside the frame, and multilineTextAlignment aligns the lines inside one Text.
Stack alignment: VStack(alignment: .leading) only matters for children narrower than the stack; the stack is as wide as its widest child, so on its own it does not reach the screen edge.
Frame alignment: .frame(maxWidth: .infinity, alignment: .leading) takes all the offered width and pins the content to the leading edge. The default .center is why stretched views stay in the middle.
Text alignment: .multilineTextAlignment(.center) changes nothing until the text wraps onto several lines.
Two axes at once: ZStack, .overlay and .background take a full Alignment such as .bottomTrailing.
Leading, not left: .leading and .trailing flip automatically in right-to-left languages.
If something is off by exactly half the free space, a default .center is at work.
Image keeps its natural size until you call .resizable(), and .scaledToFill() draws outside its frame unless you clip it. AsyncImage has no in-memory cache of decoded images, so a row that scrolls back into a list asks for the image again and decodes it again.
Modifier order: .resizable() first, then .scaledToFit() or .scaledToFill(), then .frame, then .clipped() or .clipShape(...).
**.scaledToFill() overflows: layout still reports the frame size, but the pixels paint over the neighbours until you clip.
SF Symbols behave like text**: size Image(systemName:) with .font or .imageScale and colour it with .foregroundStyle; .resizable() throws away its text-aligned sizing.
**AsyncImage**: gives a placeholder and a phase with a failure case, but relies only on URLCache and has no retry. A scrolling feed needs a loader with a memory cache, your own or a library such as Nuke or Kingfisher; downsample big photos to display size with UIImage.preparingThumbnail(of:) first.
List is a scrolling, platform-styled container, and ForEach creates one row per element of a collection. SwiftUI matches rows between updates by their id, so the id must come from the data and never change, or rows lose their state and animate wrongly.
**Identifiable**: give the model an id (a database id or a UUID stored on the model) and write ForEach(items) { ... }; otherwise pass a key path, ForEach(items, id: \.code).
Bad ids: array indices shift on insert and delete, so state jumps to the wrong row and an index can go out of range; id: \.self on values with duplicates gives two rows one identity; a UUID() created inside body makes every row new on every update.
Editable rows: ForEach($items) { $item in ... } hands each row a Binding to its element.
List features: Section for headers, .onDelete and .swipeActions for row actions, .listStyle(.insetGrouped), and .searchable and .refreshable on the list.
Wrap the controls in a Form, which gives them the grouped settings look, and bind each control to state with $, as in TextField("Email", text: $email). The control reads and writes that state directly, so there is no delegate or target-action to wire up.
**TextField and SecureField**: tune input with .keyboardType(.emailAddress), .textContentType(.emailAddress) (enables AutoFill), .textInputAutocapitalization(.never) and .autocorrectionDisabled(). TextField(_:text:axis: .vertical) grows to several lines.
**Toggle, Picker, Stepper, DatePicker**: each takes a binding; a Picker matches rows to values through .tag or the id of the ForEach it contains.
**Section: groups fields under a header and a footer, a good place for a validation hint.
Validation**: compute validity from state and put .disabled(!isValid) on the submit button instead of checking on tap.
Keyboard flow: .submitLabel(.next) and .onSubmit handle the Return key; moving focus between fields uses @FocusState.
Presentation is a modifier bound to state: set a Bool or an optional value and SwiftUI shows the sheet or alert; reset it and the screen goes away. Use .sheet(item:) whenever the sheet shows a specific value, such as the tapped row.
.sheet(isPresented:) suits a screen that needs no input, like Settings.
.sheet(item:) takes an optional Identifiable value and passes it into the content closure, so the sheet can never show an old or nil selection (the classic bug of a Bool sheet plus a separate selected variable).
The same pair exists for .fullScreenCover and .popover; .alert and .confirmationDialog take a presenting: value that flows into their buttons. Give an alert an explicit .cancel button; .confirmationDialog adds one itself.
The presented view closes itself with @Environment(\.dismiss), without knowing which binding showed it.
.presentationDetents([.medium, .large]) gives a half-height sheet, and .interactiveDismissDisabled() blocks the swipe-down while a form has unsaved edits.
Attach presentation modifiers to a view that stays on screen, not to a row inside a ForEach.
LazyVGrid flows a collection into columns and builds cells lazily, which suits a gallery; Grid is an eager table whose rows share column widths, which suits a form or comparison table. .adaptive(minimum:) fits as many columns as the width allows.
**GridItem sizes**: .fixed(100), .flexible(minimum:maximum:) or .adaptive(minimum:). An array of three .flexible() items gives exactly three columns.
Exact counts: for a fixed 2 or 4, switch on @Environment(\.horizontalSizeClass): Array(repeating: GridItem(.flexible()), count: sizeClass == .compact ? 2 : 4); size class is not the device (iPad Split View is compact).
Laziness: LazyVGrid and LazyHGrid go inside a ScrollView; each child is one cell.
**Grid**: each GridRow is a row, cells in one column share a width, .gridCellColumns(2) spans two columns, and a plain view between rows such as Divider() spans the full width.
The adaptive trap: columns are fitted by minimum, so a minimum a few points too large silently drops three columns to two on the device you did not test.
Use the #Preview macro (one named preview per state that can break the layout) and feed it sample data and fake services, so the preview never touches the network or a real database.
One preview per risky state: loading, empty, error, many items, the longest realistic string.
Check dark mode and large text with .preferredColorScheme(.dark) and .environment(\.dynamicTypeSize, .accessibility3).
Keep sample data next to the model (User.preview) instead of a new fixture in every preview.
Inject a fake service, for an @Observable dependency with .environment(MockService()).
@Previewable @State (Xcode 16) gives a preview a live binding without a wrapper view.
PreviewProvider is the older form you will still meet in existing code.
A view that cannot be previewed without a network call or a logged-in user knows too much; move the dependency behind a protocol.
Use List for long, uniform data: it reuses rows and brings separators, swipe actions, selection and edit mode for free. Use ScrollView with LazyVStack when the design needs a custom layout that list styling would fight.
Memory: List is backed by a collection view and reuses rows, so memory stays flat. A LazyVStack creates rows lazily but keeps the views it has built, with their state, so memory grows with how far the user has scrolled.
**Free in List**: swipe actions, .onDelete and .onMove, selection, edit mode, sticky section headers and keyboard navigation.
**Free in ScrollView**: any row design, no separators or insets to strip, mixed content, horizontal carousels, paging with .scrollTargetBehavior(.paging) and pinned headers through LazyVStack(pinnedViews:).
Rule of thumb: thousands of similar rows go in a List; a designed screen with a bounded number of cards goes in a ScrollView.
Layout is a negotiation: the parent proposes a size, the child picks its own size, and the parent then places the child. The child has the final say, which explains most layout surprises.
**.frame does not resize the child**: it wraps the child in a new view of that size, proposes the size and centres whatever comes back. A Text inside .frame(width: 100) wraps into 100 points; the frame itself never stretches the text.
Views take different amounts of space: Text takes what it needs and wraps or truncates; Color, Spacer, shapes and .frame(maxWidth: .infinity) take everything offered.
Stacks share space by flexibility: the least flexible children are sized first and the flexible ones split what remains; layoutPriority(1) lets one child claim its space first.
**fixedSize()**: asks the child for its ideal size and ignores the offer, the fix for a Text that truncates when it should wrap.
**GeometryReader is greedy**: it accepts the whole proposal, so dropping one into a stack pushes its siblings around.
NavigationStack keeps its screens as a path of values: NavigationLink(value:) appends a value, .navigationDestination(for:) turns a value of that type into a screen, and Back removes the last one. Bind the path to state and you navigate by editing an array.
Push and pop in code: path.append(.settings) pushes, path.removeLast() pops, path.removeAll() returns to the root.
Typed or erased path: [Route] with a Hashable enum when all routes share one type, NavigationPath when screens push unrelated types.
Deep links: parse the URL in .onOpenURL into [Route] and assign it; the whole stack appears at once, with no chain of pushes.
Where destinations go: on the stack's content, outside lazy containers such as List rows; a link that finds no destination does nothing.
**NavigationView is deprecated** (since iOS 16): its NavigationLink(destination:) built destinations eagerly and had no dependable programmatic control. For sidebar-detail layouts on iPad and Mac use NavigationSplitView.
Restoration: NavigationPath.codable lets you save and restore the stack.
The .navigationDestination sits inside the List rows. List builds rows lazily, so the stack cannot rely on seeing the destination when the link fires, and the link may do nothing; move the modifier onto the List itself.
Why laziness matters: a row that is not built yet, or has been reused, has not registered its destination. SwiftUI requires destinations to be declared outside lazy containers such as List, LazyVStack and LazyVGrid.
Duplicates: even when it seems to work, every row registers the same Product.self destination; SwiftUI warns about it and uses only the declaration closest to the root.
The fix: one .navigationDestination(for: Product.self) on the List (or on any non-lazy view inside the NavigationStack), next to .navigationTitle.
Also check: Product must be Hashable, and the link must be inside a NavigationStack; a value-based link outside one is inert.
In a plain run, on first appearance it prints init, body, appear; after every tap, only init. The parent's body re-runs and recreates the Child struct, but the new value equals the old one, so SwiftUI skips Child.body, and the child never left the screen, so onAppear does not fire again.
**init is not a lifecycle event**: it runs every time a parent's body builds the view, possibly many times while it stays on screen. Keep it trivial: no network calls and no model creation outside @State.
**body is skipped when inputs are equal**: SwiftUI compares the new view's stored properties and the state it reads with the previous ones. Self._printChanges() inside body logs what triggered a re-run.
**onAppear and onDisappear: fire when the view enters and leaves the visible hierarchy: push and pop, tab switches, rows scrolling in a lazy list. They can fire many times during the view's life.
Async work** belongs in .task, which is cancelled when the view disappears (see the state topic).
The Task started in .onAppear is not tied to the view, so nothing ever cancels it: each return to the screen starts one more endless loop, and all of them keep adding to seconds, even while another screen covers this one.
Why: .onAppear fires again every time the view comes back, for example when a pushed screen is popped, and an unstructured Task { } lives until its closure returns. A while true loop never returns.
The fix: .task starts the work when the view appears and cancels it when the view disappears, so exactly one loop runs while the stopwatch is on screen.
Let cancellation end the loop: keep try await inside do/catch: with try? the CancellationError from Task.sleep is swallowed and a cancelled loop would spin without waiting.
Real elapsed time: with .task the count pauses while the screen is covered. If it must keep counting, store a start Date and show it with Text(start, style: .timer) or a TimelineView.
How to catch it: log inside the loop, then push and pop a screen a few times: more than one line per second means more than one loop.
@ViewBuilder is a result builder that folds a list of statements into one nested generic view type, which is how body can hold several views and an if without you writing that type out. AnyView erases that type, so SwiftUI can no longer compare the old and new trees structurally.
What the builder produces: siblings become a TupleView, an if/else becomes _ConditionalContent<A, B>, and a bare if becomes an optional view.
Type is identity: the two branches of an if/else are different types, so switching branches destroys one subtree and creates the other, resetting any @State inside it.
Builder limits: it understands only view expressions, so a for loop has to become ForEach. Mark your own computed properties, functions and container init parameters @ViewBuilder to get the same syntax.
**The cost of AnyView: when the wrapped type changes, SwiftUI cannot match the subtrees, so it tears them down and rebuilds them, losing state and doing extra work. It also hides the structure from the framework's diffing.
Alternatives**: a @ViewBuilder helper, a Group, or a generic Content: View parameter. Legitimate AnyView uses are narrow: a heterogeneous collection of views or breaking a recursive type.
withAnimation animates every change caused by the state mutation inside its closure, wherever the affected views sit; the .animation(_:value:) modifier animates one view's reaction to one named value.
**withAnimation(.snappy) { isExpanded.toggle() }**: attaches an animation to the transaction, so every view that depends on isExpanded animates.
**.animation(.smooth, value: count)**: local: this view animates, and only when count changes.
**Why .animation(_:) was deprecated**: with no value it animated the view's response to any change, including ones you never intended, which produced drifting layouts that were hard to trace.
Transitions: insertion and removal use .transition(...), which only runs when the change that inserts or removes the view is animated.
Modern presets are springs: .smooth, .snappy and .bouncy (iOS 17) replace hand-tuned durations; .contentTransition(.numericText()) rolls counters, and .transaction { $0.animation = nil } opts a subtree out.
Inside a List row, buttons with the default style share the whole row as their tap target, so one tap triggers every button in the row. Give each button .buttonStyle(.borderless) or .plain so it owns only its own label's area.
Why: a list row is a cell, and the row itself is the tap target; .automatic buttons inherit that region instead of their label's frame. The fix looks like styling but is really hit testing.
Better layout: mixing a NavigationLink row with in-row buttons is confusing anyway. Put secondary and destructive actions in .swipeActions or a .contextMenu, and keep the row's tap for navigation; if a row needs one inline button, use .borderless as below.
The opposite bug: outside a list, empty space such as a Spacer takes no taps until you add .contentShape(.rect).
If one tap triggers two things, look for a container that is itself a tap target.
Pop-to-root is one data change (clearing the array that drives the NavigationStack), so the deep screen needs access to that path, because @Environment(\.dismiss) only ever undoes one level. In a sheet the flow gets its own NavigationStack and its own router.
**dismiss() depends on context: at the root of a sheet it closes the sheet, in a pushed view it pops one screen. It cannot pop several levels.
Share the path**: an @Observable router in the environment holding var path: [Route] lets any descendant call router.path.removeAll(); with NavigationPath the equivalent is path.removeLast(path.count).
One assignment, no timing bugs: "save, go back to root and open the new item" is router.path = [.item(newID)], with no chain of pops to animate.
A sheet has its own stack and router: create a second Router in the sheet content and inject it with .environment(sheetRouter); otherwise the inherited outer router changes the wrong path. Ending the flow is sheetRouter.path.removeAll() to return to the sheet root, or editing = nil to close the sheet.
Keep the two kinds of state apart: .sheet(item:) for the modal, a separate path for pushes inside it, and .interactiveDismissDisabled(isDirty) while a form has unsaved edits.
updateUIView is empty, so the new url never reaches the WKWebView. makeUIView runs once per view identity; when the parent passes another URL, SwiftUI keeps the same UIKit view and only calls updateUIView, which does nothing.
The contract: makeUIView creates the UIKit view once; updateUIView runs right after it and again whenever the inputs or the environment change, and its job is to copy the current SwiftUI state into the view; the Coordinator is a long-lived object made once by makeCoordinator that holds state surviving between updates.
Do not reload on every update: updateUIView also runs for unrelated changes, such as a new size or colour scheme. Loading unconditionally would reload the page and lose the scroll position, so the fix keeps the last loaded URL in the Coordinator and loads only when it differs.
**Why not compare with webView.url**: it changes on redirects and when the user follows a link, so the comparison would yank the user back to url.
**The .id alternative**: WebView(url: article.url).id(article.id) gives each article a new identity, so SwiftUI destroys the view and calls makeUIView again. It is simpler, but it throws away the web view and its history on every switch.
How to catch it: in review, treat any input that only makeUIView reads as a bug; a preview that changes the input after the first render shows it at once.