Building, Testing, and Scaling With SwiftUI Part 1: Building Previewable Views
One aspect of SwiftUI that is crucial to my workflows is the SwiftUI Preview system. Previews are an incredibly powerful and sophisticated tool, intelligently recompiling only the code which has been modified, in order to achieve a blazing fast edit-refresh cycle. If you change the String value you pass to a SwiftUI Text
view, Previews will recompile that specific view only and nothing else in your view hierarchy, dynamically swapping it in at runtime. It’s an impressive piece of software that we should all be using to speed up our development workflows.
Beyond its time-saving qualities, I find that when you develop your views to be previewable, you’re also just writing good code. Writing views that preview easily means you’re writing views that clearly define their inputs and outputs, which make them highly reusable and highly testable.
For the purposes of this discussion, I’ll be using code that uses Model-View-ViewModel naming conventions, but the architecture you use is irrelevant. In order to follow along, your architecture should have a “View” layer that is separate from everything else. Many projects don’t start out this way, but once you start introducing this separation, you’ll wonder how you ever lived without it. Enough abstract, let’s look at some code.
struct PokemonList: View {
@State var viewModel = PokemonListViewModel()
// ... view stuff
}
@Observable
final class PokemonListViewModel {
var pokemonList: [Pokemon] = []
func fetchPokemon() async {
// populates pokemonList
}
}
#Preview {
PokemonList()
}
Suppose I were a huge Pokémon fan (which I am) and I wanted a nice way to browse a list of Pokémon. I might throw together a little app that uses the wonderful PokéApi. Pretty standard stuff here: a view, a view model that does some stuff to populate data into that view, and a preview that instantiates the view, all using the new @Observable
APIs in iOS 17. (Side Note: You can do the same thing in iOS 16 and earlier using @StateObject
on your property and having your view model subclass ObservableObject
).
The Catch
The interesting bit is at the end: #Preview { PokemonList() }
. This little snippet of code is insidious. PokemonList()
might just be instantiating a view, but that view takes on the responsibility of instantiating its own view model. Check out the code we wrote:
@State var viewModel = PokemonListViewModel()
This means that no matter what environment we want to run our view in (like a demo environment in a SwiftUI Preview) we will always be using our real view model, making real queries to the PokéApi. This makes it difficult to test our views against specific samples of data and slows down development. In some larger codebases, our real view models might even crash inside of a SwiftUI Preview because legacy code might make assumptions about being in a production environment.
The solution? An extra level of indirection!.
Making a View Model Protocol
The goal of defining a view model protocol is to clearly define all inputs and outputs to a view. By doing so, we are able to create stub implementations of our view models that don’t rely on a production environment to function. For the simple example above, this protocol is pretty straightforward, but many view models are highly complex. Therefore this becomes the hardest part of the exercise and, unfortunately, it is highly dependent on the individual codebase. The view model protocol for our example looks like this:
protocol PokemonListViewModelProtocol: Observable {
var pokemonList: [Pokmeon] { get }
func fetchPokemon() async
}
We then generalize our view over the view model protocol, and allow it to be passed in through the initializer:
struct PokemonList<ViewModel: PokemonListViewModelProtocol>: View {
@State var viewModel: ViewModel
init(viewModel: ViewModel) {
_viewModel = State(wrappedValue: viewModel)
}
// ... view stuff
}
And finally, we conform our view model to the new protocol, and pass it into the initializer wherever we want to use the view, like in the SwiftUI Preview.
@Observable
final class PokemonListViewModel: PokemonListViewModelProtocol {
var pokemonList: [Pokemon] = []
func fetchPokemon() async {
// populates pokemonList
}
}
#Preview {
PokemonList(viewModel: PokemonListViewModel())
}
Okay the hard part is over. But our preview is still using the real view model. So now comes the slightly less hard (or potentially way harder) part.
Making A Stub View Model
Generating test data is, again, highly dependent on the individual use cases. In the contrived example I give here, Pokemon
is a simple struct and I can instantiate them myself. And in fact I made several Pokemon
structs, and stuck them all in a static variable called Pokemon.list
. You, dear reader, may not find yourself in so fortunate a situation. Many times, for reasons that are forgotten or unspoken, the types that we use in our views are difficult to instantiate on their own. In those situations, I can only suggest that you add yet another layer of indirection (similar to what we’ve done here) and implore that you find a way to use more value types in your code.
Philosophy aside, one of the best ways of generating fake data is from real data. For this example, I made a bunch of manual API calls using the wonderful httpie and shoved them into JSON files in my project. To make stub data, I read those JSON files from the bundle (force unwrap everything, it’s only for development) and shove them into static variables on my data type. If you’re worried about accidentally shipping the force-unwrapped stubs, wrap them in #if DEBUG
blocks, though you’ll need to do the same for your previews.
Here’s what the JSON loading looks like:
extension Pokemon {
static let list: [Pokemon] = [
bulbasaur,
// ...
]
static let bulbasaur: Pokemon = loadStub(fromFile: "001-bulbasaur.json")
// ... several more pokemon
private static func loadStub(fileName: String) -> Pokemon {
let split = fileName.split(separator: ".")
guard split.count == 2 else {
fatalError("bad file name, should have an extension")
}
let baseName = String(split[0])
let ext = String(split[1])
guard let filepath = Bundle.main.path(forResource: baseName, ofType: ext) else {
fatalError("Could not load \(fileName)")
}
let data = try! String(contentsOfFile: filepath).data(using: .utf8)!
return try! JSONDecoder().decode(Pokemon.self, from: data)
}
}
With our test data in place, we can define the stub view model! Once you reach this part, everything should be plug and play:
@Observable
final class StubPokemonListViewModel: PokemonListViewModelProtocol {
let pokemonList = Pokemon.list
func fetchPokemon() async { /* no-op */ }
}
#Preview {
PokemonList(viewModel: StubPokemonListViewModel())
}
Since we’re hardcoding our values in, we can largely ignore the behavior of data fetching tasks for most use cases (hence the no-op) and now our preview is based entirely on our locally defined static data. This means we can do lots of things we couldn’t very easily do before!
- Want to test what your view looks like with an empty list? Sure!
- Want to test what your view looks like with less than ten, or over a thousand elements? Go for it.
- Want to test what it looks like for
fetchPokemon()
to take forever? Nothing’s stopping you from sticking aTask.sleep
in there and actually changing thepokemonList
.
The possibilities become endless. You can even make rather sophisticated stub view models that can easily represent lots of different states. Since I started adopting this pattern in my code I have felt unleashed, as though the shackles of legacy code have been broken and I can build and iterate on views freely. I strongly recommend you give this style of view a try.
Next time we talk about this, we’ll look at a another related problem: nested views with their own view models.