Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Using URLSession to load JSON data for SwiftUI Views

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?

like image 684
Ralf Ebert Avatar asked May 17 '20 17:05

Ralf Ebert


People also ask

What is URLSession shared?

For basic requests, the URLSession class provides a shared singleton session object that gives you a reasonable default behavior for creating tasks. Use the shared session to fetch the contents of a URL to memory with just a few lines of code.


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() {}
    }

}
like image 130
Ralf Ebert Avatar answered Oct 10 '22 20:10

Ralf Ebert


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

like image 28
Dabble Avatar answered Oct 10 '22 21:10

Dabble