Using URLSession to load JSON data for SwiftUI Views

Viewed 3749

What are proven approaches for structuring the networking layer of a SwiftUI app? Specifically, how do you structure using URLSession to load JSON data to be displayed in SwiftUI Views and handling all the different states that can occur properly?

2 Answers

Here is what I came up with in my last projects:

  • Represent the loading process as a ObservableObject model class
  • Use URLSession.dataTaskPublisher for loading
  • Using Codable and JSONDecoder to decode the response to Swift types using the Combine support for decoding
  • Keep track of the state in the model as a @Published property so that the view can show loading/error states.
  • Keep track of the loaded results as a @Published property in a separate property for easy usage in SwiftUI (you could also use View#onReceive to subscribe to the publisher directly in SwiftUI but keeping the publisher encapsulated in the model class seemed more clean overall)
  • Use the SwiftUI .onAppear modifier to trigger the loading if not loaded yet.
  • Using the .overlay modifier is convenient to show a Progress/Error view depending on the state
  • Extract reusable components for repeatedly occuring tasks (here is an example: EndpointModel)

Standalone example code for that approach (also available in my SwiftUIPlayground):

// SwiftUIPlayground
// https://github.com/ralfebert/SwiftUIPlayground/

import Combine
import SwiftUI

struct TypiTodo: Codable, Identifiable {
    var id: Int
    var title: String
}

class TodosModel: ObservableObject {

    @Published var todos = [TypiTodo]()
    @Published var state = State.ready

    enum State {
        case ready
        case loading(Cancellable)
        case loaded
        case error(Error)
    }

    let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
    let urlSession = URLSession.shared

    var dataTask: AnyPublisher<[TypiTodo], Error> {
        self.urlSession
            .dataTaskPublisher(for: self.url)
            .map { $0.data }
            .decode(type: [TypiTodo].self, decoder: JSONDecoder())
            .receive(on: RunLoop.main)
            .eraseToAnyPublisher()
    }

    func load() {
        assert(Thread.isMainThread)
        self.state = .loading(self.dataTask.sink(
            receiveCompletion: { completion in
                switch completion {
                case .finished:
                    break
                case let .failure(error):
                    self.state = .error(error)
                }
            },
            receiveValue: { value in
                self.state = .loaded
                self.todos = value
            }
        ))
    }

    func loadIfNeeded() {
        assert(Thread.isMainThread)
        guard case .ready = self.state else { return }
        self.load()
    }
}

struct TodosURLSessionExampleView: View {

    @ObservedObject var model = TodosModel()

    var body: some View {
        List(model.todos) { todo in
            Text(todo.title)
        }
        .overlay(StatusOverlay(model: model))
        .onAppear { self.model.loadIfNeeded() }
    }
}

struct StatusOverlay: View {

    @ObservedObject var model: TodosModel

    var body: some View {
        switch model.state {
        case .ready:
            return AnyView(EmptyView())
        case .loading:
            return AnyView(ActivityIndicatorView(isAnimating: .constant(true), style: .large))
        case .loaded:
            return AnyView(EmptyView())
        case let .error(error):
            return AnyView(
                VStack(spacing: 10) {
                    Text(error.localizedDescription)
                        .frame(maxWidth: 300)
                    Button("Retry") {
                        self.model.load()
                    }
                }
                .padding()
                .background(Color.yellow)
            )
        }
    }

}

struct TodosURLSessionExampleView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            TodosURLSessionExampleView(model: TodosModel())
            TodosURLSessionExampleView(model: self.exampleLoadedModel)
            TodosURLSessionExampleView(model: self.exampleLoadingModel)
            TodosURLSessionExampleView(model: self.exampleErrorModel)
        }
    }

    static var exampleLoadedModel: TodosModel {
        let todosModel = TodosModel()
        todosModel.todos = [TypiTodo(id: 1, title: "Drink water"), TypiTodo(id: 2, title: "Enjoy the sun")]
        todosModel.state = .loaded
        return todosModel
    }

    static var exampleLoadingModel: TodosModel {
        let todosModel = TodosModel()
        todosModel.state = .loading(ExampleCancellable())
        return todosModel
    }

    static var exampleErrorModel: TodosModel {
        let todosModel = TodosModel()
        todosModel.state = .error(ExampleError.exampleError)
        return todosModel
    }

    enum ExampleError: Error {
        case exampleError
    }

    struct ExampleCancellable: Cancellable {
        func cancel() {}
    }

}

Splitting off the state / data / networking into a separate @ObservableObject class outside the View Struct is definitely the way to go. There are too many SwiftUI "Hello World" examples out there stuffing it all into the View struct.

As a best practice you could look to standardize your @ObservableObject naming inline with MVVM and call that "Model" class a ViewModel, as in:

@StateObject var viewModel = TodosViewModel()

The majority of code in there is handling overlay state, onAppear events and display issues for the View.

Create a new TodosModel class and reference that in the ViewModel:

@ObservedObject var model = TodosModel()

Then move all the networking / api / JSON code into that class with one method called by ViewModel:

public func getList() -> AnyPublisher<[TypiTodo], Error>

The View-ViewModel-Model are now split up, related to Paul D's comment, the ViewModel could combine 1 or more Models to return whatever the view needs. And, more importantly, the TodoModel entity knows nothing about the View and can focus on http / JSON / CRUD.

Below is great example using Combine / HTTP / JSON decode. You can see how it uses tryMap, mapError to further separate the networking from the decode errors. https://gist.github.com/stinger/e8b706ab846a098783d68e5c3a4f0ea5

See a very short and clear explanation of the difference between @StateObject and @ObservedObject in this article: https://levelup.gitconnected.com/state-vs-stateobject-vs-observedobject-vs-environmentobject-in-swiftui-81e2913d63f9

Related