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

About the Author

Object Partners profile.
Leave a Reply

Your email address will not be published.

Related Blog Posts
Natively Compiled Java on Google App Engine
Google App Engine is a platform-as-a-service product that is marketed as a way to get your applications into the cloud without necessarily knowing all of the infrastructure bits and pieces to do so. Google App […]
Building Better Data Visualization Experiences: Part 2 of 2
If you don't have a Ph.D. in data science, the raw data might be difficult to comprehend. This is where data visualization comes in.
Unleashing Feature Flags onto Kafka Consumers
Feature flags are a tool to strategically enable or disable functionality at runtime. They are often used to drive different user experiences but can also be useful in real-time data systems. In this post, we’ll […]
A security model for developers
Software security is more important than ever, but developing secure applications is more confusing than ever. TLS, mTLS, RBAC, SAML, OAUTH, OWASP, GDPR, SASL, RSA, JWT, cookie, attack vector, DDoS, firewall, VPN, security groups, exploit, […]