The tools around the code decide how fast you can work in it. This lesson is previews that render instantly against realistic data — including their failure states — plus the linting and formatting configuration worth having, and the instruments to reach for before guessing.
Previews are not a demo feature
A preview renders a view in Xcode's canvas without launching the app. Used properly it is your main feedback loop: change a padding value, see it immediately, on three device sizes at once.
Used carelessly it is a feature you stop using by week two, because the previews fetch from a backend that is not running and every canvas shows a spinner forever. The difference between those two outcomes is entirely about whether the app's dependencies can be swapped, which is why this lesson comes after the one about the composition root rather than near the beginning.
Make every dependency fake-able
This is the return on the composition root from lesson 14. One function hands the whole app a stubbed graph:
static func preview(
catalogue: CatalogRepository = PreviewCatalogRepository(),
orders: OrderRepository = PreviewOrderRepository(),
profile: ProfileRepository = PreviewProfileRepository()
) -> AppEnvironment {
Previews run in a host process with no backend, no Keychain entitlement and no view controller to present a payment sheet from — so every one of those is replaced, and a canvas renders instantly and deterministically.
#Preview("Loaded") {
let environment = AppEnvironment.preview()
return NavigationStack {
MenuView()
.environment(environment.menu)
.environment(environment.cart)
.environment(environment.toasts)
}
}
Wrapping in a NavigationStack matters more than it looks: without it the navigation
title and toolbar do not render, so the preview shows a screen missing its chrome and you end up
tuning spacing against the wrong layout.
The state nobody previews
#Preview("Failed") {
let environment = AppEnvironment.preview(
catalogue: PreviewCatalogRepository(error: APIError.network(message: "The backend is not running."))
)
The error branch is the one nobody looks at, because reproducing it means turning the backend off. Here it is one line, and it sits in the canvas beside the happy path — which is how the copy in it gets read at all, let alone written well.
Worth a preview each: loaded, empty, failed, and the long-content case. Those four cover most of the layout bugs that otherwise reach a customer.
Data that is realistic, not tidy
static let pepperoni = Product(
id: "22222222-2222-2222-2222-222222222222",
name: "Classic Pepperoni",
description: "Double pepperoni, mozzarella, our house tomato sauce.",
type: .pizza,
imageUrl: nil,
active: true,
displayOrder: 2,
Preview data that is too neat hides exactly the problems previews exist to catch — the two-line product name, the long topping list, the missing image, the price that is four digits. The fixture file is shared with the tests, which keeps one set of awkward-enough examples rather than two sets of convenient ones.
All of it lives under #if DEBUG, so none of it — including the sample email addresses
— is compiled into a release build.
Previews that need state
A component taking a @Binding has nowhere to bind to in a preview. On Xcode 16
@Previewable solves it inline; on 15 the portable answer is a small wrapper view:
private struct SavedAddressPickerPreview: View {
@State private var selectedID = SampleData.address.id
var body: some View {
SavedAddressPicker(
addresses: [SampleData.address],
selectedID: $selectedID,
newAddressID: CheckoutViewModel.newAddressID
)
It also exercises the component the way a screen actually uses it, which the inline version does not — you can tap between options in the canvas and watch the selection move.
When a preview will not build
Previews compile your whole module, so a preview failure is usually a compile error somewhere
else. The other frequent cause is a missing .environment line — reading a type nobody
injected is a crash, not a compile error, so the canvas dies with a diagnostic that points at the
framework rather than at the line you forgot.
Previewing the conditions you cannot reproduce
A preview can be configured with environment overrides, and the two worth using habitually are the ones that are awkward to set up on a device.
.dynamicTypeSize(.accessibility3) renders the screen at a large accessibility text
size, which is where truncation and overlap appear. .environment(\.layoutDirection, .rightToLeft)
mirrors the layout, which catches anything positioned with a hard-coded leading offset. Adding a
preview at the largest text size beside the default one is thirty seconds and catches a class of bug
that is otherwise reported from the field.
Naming previews matters too, because the canvas lists them by name.
#Preview("Failed") is findable; three unnamed previews are not.
Linting, kept small
A linter earns its place by catching what review keeps catching. A linter with two hundred rules earns resentment: people learn to silence it, and then it catches nothing.
disabled_rules:
# Three and four line closures are the shape of SwiftUI. This rule fires on almost every view.
- multiple_closures_with_trailing_closure
# `content()` in a @ViewBuilder property is not "unused" — the compiler disagrees with the rule.
- unused_closure_parameter
Both of those fire constantly on idiomatic SwiftUI, and a rule that is wrong most of the time trains people to ignore the output.
The one worth turning up:
force_unwrapping:
severity: error
Force-unwrapping is the single most common cause of crashes in iOS apps. Making it an error in app code and allowing it in tests — where a crash is a clear test failure — is the configuration that matches how it is actually used.
And the ones worth turning on, because they catch real mistakes rather than style:
opt_in_rules:
- array_init
- closure_spacing
- contains_over_filter_count
- contains_over_first_not_nil
- empty_count
- empty_string
- explicit_init
- first_where
- force_unwrapping
- implicitly_unwrapped_optional
- last_where
Half of those are performance: first_where catches filter { }.first,
which builds a whole array to take one element, and contains_over_filter_count catches
the same mistake spelled differently. implicitly_unwrapped_optional is the important
one after force-unwrapping — an Int! is a crash waiting for the right ordering.
Line length is the other setting worth tuning rather than accepting. Under
line_length::
warning: 110
error: 140
ignores_comments: true
ignores_urls: true
ignores_comments is the important half. Wrapping prose to fit a code limit makes it
harder to read, and a linter that fights your explanations is a linter that discourages writing
them.
Size limits catch the thing size limits are for
type_body_length:
warning: 320
error: 450
file_length:
warning: 500
error: 700
ignore_comment_only_lines: true
ignore_comment_only_lines is the setting that makes these usable in a codebase that
explains itself. Without it, a well-documented file trips the limit for the wrong reason, and the
pressure it creates is to delete the comments.
Note that function_body_length is disabled entirely. A SwiftUI body is
legitimately long and legitimately nested, and the rule fires on almost every view — the
type length limit is the one that catches a view that has genuinely grown too big, which is
the real signal.
Formatting is not worth arguing about
So do not argue about it — commit a configuration and let a tool apply it.
--indent 4
--maxwidth 110
--wraparguments before-first
--wrapparameters before-first
--wrapcollections preserve
--closingparen balanced
--commas always
--trimwhitespace always
--stripunusedargs closure-only
--self remove
Two rules in that file are disabled with reasons, which is the part worth copying. redundantType
would rewrite let x: ViewState<Catalogue> = .idle into a form where the type is
implicit, and here the explicit type is the documentation.
The debugger is a tool, not a fallback
Three habits that save more time than they cost.
Breakpoint actions. A breakpoint can log an expression and continue automatically, which is a print statement you did not have to write, recompile for, or remember to remove.
The view hierarchy inspector. The button in the debug bar freezes the running UI
into a 3D exploded view, which is the fastest way to answer "why is this view 400 points tall" — the
answer is usually a frame or a Spacer two levels up from where you were
looking.
Self._printChanges() in a body prints which property
caused that redraw. It is the single most useful line for understanding why a view is updating more
than you expected, and it goes straight back out again once you know.
And prefer Logger over print for anything you would want from a customer's
device. It goes to the unified log, viewable in Console.app, and it redacts interpolated values by
default — which is what stops an email address ending up in a diagnostic file somebody emails around.
Instruments, before guessing
Three templates cover almost everything.
Time Profiler samples the stack. The usual surprise is not that something is slow
but that it runs far more often than expected. Allocations and
Leaks find retain cycles, which in SwiftUI almost always means a closure capturing
self strongly. And the SwiftUI template shows which view bodies are
evaluating and how often — the fastest way to find a view that redraws on every keystroke.
One rule before any of it: profile a Release build on a real device. Debug builds are unoptimised and SwiftUI in particular is much slower under them, so a great many "performance problems" turn out to be the debugger.
Keeping the project honest
./Scripts/generate-project.sh
Where the project file is generated from a manifest, the two can drift — someone changes a build
setting in Xcode's editor, commits the .pbxproj, and the next regeneration silently
discards it. The fix is a CI step that regenerates and fails if the result differs from what was
committed, which lesson 21 sets up.
One more thing worth automating
A pre-commit hook that runs the formatter is the difference between a diff that shows what changed and one that shows what changed plus forty lines of re-indentation. It is three lines of shell, and it removes an entire category of review comment.
What to set up on day one
In order of return, for a project that does not have any of this yet:
- Previews with stub data, including a failure preview. This is the one that changes how the day feels.
- SwiftFormat on a pre-commit hook, so formatting never appears in a diff.
- SwiftLint with a small rule set, treating force-unwrapping as an error.
- The project generated from a manifest, once more than one person is adding files.
All four are an afternoon together, and each one keeps paying every day afterwards. The order matters because the first is the only one you notice immediately — the others are things you stop having to think about, which is a harder benefit to feel and a larger one.
Next
The last practical lesson: build configuration, signing, CI, and getting an app onto the store.