Introduction
Canvas previews are an in-your-face feature of SwiftUI. When you create a new view, half of the boilerplate code is for the preview. A third of your Xcode real estate is taken up by the preview.
Despite the prominence of the feature, many developers simply delete the preview code from their views and rely on the simulator.
In past releases of Xcode (including the Xcode 13 betas), a reluctance to use previews was understandable. They’d fail for no apparent reason, and the error messages were beyond cryptic.
I’ve stuck with previews from the start, but at times, they’ve felt like more effort than they’re worth. But, with Xcode 13, I think we should all be using them for all views. In particular, I’ve noticed:
- They’re more reliable.
- The error messages finally make sense.
- Landscape mode is supported.
I consider previews a little like UI unit tests for your views. Like with unit tests, there’s some extra upfront effort required, but you get a big payback in terms of productivity and quality.
In this article, I’m going to cover:
- What you can check in your previews (think light/dark mode, different devices, landscape mode, etc.) and how to do it.
- Reducing the amount of boilerplate code you need in your previews.
- Writing previews for stateful apps. (I’ll be using Realm, but the same approach can be used with Core Data.)
- Troubleshooting your previews.
One feature I won’t cover is using previews as a graphical way to edit views. One of the big draws of SwiftUI is writing everything in code rather than needing storyboards and XML files. Using a drag-and-drop view builder for SwiftUI doesn’t appeal to me.
95% of the examples I use in this article are based on a BlackJack training app. You can find the final version in the repo.
Prerequisites
- Xcode 13+
- iOS 15+
- Realm-Cocoa 10.17.0+
Note:
- I’ve used Xcode 13 and iOS 15, but most of the examples in this post will work with older versions.
- Previewing in landscape mode is new in Xcode 13.
- The
buttonStyle
modifier is only available in iOS 15. - I used Realm-Cocoa 10.17.0, but earlier 10.X versions are likely to work.
Working with previews
Previews let you see what your view looks like without running it in a simulator or physical device. When you edit the code for your view, its preview updates in real time.
This section shows what aspects you can preview, and how it’s done.
A super-simple preview
When you create a new Xcode project or SwiftUI view, Xcode adds the code for the preview automatically. All you need to do is press the “Resume” button (or CMD-Alt-P).
The preview code always has the same structure, with the View
that needs previewing (in this case, ContentView
) within the previews
View
:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Views that require parameters
Most of your views will require that the enclosing view pass in parameters. Your preview must do the same—you’ll get a build error if you forget.
My ResetButton
view requires that the caller provides two values—label
and resetType
:
struct ResetButton: View {
var label: String
var resetType: ResetType
...
}
The preview code needs to pass in those values, just like any embedding view:
struct ResetButton_Previews: PreviewProvider {
static var previews: some View {
ResetButton(label: "Reset All Matrices",
resetType: .all)
}
}
Views that require Binding
s
In a chat app, I have a LoginView
that updates the username
binding that’s past from the enclosing view:
struct LoginView: View {
@Binding var username: String
...
}
The simplest way to create a binding in your preview is to use the constant
function:
struct LoginView_Previews: PreviewProvider {
static var previews: some View {
LoginView(username: .constant("Billy"))
}
}
NavigationView
s
In your view hierarchy, you only add a NavigationView
at a single level. That NavigationView
then wraps all subviews.
When previewing those subviews, you may or may not care about the NavigationView
functionality. For example, you’ll only see titles and buttons in the top nav bar if your preview wraps the view in a NavigationView
.
If I preview my PracticeView
without adding a NavigationView
, then I don’t see the title:
To preview the title, my preview code needs to wrap PracticeView
in a NavigationView
:
struct PracticeView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
PracticeView()
}
}
}
Smaller views
Sometimes, you don’t need to preview your view in the context of a full device screen. My CardView
displays a single playing card. Previewing it in a full device screen just wastes desk space:
We can add the previewLayout
modifier to indicate that we only want to preview an area large enough for the view. It often makes sense to add some padding
as well:
struct CardView_Previews: PreviewProvider {
static var previews: some View {
CardView(card: Card(suit: .heart))
.previewLayout(.sizeThatFits)
.padding()
}
}
Light and dark modes
It can be quite a shock when you finally get around to testing your app in dark mode. If you’ve not thought about light/dark mode when implementing each of your views, then the result can be ugly, or even unusable.
Previews to the rescue!
Returning to CardView
, I can preview a card in dark mode using the preferredColorScheme
view modifier:
struct CardView_Previews: PreviewProvider {
static var previews: some View {
CardView(card: Card(suit: .heart))
.preferredColorScheme(.dark)
.previewLayout(.sizeThatFits)
.padding()
}
}
That seems fine, but what if I previewed a spade instead?
That could be a problem.
Adding a white background to the view fixes it:
Preview multiple view instances
Sometimes, previewing a single instance of your view doesn’t paint the full picture. Just look at the surprise I got when enabling dark mode for my card view. Wouldn’t it be better to simultaneously preview both hearts and spades in both dark and light modes?
You can create multiple previews for the same view using the Group
view:
struct CardView_Previews: PreviewProvider {
static var previews: some View {
Group {
CardView(card: Card(suit: .heart))
CardView(card: Card(suit: .spade))
CardView(card: Card(suit: .heart))
.preferredColorScheme(.dark)
CardView(card: Card(suit: .spade))
.preferredColorScheme(.dark)
}
.previewLayout(.sizeThatFits)
.padding()
}
}
Composing views in a preview
A preview of a single view in isolation might look fine, but what will they look like within a broader context?
Previewing a single DecisionCell
view looks great:
struct DecisionCell_Previews: PreviewProvider {
static var previews: some View {
DecisionCell(
decision: Decision(handValue: 6, dealerCardValue: .nine, action: .hit), myHandValue: 8, dealerCardValue: .five)
.previewLayout(.sizeThatFits)
.padding()
}
}
But, the app will never display a single DecisionCell
. They’ll always be in a grid. Also, the text, background color, and border vary according to state. To create a more realistic preview, I created some sample data within the view and then composed multiple DecisionCell
s using vertical and horizontal stacks:
struct DecisionCell_Previews: PreviewProvider {
static var previews: some View {
let decisions: [Decision] = [
Decision(handValue: 6, dealerCardValue: .nine, action: .split),
Decision(handValue: 6, dealerCardValue: .nine, action: .stand),
Decision(handValue: 6, dealerCardValue: .nine, action: .double),
Decision(handValue: 6, dealerCardValue: .nine, action: .hit)
]
return Group {
VStack(spacing: 0) {
ForEach(decisions) { decision in
HStack (spacing: 0) {
DecisionCell(decision: decision, myHandValue: 8, dealerCardValue: .three)
DecisionCell(decision: decision, myHandValue: 6, dealerCardValue: .three)
DecisionCell(decision: decision, myHandValue: 8, dealerCardValue: .nine)
DecisionCell(decision: decision, myHandValue: 6, dealerCardValue: .nine)
}
}
}
VStack(spacing: 0) {
ForEach(decisions) { decision in
HStack (spacing: 0) {
DecisionCell(decision: decision, myHandValue: 8, dealerCardValue: .three)
DecisionCell(decision: decision, myHandValue: 6, dealerCardValue: .three)
DecisionCell(decision: decision, myHandValue: 8, dealerCardValue: .nine)
DecisionCell(decision: decision, myHandValue: 6, dealerCardValue: .nine)
}
}
}
.preferredColorScheme(.dark)
}
.previewLayout(.sizeThatFits)
.padding()
}
I could then see that the black border didn’t work too well in dark mode:
Switching the border color from black
to primary
quickly fixed the issue:
Landscape mode
Previews default to portrait mode. Use the previewInterfaceOrientation
modifier to preview in landscape mode instead:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewInterfaceOrientation(.landscapeRight)
}
}
Device type
Previews default to the simulator device that you’ve selected in Xcode. Chances are that you want your app to work well on multiple devices. Typically, I find that there’s extra work needed to make an app I designed for the iPhone work well on an iPad.
The previewDevice
modifier lets us specify the device type to use in the preview:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewDevice(PreviewDevice(rawValue: "iPad (9th generation)"))
}
}
You can find the names of the available devices from Xcode’s simulator menu, or from the terminal using xcrun simctl list devices
.
Pinning views
In the bottom-left corner of the preview area, there’s a pin button. Pressing this “pins” the current preview so that it’s still shown when you browse to the code for other views:
This is useful to observe how a parent view changes as you edit the code for the child view:
Live previews
At the start of this article, I made a comparison between previews and unit testing. Live previews mean that you really can test your views in isolation (to be accurate, the view you’re testing plus all of the views it embeds or links to).
Press the play button above the preview to enter live mode:
You can now interact with your view:
Getting rid of excess boilerplate preview code
As you may have noticed, some of my previews now have more code than the actual views. This isn’t necessarily a problem, but there’s a lot of repeated boilerplate code used by multiple views. Not only that, but you’ll be embedding the same boilerplate code into previews in other projects.
To streamline my preview code, I’ve created several view builders. They all follow the same pattern—receive a View
and return a new View
that’s built from that View
.
I start the name of each view builder with _Preview
to make it easy to take advantage of Xcode’s code completion feature.
Light/dark mode
_PreviewColorScheme
returns a Group
of copies of the view. One is in light mode, the other dark:
struct _PreviewColorScheme<Value: View>: View {
private let viewToPreview: Value
init(_ viewToPreview: Value) {
self.viewToPreview = viewToPreview
}
var body: some View {
Group {
viewToPreview
viewToPreview.preferredColorScheme(.dark)
}
}
}
To use this view builder in a preview, simply pass in the View
you’re previewing:
struct CardView_Previews: PreviewProvider {
static var previews: some View {
_PreviewColorScheme(
VStack {
ForEach(Suit.allCases, id: \.rawValue) { suit in
CardView(card: Card(suit: suit))
}
}
.padding()
.previewLayout(.sizeThatFits)
)
}
}
Orientation
_PreviewOrientation
returns a Group
containing the original View
in portrait and landscape modes:
struct _PreviewOrientation<Value: View>: View {
private let viewToPreview: Value
init(_ viewToPreview: Value) {
self.viewToPreview = viewToPreview
}
var body: some View {
Group {
viewToPreview
viewToPreview.previewInterfaceOrientation(.landscapeRight)
}
}
}
To use this view builder in a preview, simply pass in the View
you’re previewing:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
_PreviewOrientation(
ContentView()
)
}
}
No device
_PreviewNoDevice
returns a view built from adding the previewLayout
modifier and adding `padding to the input view:
struct _PreviewNoDevice<Value: View>: View {
private let viewToPreview: Value
init(_ viewToPreview: Value) {
self.viewToPreview = viewToPreview
}
var body: some View {
Group {
viewToPreview
.previewLayout(.sizeThatFits)
.padding()
}
}
}
To use this view builder in a preview, simply pass in the View
you’re previewing:
struct CardView_Previews: PreviewProvider {
static var previews: some View {
_PreviewNoDevice(
CardView(card: Card())
)
}
}
Multiple devices
_PreviewDevices
returns a Group
containing a copy of the View
for each device type. You can modify devices
in the code to include the devices you want to see previews for:
struct _PreviewDevices<Value: View>: View {
let devices = [
"iPhone 13 Pro Max",
"iPhone 13 mini",
"iPad (9th generation)"
]
private let viewToPreview: Value
init(_ viewToPreview: Value) {
self.viewToPreview = viewToPreview
}
var body: some View {
Group {
ForEach(devices, id: \.self) { device in
viewToPreview
.previewDevice(PreviewDevice(rawValue: device))
.previewDisplayName(device)
}
}
}
}
I’d be cautious about adding too many devices as it will make any previews using this view builder slow down and consume resources.
To use this view builder in a preview, simply pass in the View
you’re previewing:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
_PreviewDevices(
ContentView()
)
}
}
Combining multiple view builders
Each view builder receives a view and returns a new view. That means that you can compose the functions by passing the results of one view builder to another. In the extreme case, you can use up to three on the same view preview:
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
_PreviewOrientation(
_PreviewColorScheme(
_PreviewDevices(ContentView())
)
)
}
}
This produces 12 views to cover all permutations of orientation, appearance, and device.
For each view, you should consider which modifiers add value. For the CardView
, it makes sense to use _PreviewNoDevice
and _PreviewColorSchem
e, but previewing on different devices and orientations wouldn’t add any value.
Previewing stateful views (Realm)
Often, a SwiftUI view will fetch state from a database such as Realm or Core Data. For that to work, there needs to be data in that database.
Previews are effectively running on embedded iOS simulators. That helps explain how they are both slower and more powerful than you might expect from a “preview” feature. That also means that each preview also contains a Realm database (assuming that you’re using the Realm-Cocoa SDK). The preview can store data in that database, and the view can access that data.
In the BlackJack training app, the action to take for each player/dealer hand combination is stored in Realm. For example, DefaultDecisionView
uses @ObservedResults
to access data from Realm:
struct DefaultDecisionView: View {
@ObservedResults(Decisions.self,
filter: NSPredicate(format: "isSoft == NO AND isSplit == NO")) var decisions
To ensure that there’s data for the previewed view to find, the preview checks whether the Realm database already contains data (Decisions.areDecisionsPopulated
). If not, then it adds the required data (Decisions.bootstrapDecisions()
):
struct DefaultDecisionView_Previews: PreviewProvider {
static var previews: some View {
if !Decisions.areDecisionsPopulated {
Decisions.bootstrapDecisions()
}
return _PreviewOrientation(
_PreviewColorScheme(
Group {
NavigationView {
DefaultDecisionView(myHandValue: 6, dealerCardValue: .nine)
}
NavigationView {
DefaultDecisionView(myHandValue: 6, dealerCardValue: .nine, editable: true)
}
}
.navigationViewStyle(StackNavigationViewStyle())
)
)
}
}
DefaultDecisionView
is embedded in DecisionMatrixView
and so the preview for DecisionMatrixView
must also conditionally populate the Realm data. In turn, DecisionMatrixView
is embedded in PracticeView
, and PracticeView
in ContentView
—and so, they too need to bootstrap the Realm data so that it’s available further down the view hierarchy.
This is the implementation of the bootstrap functions:
extension Decisions {
static var areDecisionsPopulated: Bool {
do {
let realm = try Realm()
let decisionObjects = realm.objects(Decisions.self)
return decisionObjects.count >= 3
} catch {
print("Error, couldn't read decision objects from Realm: \(error.localizedDescription)")
return false
}
}
static func bootstrapDecisions() {
do {
let realm = try Realm()
let defaultDecisions = Decisions()
let softDecisions = Decisions()
let splitDecisions = Decisions()
defaultDecisions.bootstrap(defaults: defaultDefaultDecisions, handType: .normal)
softDecisions.bootstrap(defaults: defaultSoftDecisions, handType: .soft)
splitDecisions.bootstrap(defaults: defaultSplitDecisions, handType: .split)
try realm.write {
realm.delete(realm.objects(Decision.self))
realm.delete(realm.objects(Decisions.self))
realm.delete(realm.objects(Decision.self))
realm.delete(realm.objects(Decisions.self))
realm.add(defaultDecisions)
realm.add(softDecisions)
realm.add(splitDecisions)
}
} catch {
print("Error, couldn't read decision objects from Realm: \(error.localizedDescription)")
}
}
}
Partitioned, synced realms
The BlackJack training app uses a standalone Realm database. But what happens if the app is using Realm Sync?
One option could be to have the SwiftUI preview sync data with your backend Realm service. I think that’s a bit too complex, and it breaks my paradigm of treating previews like unit tests for views.
I’ve found that the simplest solution is to make the view aware of whether it’s been created by a preview or by a running app. I’ll explain how that works.
AuthorView
from the RChat app fetches data from Realm:
struct AuthorView: View {
@ObservedResults(Chatster.self) var chatsters
...
}
Its preview code bootstraps the embedded realm:
struct AuthorView_Previews: PreviewProvider {
static var previews: some View {
Realm.bootstrap()
return AppearancePreviews(AuthorView(userName: "rod@contoso.com"))
.previewLayout(.sizeThatFits)
.padding()
}
}
The app adds bootstrap as an extension to Realm:
extension Realm: Samplable {
static func bootstrap() {
do {
let realm = try Realm()
try realm.write {
realm.deleteAll()
realm.add(Chatster.samples)
realm.add(User(User.sample))
realm.add(ChatMessage.samples)
}
} catch {
print("Failed to bootstrap the default realm")
}
}
}
A complication is that AuthorView
is embedded in ChatBubbleView
. For the app to work, ChatBubbleView
must pass the synced realm configuration to AuthorView
:
AuthorView(userName: authorName)
.environment(\.realmConfiguration,
app.currentUser!.configuration(
partitionValue: "all-users=all-the-users"))
But, when previewing ChatBubbleView
, we want AuthorView
to use the preview’s local, embedded realm (not to be dependent on a Realm back-end app). That means that ChatBubbleView
must check whether or not it’s running as part of a preview:
struct ChatBubbleView: View {
...
var isPreview = false
...
var body: some View {
...
if isPreview {
AuthorView(userName: authorName)
} else {
AuthorView(userName: authorName)
.environment(\.realmConfiguration,
app.currentUser!.configuration(
partitionValue: "all-users=all-the-users"))
}
...
}
}
The preview is then responsible for bootstrapping the local realm and flagging to ChatBubbleView
that it’s a preview:
struct ChatBubbleView_Previews: PreviewProvider {
static var previews: some View {
Realm.bootstrap()
return ChatBubbleView(
chatMessage: .sample,
authorName: "jane",
isPreview: true)
}
}
Troubleshooting your previews
As mentioned at the beginning of this article, the error messages for failed previews are actually useful in Xcode 13.
That’s the good news.
The bad news is that you still can’t use breakpoints or print to the console.
One mitigation is that the previews
static var in your preview is a View
. That means that you can replace the body
of your ContentView
with your previews
code. You can then run the app in a simulator and add breakpoints or print to the console. It feels odd to use this approach, but I haven’t found a better option yet.
Conclusion
I’ve had a mixed relationship with SwiftUI previews.
When they work, they’re a great tool, making it quicker to write your views. Previews allow you to unit test your views. Previews help you avoid issues when your app is running in dark or landscape mode or on different devices.
But, they require effort to build. Prior to Xcode 13, it would be tough to justify that effort because of reliability issues.
I believe that Xcode 13 is the tipping point where the efficiency and quality gains far outweigh the effort of writing preview code. That’s why I’ve written this article now.
In this article, you’ve seen a number of tips to make previews as useful as possible. I’ve provided four view builders that you can copy directly into your SwiftUI projects, letting you build the best previews with the minimum of code. Finally, you’ve seen how you can write previews for views that work with data held in a database such as Realm or Core Data.
Please provide feedback and ask any questions in the Realm Community Forum.