Using PreviewModifier to build a previewing environment

Published on: July 10, 2024

Xcode 16 and iOS 18 come with a feature that allows us to build elaborate preview environments using a new PreviewModifier protocol. This protocol allows us to define objects that can create a single context or environment that’s cached and used across your SwiftUI previews.

This is useful because it means that you could, for example, populate a database with a bunch of mock data that is then used in your previews.

You can also use PreviewModifier to apply specific styling to your previews, to wrap them all in a specific wrapper, and more.

Essentially, they’re a tool that allows you to configure your previews consistently across the board.

Decorating views using PreviewModifier

The PreviewModifier protocol specifies two methods that you can implement:

  • A static makeSharedContext method
  • An instance method called body

The body instance methods is passed the view that’s being previewed and a Context. This context can either be an object that you created in makeSharedContext or Void if you don’t implement makeSharedContext.

For this example, let’s assume that you went ahead and did not implement makeSharedContext. In a situation like that, we can use PreviewModifier to decorate a view for our previews. For example, we could wrap it in another view or apply some styling to it.

I’m pretty sure that you’re more creative than me so I’m just going to go ahead and show you how you would apply a orange background to your previewed view. Yeah, I know… very creative. The point is to show you how to do this so that you can do something much smarter than what I’m describing here.

struct OrangeBackground: PreviewModifier {
    func body(content: Content, context: Void) -> some View {
        content
            .padding()
            .background {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.orange)
            }
    }
}

#Preview(traits: .modifier(OrangeBackground())) {
    Text("Hello, world!")
}

Let’s look at the PreviewModifier first, and then I’ll explain how I applied it to my preview.

The modifier is defined as a struct and I only implemented the body function.

This function is padded Content which is whatever view the #Preview macro is being used on (in this case, Text), and it receives a context. In this case Void because I didn’t make a context.

The content argument can be styled, modified, and wrapped however you need. It’s a view so you can do things like give it a background, transform it, adjust its environment, and much much more. Anything you can do with a view inside of a View body you can do here.

The main difference is that you’re receiving a fully instantiated instance of your view. That means you can’t inject new state or bindings into it or otherwise modify it. You can only apply view modifiers to it.

This brings us to our next feature of PreviewModifier creating a context to provide mocked data and more.

Using PreviewModifier to inject mock data

To inject mock data into your previews through PreviewModifier all you need to do is implement the makeSharedContext method from the PreviewModifier protocol. This method is static and is called once for all your previews. This means that the context that you create in this method is reused for all of your previews.

In practice this is nice because it means you get consistent mock data for your previews without the overhead of recreating this data frequently.

Here’s what a sample implementation for makeSharedContext looks like:

struct MockDataSource {
    // ...
}

struct OrangeBackground: PreviewModifier {
    static func makeSharedContext() async throws -> MockDataSource {
        return MockDataSource()
    }
}

In this case, I’m creating an instance of some data source in my makeSharedContext method. This MockDataSource would hold all mocks and all data for my views which is great.

However, the only way for us to really use that mock data in our view is by adding our data source (or the mocked data) to our previewed view’s environment.

struct OrangeBackground: PreviewModifier {
    static func makeSharedContext() async throws -> MockDataSource {
        return MockDataSource()
    }

    func body(content: Content, context: MockDataSource) -> some View {
        content
            .environment(\.dataSource, context)
    }
}

Since we can’t make a new instance of our content, we can’t inject our mock data source directly into the view through its initializer. The only way we can get the data source to the view is by adding it to the environment.

This is not ideal in my opinion, but the design makes sense.

I’m also pretty sure that Apple designed this API with mocking SwiftData databases in mind and it would work great for that.

On top of having to use the environment, the PreviewModifier only works in projects that target iOS 18 or later. Not a huge problem but it would have been nice if using Xcode 16 was good enough for us to be able to use this handy new API.

Categories

SwiftUI

Subscribe to my newsletter