Structuring SwiftUI Previews for API Calls

SwiftUI, together with Combine and Xcode 11+, provide a powerful toolset for quickly creating an app with a native UI. In Xcode 11+, the preview pane was introduced in order to provide live snapshots of your SwiftUI views and UIViewControllers as your code changes without having to launch the simulator each time. This makes iterating on SwiftUI even faster and makes it really easy to tweak your designs. However, it will quickly become apparent that previewing can be a challenge when the app is designed to display data from a network call. In this post, we’ll walk through how this problem can be solved by building an app together to help users discover video games. For those not familiar with SwiftUI, Combine, or MVVM, I’ve linked resources down below to help you get up to speed.
We start with a simple tabbed UI containing a view to browse games and another view to track games the user wants to play later. We’ll call this tabbed view the HomeView
.
enum TabTag: String {
case discover, later
}
struct HomeView: View {
@State private var selection = TabTag.discover.rawValue
var body: some View {
TabView(selection: $selection) {
DiscoverGamesView()
PlayLaterView()
}.accentColor(Color(.systemIndigo))
}
}
We’ll also tell the SceneDelegate that this is the view to show when the app first launches. Note that in iOS 14, we can bypass the SceneDelegate altogether and make our HomeView the scene.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = HomeView()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
}
For this post, let’s focus on the DiscoverGamesView
portion of the app. This view is where we’ll display a list of games which we’ll call a GameRow
based on the games we receive from a call to our API. The API calls will live in our GamesService
class. Let’s take a look at our service.
final class GamesService: GamesServiceType {
private let networkService: NetworkServiceType
private let imageService: ImageServiceType
init(networkService: NetworkService = NetworkService(),
imageService: ImageService = ImageService()) {
self.networkService = networkService
self.imageService = imageService
}
func loadGames(with filters: [String : String]? = nil) -> AnyPublisher<[Game], NetworkError> {
return networkService
.load(Resource<Games>.games())
.map { $0.results }
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
... ...
}
Here we’re injecting NetworkService
and ImageService
instances which makes it really easy to provide different services should we want to test this class with mocked services or if we wanted to completely swap out our implementation of our NetworkServiceType
without affecting our GamesService
code. These service classes provide convenience functions for making network calls with Combine. We’ll ignore the implementation details of that since it’s outside the scope of this post, but I’ve linked great resources below if you’d like to dig into that more.
Now that we have a function we can call to load our games, we can get back to our views.
struct DiscoverGamesView: View {
@State private var showModal: Bool = false
@StateObject private var viewModel = DiscoverGamesViewModel(gamesService: GamesService())
private let title = "Discover"
var body: some View {
NavigationView {
if viewModel.hasError {
NetworkErrorView(errorMessage: "Unable to load games")
.navigationBarTitle(title)
} else {
ScrollView {
// New to iOS 14, LazyVStack allows for lazy loading of views in
// the stack as the views appear on screen.
LazyVStack {
ForEach(viewModel.games) { game in
makeRow(for: game)
.padding(.horizontal, 15)
.padding(.bottom, 5)
}
}
}
.navigationBarTitle(title)
.navigationBarItems(trailing:
Button(action: {
self.showModal = true
}, label: {
Image(systemName: "person.crop.circle")
.imageScale(.large)
}).sheet(isPresented: $showModal) {
ProfileView()
}
)
}
}
.tabItem {
VStack {
Image(systemName: "gamecontroller").imageScale(.large)
Text(title)
}
}
.tag(TabTag.discover.rawValue)
}
}
extension DiscoverGamesView {
func makeRow(for game: Game) -> some View {
NavigationLink(destination: GameDetailView(game: game)) {
let viewModel = GameRowViewModel(gamesService: GamesService())
GameRow(game: game, viewModel: viewModel)
}.buttonStyle(PlainButtonStyle())
}
}
At the bottom of DiscoverGamesView
is where we construct our GameRow
for each row of our list which gets its own ViewModel and service. The NavigationLink
is what provides a view to push onto the navigation stack when tapping on a row.
Additionally, DiscoverGamesView
has a required DiscoverGamesViewModel
which will serve as an intermediary between the service and view in order to pass along the data we need for rendering the list of games and any errors that are encountered in the service. Since we’re using Combine, we’re able to simply mark our ViewModel property with the property wrapper @StateObject
to allow us to observe changes to its properties. In other words, when viewModel.games
or viewModel.hasError
changes, our body
code will be re-run and our components updated to reflect the changes automatically. This is a new property wrapper in iOS 14 which allows our source of truth to live within our view unlike @ObservedObject
which requires the value be passed in since the view’s lifetime is not guaranteed.
final class DiscoverGamesViewModel<T: GamesServiceType>: ObservableObject {
@Published private(set) var hasError = false
@Published private(set) var games = [Game]()
private var cancellables = Set<AnyCancellable>()
private let gamesService: T
init(gamesService: T) {
self.gamesService = gamesService
gamesService.loadGames(with: nil).sink(receiveCompletion: { [weak self] (status) in
switch status {
case .finished:
return
case .failure(let error):
print(error)
self?.hasError = true
}
}) { [weak self] games in
self?.games = games
}.store(in: &cancellables)
}
}
You’ll notice that the view model must also implement ObservableObject
and mark it’s published properties with the @Published
property wrapper so they can be observed by our view. Now that we’ve got some of the foundation laid down, let’s start by looking at what the GameRow
code looks like and how we can preview it.
struct GameRow: View {
private let game: Game
@ObservedObject private(set) var viewModel: GameRowViewModel
init(game: Game, viewModel: GameRowViewModel) {
self.game = game
self.viewModel = viewModel
viewModel.retrieveImage(for: game)
}
var body: some View {
VStack {
ZStack(alignment: .bottomLeading) {
Image(uiImage: viewModel.image ?? UIImage())
.resizable()
.aspectRatio(contentMode: .fill)
.frame(height: 150)
.clipped()
Text(game.name)
.padding(10)
.background(Color.black.blur(radius: 30))
.foregroundColor(.white)
.font(.headline)
.multilineTextAlignment(.leading)
}
.clipped()
HStack {
Text(game.platformsDisplay())
.padding(10)
.foregroundColor(.white)
.font(.caption)
.multilineTextAlignment(.leading)
Spacer()
}
}
.background(Color(.systemIndigo))
.cornerRadius(15)
}
}
Here we notice there are similarities to our DiscoverGamesView
. There’s a required @ObservedObject
ViewModel. The ViewModel is responsible for interacting with the service in order to retrieve the game’s image to display in the cell. Our view body
also contains the game title and the list of platforms the game can run on. This GameRow
view component is a perfect candidate for previewing. Let’s go ahead and add that now.
struct GameRow_Previews: PreviewProvider {
static var previews: some View {
Group {
GameRow(game: Game.example,
viewModel: GameRowViewModel(gamesService: GamesService()))
.previewLayout(.fixed(width: 320, height: 250))
.padding()
GameRow(game: Game.example,
viewModel: GameRowViewModel(gamesService: GamesService()))
.previewLayout(.fixed(width: 320, height: 250))
.padding()
.background(Color(.systemBackground))
.colorScheme(.dark)
}
}
}
To create a preview, we simply create a new struct at the bottom of our GameRow
file and implement PreviewProvider
which requires that we implement the previews
static variable. In here, we simply instantiate an instance of the view we’d like to preview. In this case, we create a new GameRow
and provide it a model for our game and an instance of GameRowViewModel
which requires an injected GamesService
instance. We can even wrap this in a Group
and provide multiple versions of the GameRow
to preview. Here we have a light mode and dark mode version. We have also constrained our previews to a custom frame of 320×250 given our view isn’t being size constrained by a parent list view when previewing it. Now that the preview is ready, type Cmd+Shift+P to open the preview pane. You should see a preview of the GameRow
but you’ll quickly notice something isn’t right. Our image is missing!

However, there’s an easy fix for this. Since we’ve been injecting all of our dependencies this entire time, we can create a MockGameService
which will return a static image for displaying in previews. Let’s do that now.
protocol GamesServiceType {
// Runs game search with optional query params
func loadGames(with filters: [String : String]?) -> AnyPublisher<[Game], NetworkError>
// Fetches the backgroundImage for the given game
func loadImageForGame(_ game: Game) -> AnyPublisher<UIImage?, Never>
}
final class MockGamesService: GamesServiceType {
func loadGames(with filters: [String : String]?) -> AnyPublisher<[Game], NetworkError> {
return Just([Game.example, Game.exampleTwo]])
.catch { _ in AnyPublisher<Output, Failure>.empty() }
.eraseToAnyPublisher()
}
func loadImageForGame(_ game: Game) -> AnyPublisher<UIImage?, Never> {
return Just(UIImage(named: "example_game_poster"))
.catch { _ in AnyPublisher<Output, Failure>.empty() }
.eraseToAnyPublisher()
}
}
We’ll create a new class called MockGamesService
which will implement our GamesServiceType
protocol just like our GamesService
but instead of making any real network calls, we’ll use Just()
from Combine to return an image from our asset catalog. Now, when we create our GameRow
preview, we can pass in an instance of MockGamesService
.
GameRow(game: Game.example,
viewModel: GameRowViewModel(gamesService: MockGamesService()))
.previewLayout(.fixed(width: 320, height: 250))
.padding()

Much better! Now what if we want to preview our DiscoverGamesView
inside it’s tab and navigation controller along with some populated data? If we try to do that now, we’ll find we have a similar issue. We’re relying on our service to populate our ViewModels but when we run our previews, our services don’t return data. If we place preview code in our HomeView
, we’ll just see this.
struct HomeView_Previews: PreviewProvider {
static var previews: some View {
HomeView()
}
}

This is a bit of a trickier problem than our GameRow
example because we want all of our child views of the HomeView
down to the individual GameRow
views to be able to distinguish between rendering mocks for previews and rendering data from our API. To solve this, we’ll use generics!
Leveraging Generics for Previews
Let’s go back and take a look at our DiscoverGamesViewModel
that holds onto our GamesService
which we’ll want to be able to swap with a mocked version for previews and unit tests. In here, let’s convert the gamesService
property into a generic one.
final class DiscoverGamesViewModel<T: GamesServiceType>: ObservableObject {
private let gamesService: T
... ...
init(gamesService: T) {
self.gamesService = gamesService
... ...
}
}
Now we can accept any service which implements the GamesServiceType
protocol. So where do we introduce the concrete type for this generic game service property? Since our preview code lives at the HomeView
level, that’s where we’ll want to pass the concrete type as we’ll see later. To be able to do that, we’ll need to continue using generics on the DiscoverGamesViewModel
’s owner which in this case is DiscoverGamesView
.
struct DiscoverGamesView<T: GamesServiceType>: View {
//...
@StateObject private var viewModel = DiscoverGamesViewModel<T>(gamesService: ????))
//...
}
We mark our viewModel
property type as generic where T
is some GamesServiceType
in order to defer having to give it a concrete type quite yet. However, our DiscoverGamesViewModel
constructor requires an instance of a GameServiceType
. Uh oh. What do we do here? This is a great place for a factory. We can create a GameServiceFactory
which will use our type T
to determine which kind of service to return.
final class GamesServiceFactory {
static func make<T: GamesServiceType>() -> T {
if T.self == GamesService.self {
return GamesService() as! T
} else if T.self == MockGamesService.self {
return MockGamesService() as! T
}
return GamesService() as! T
}
}
Now we can leverage this to provide our DiscoverGamesViewModel
an instance of a service.
struct DiscoverGamesView<T: GamesServiceType>: View {
//...
@StateObject private var viewModel = DiscoverGamesViewModel<T>(gamesService: GamesServiceFactory.make())
//...
}
We can follow this exact same pattern with our GameRow
and it’s ViewModel.
GameRow(game: game, viewModel: GameRowViewModel<T>(gamesService: GamesServiceFactory.make()))
Finally, the last place we’ll need to add our generic type T
is on the HomeView
so that we can help our preview code distinguish itself from our production code.
struct HomeView<T: GamesServiceType>: View {
@State private var selection = TabTag.discover.rawValue
var body: some View {
TabView(selection: $selection) {
DiscoverGamesView<T>()
PlayLaterView()
}.accentColor(Color(.systemIndigo))
}
}
struct HomeView_Previews: PreviewProvider {
static var previews: some View {
HomeView<MockGamesService>()
}
}
Here we can finally see how these generics can help us. On our preview, we tell the HomeView
it’ll leverage a concrete MockGamesService
type for its views which will return mocked data for our games and mocked images for our GameRow
. Back in the SceneDelegate
, we do the same except we give it a GamesService
type instead indicating we’ll be fetching the data from the API when the app is running.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = HomeView<GameService>()
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
}
We did it! Now when we run the preview from the HomeView
we’ll see the two mocked games as well as the static image in our asset catalog all coming from our MockGamesService
, and when the app is run from the simulator, we see all of the games from the API and their respective images as expected. 🎉

Now if we select the “push pin” 📌 icon in the bottom left of the preview pane, we can navigate away to other view code such as DiscoverGamesView
and see our entire app live update as we make changes to the view.

This may seem like it was a lot of heavy lifting just to see a preview of our app, but now there’s a lot we can do with this structure. Previews allow us to test all sorts of variations of configurations and sizes at a glance. We can set up groups of previews that display dark mode, light mode, every dynamic font size, various accessibility settings, and more. Additionally, we can now easily write tests against our view models. Let’s take a look at how we can do that.
Easily Writing Unit Tests
final class MockGamesService: GamesServiceType {
private let returnsErrors: Bool
init(returnsErrors: Bool = false) {
self.returnsErrors = returnsErrors
}
func loadGame(for id: String) -> AnyPublisher<Game, NetworkError> {
return returnsErrors ? .fail(NetworkError.invalidRequest) : .just(Game.example)
}
func loadGames(with filters: [String : String]?) -> AnyPublisher<[Game], NetworkError> {
return returnsErrors ? .fail(.invalidResponse) : .just([Game.example, Game.exampleTwo])
}
func loadImageForGame(_ game: Game) -> AnyPublisher<UIImage?, Never> {
return returnsErrors ? .just(nil) : .just(UIImage(named: "example_game_poster"))
}
}
extension Publisher {
static func empty() -> AnyPublisher<Output, Failure> {
return Empty().eraseToAnyPublisher()
}
static func just(_ output: Output) -> AnyPublisher<Output, Failure> {
return Just(output)
.catch { _ in AnyPublisher<Output, Failure>.empty() }
.eraseToAnyPublisher()
}
static func fail(_ error: Failure) -> AnyPublisher<Output, Failure> {
return Fail(error: error).eraseToAnyPublisher()
}
}
First, we update our MockGamesService
with some convenience functions on Publisher
to remove some of the redundant code. Next, we can introduce an injected toggle to have our service return an error or a success response when load functions are called. This will help us test that our ViewModel responds to the various types of states that our service can return.
func testDiscoverViewModelError() {
let mockService = MockGamesService(returnsErrors: true)
let viewModel = DiscoverGamesViewModel(gamesService: mockService)
viewModel.$hasError.sink { (hasError) in
XCTAssertTrue(hasError)
}.store(in: &cancellables)
viewModel.$games.sink { (games) in
XCTAssert(games.count == 0)
}.store(in: &cancellables)
}
func testDiscoverViewModelGames() {
let mockService = MockGamesService(returnsErrors: false)
let viewModel = DiscoverGamesViewModel(gamesService: mockService)
viewModel.$hasError.sink { (hasError) in
XCTAssertFalse(hasError)
}.store(in: &cancellables)
viewModel.$games.sink { (games) in
XCTAssert(games.count == 2)
}.store(in: &cancellables)
}
In our first test, testDiscoverViewModelError()
, we set our error flag to true and test that our ViewModel has no game data and hasError
is true. In our second test, testDiscoverViewModelGames()
, we test the opposite. These are very simple tests, but they demonstrate how quickly we can establish some unit tests now that our dependencies are injected and genericized.
There are many other ways this could be solved. In this case, one of our goals was to be able to see the entire HomeView
previewed as one piece populated with data from its children including the DiscoverGamesView
and its rows. However, maybe you’re just as satisfied with a preview directly on the DiscoverGamesView
in which case you could solve this similar to how we solved the GameRow
not loading the image. If you’d like to know more about SwiftUI, Combine, customizing previews, or some of the other blog posts that inspired this one, take a look at the links below.
Resources
- Learning SwiftUI:
- Learning Combine:
- More info on customizing previews: https://www.avanderlee.com/swiftui/previews-different-states/
- Inspiration for the Combine service structure: https://medium.com/flawless-app-stories/mvvm-design-pattern-with-combine-framework-on-ios-5ff911011b0b
- Using this API for games: https://api.rawg.io/docs/#operation/games_list