Using PreviewModifier to build a previewing environment
Published on: July 10, 2024Xcode 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.