Making SwiftUI Previews Work For You

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.

Xcode showing a blackjack app with multiple previews for different devices, orientations, and color schemes

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).

A simple Xcode preview which updates as the code is edited

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 Bindings

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"))
   }
}

NavigationViews

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:

Preview where there is no title on the screen

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()
       }
   }
}

Preview with title

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:

Lots of preview space taken up for the iPhone, but the view we're testing only takes up a tiny proportion of the 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()
   }
}

Lots of screen space freed up by only previewing the view (rather than the entire iPhone screen

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()
   }
}

10 Hearts displaying correctly in front of a dark background

That seems fine, but what if I previewed a spade instead?

Black spade isn't visible in front of dark background

That could be a problem.

Adding a white background to the view fixes it:

King of spades now shows up as it sits in a white box

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()
   }
}

Multiple cards, each with their own preview

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()
   }
}

A single, yellow Hit cell

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 DecisionCells 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:

Dark border around selected cells is lost in front of the dark background

Switching the border color from black to primary quickly fixed the issue:

White frame around selected cell is visible in front of dark background

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)
   }
}

iPhone app previewed in lanscape mode

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)"))
   }
}

App previewed on an iPad

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:

Pin icon in Xcode

This is useful to observe how a parent view changes as you edit the code for the child view:

Animation of the pinned view continuing to be shown and updates as the developer switches to other views in Xcode

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:

Play button above Xcode preview

You can now interact with your view:

Preview responds to user actions

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)
       )
   }
}

multiple cards shown in the same preview

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()
       )
   }
}

Preview showing the same view in portrait and lanscape modes

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())
       )
   }
}

9 Clubs previewed in a small window

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()
       )
   }
}

The same view previewed on 3 different device types

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 _PreviewColorScheme, 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.





Leave a Reply