Using PreviewModifier for Quick Xcode Previews
This post is brought to you by Emerge Tools, the best way to build on mobile.
Ah, Xcode Previews. The absolute best, worst thing Apple has ever shipped for developers. When it works, which for me is around 90% of the time, it’s absolutely essential to my workflow.
Other times? It crashes, spins, indexes and loads for an eternity — and I end up sitting there and waiting for it to work again out of nothing else but pure, unadulterated spite. And, as I launch my fusillade of negative thoughts and words towards Xcode, I couldn’t help but wonder if the problem was me.
Foolishly, I was passing in an expensive object around my previews, a holdover of a //TODO: Don't do this
I had while prototyping stuff. In the process, though, I came to love having actual data from the server that wasn’t mocked. This was the good stuff, along with the real-world connectivity loading times.
In short, I wanted my cake. And I wanted to eat it, too.
Recently, I found a tip that’s helped out with the long loads like this, PreviewModifier
(docs). It helps make complex, heavy-load type of objects more performant to use across previews by basically doing all the expensive work once.
Sharing is Caring
Consider the purpose of Observable
- it’s literally to reuse and share the same chunk of data, managers, data access objects, networking layers — you name it — across your codebase:
@Observable
class DAO {
var accountData: AccountData = .empty
init() {
veryExpensiveSetup()
}
func veryExpensiveSetup() {
// Hit API, or DB, or both...
accountData = fetchedData
}
}
@main
struct TheBestApp: App {
// Expensive work happens here
@State private var dataAccess = DAO()
var body: some Scene {
WindowGroup {
MyViewHierarchy()
// But at least the hit happens only once
.environment(dataAccess)
}
}
}
And so it goes, dataAccess
can do the time-consuming stuff with an upfront cost, and let the rest of the app use it without incurring the same hit:
struct MyViewHierarchy: View {
@Environment(DAO.self) private var dataAccess
var body: some View {
Form {
// Everything has access to dataAccess
Text(dataAccess.accountData.name)
UserAccountView()
NotificationsView()
LegalView()
}
}
}
This is SwiftUI 101 stuff. So, given the wins we have here — why don’t we extend the same logic to Xcode Previews? Instead of doing something like this:
#Preview {
MyViewHierarchy()
// Snapple! We're running `init()` every
// time this thing reloads!
.environment(DAO())
}
…or this1:
extension DAO {
var mock: DAO { // Setup mocked DAO... }
}
extension AccountData {
var mock: AcccountData { // Setup mocked account data... }
}
…maybe you want the real, bonafide data that comes with actually hitting your API, or setting up your data layer. But, without doing that over and over.
Caching Preview Objects
It turns out, you can. And I had no idea this was possible until a few days ago. It’s eerily similar to how ViewModifier
is handled. Here’s what it looks like:
// Adopt `PreviewModifier`
struct CachedPreviewData: PreviewModifier {
static func makeSharedContext() async throws -> DAO {
let dao = DAO()
await dao.makeSomeOtherNetworkCalls()
await dao.noSeriouslyGoCrazyWithIt()
// Because we're only doing this once...
return dao
}
// And now it's reused
func body(content: Content, context: DAO) -> some View {
content
.environment(context)
}
}
// Add the modifier to the preview.
#Preview(traits: .modifier(CachedPreviewData())) {
// Now this, or any other preview using the same setup
// Has access to `DAO`, and it only ran `init()` once
MyViewHierarchy()
}
When you do this, Xcode Previews will cache the expensive object, and then refer to it over and over. Now, you’re free to have real data, in real previews, without it being a real %!@*&%$ pain in the @$!.
And that’s it! Print out some logs, and you’ll see that crazy work only happens once. Plus, you can get as creative as you want here - again, it works just like ViewModifier
does. So, pass in some flags or data in the initializer, make a few of them, house core logic in only one — it all works:
struct CachedPreviewData: PreviewModifier {
// Add in a flag to access different stuff
enum UserRole: Int { case user, admin, temp }
var role: UserRole
// Same code as we had before that uses that enum
}
#Preview(traits: .modifier(CachedPreviewData(role: .admin))) {
// Admin role will be used
MyViewHierarchy()
}
Until next time ✌️
-
Which, by the way, is not a bad approach at all. I use this method all the time. But there are situations where you need to hit the actual server for a variety of testing reasons. ↩