BLACK FRIDAY SALE: Save big on all my Swift books and bundles! >>

SOLVED: How to pass status variables to isolated functions with escaping closures (as Combine does)?

Forums > SwiftUI

Don't know how to explain it better. I'm trying to outline the code, because it's a bunch of lines.

  1. I have a View with a form showing an import button.
  2. I have a ViewModel (according to Paul's approach), that holds basically all of the states as @Published vars that I need, as well as functions to execute.
  3. I have a struct SomeAPI that provides special functions to fetch data.

The API fetchCollection method is a bit complex. It's using Combine, reacting on queued status with retry, etc. It also holds an enum describing the status, such a call is in.

My problem is: I want to show the status of such a call in the view.

struct SomeAPI {
    enum FetchStatus: String {
        case none, started, queued, loaded
    }

    /// Provides a publisher for one specific fetch call
    ///
    /// here: also receive some status to be set inside the chain of combine calls.
    func fetchCollection(_ url: String) -> AnyPublisher<[CollectionItem], Never> {
        return URLSession.shared.dataTaskPublisher(for: url)
            .retry(1)
            .tryMap({
                // reacting on status codes 202, 429, etc. and throwing appropriate errors
            })
            .catch({ (error: Error) -> AnyPublisher<Result<URLSession.DataTaskPublisher.Output, Error>, Error> in
                // reacting on retryable errors, then delaying and failing
                // here I want to set the status, e.g. to .queued
            })
            // some more work, where I want to set status to other intermediate states.
            .eraseToAnyPublisher()
    }
}

extension AddFromCollectionView {
    @MainActor class ViewModel: ObservableObject {
        // declare 2 independent status variables because I have 2 fetches running in parallel.
        @Published var gamesStatus: SomeAPI.FetchStatus = .none
        @Published var expansionsStatus: SomeAPI.FetchStatus = .none
        // holding the merged result
        @Published var collectionItems = [CollectionItem]()

        func importCollection() {
            var fetches = [
                SomeAPI.shared.fetchCollection(url1), // here, also pass gamesStatus
                SomeAPI.shared.fetchCollection(url2) // here, also pass expansionsStatus
            ]

            fetches.publisher.flatMap({ $0 })
                .collect()
                .sink(receiveCompletion: { completion in
                    print(completion)
                }, receiveValue: { values in
                    let allItems = values.joined()
                    self.collectionItems = allItems.sorted { $0.id > $1.id }
                })
                .store(in: &cancellables)
        }
    }
}

The view shows a list of viewModel.collectionItems just fine. Now I want to show a progress bar reflecting the status of the individual requests (started, queued, loaded) because queue means retry with a delay, but I struggle with how to pass the variables to the fetchCollection function.

Binding doesn't work and is wrong anyways since it's a SwiftUI thing and shouldn't be used inside an isolated, view independent struct. I can declare an inout parameter in fetchCollection, but as soon as I set the variable, I'm getting

Escaping closure captures 'inout' parameter 'status'

I read it may be because status is an enum (value type).

So: how can I have a status update reflected in the view, without moving all functions into the view or view model (where I would have direct access to the published vars)?

   

Ok, I "solved" it by passing a CurrentValueSubject.

For if you're interested, and/or maybe have better ideas.

struct SomeAPI {
    enum FetchProgress: Equatable {
        case none, started, queued
        case decoding
        case loaded(Int)
    }

    func fetchCollection(_ url: URL, progress: CurrentValueSubject<FetchProgress, Never>) -> AnyPublisher<[GeekCollectionItem], Never> {
        return URLSession.shared.dataTaskPublisher(for: url)
            .retry(1)
            .tryMap({ // ...
            })
            .catch {
                // ...
                progress.send(.queued)
            }
            // ...
            .map { output -> URLSession.DataTaskPublisher.Output in
                DispatchQueue.main.async {
                    progress.send(.decoding)
                }
                return output
            }
            // ...
            .receive(on: DispatchQueue.main)
            .map { result -> [CollectionItem] in
                progress.send(.loaded(result.count))
                return result
            }
            .eraseToAnyPublisher()
    }
}

extension AddFromCollectionView {
    @MainActor class ViewModel: ObservableObject {
        // declare 2 independent status variables because I have 2 fetches running in parallel.
        @Published var gamesStatus: SomeAPI.FetchProgress = .none
        @Published var expansionsStatus: SomeAPI.FetchProgress = .none
        @Published var gamesProgress = CurrentValueSubject<SomeAPI.FetchProgress, Never>(.none)
        @Published var expansionsProgress = CurrentValueSubject<SomeAPI.FetchProgress, Never>(.none)

        func importCollection() {
            gamesProgress.assign(to: &$gamesStatus)
            gamesProgress.send(.started)
            expansionsProgress.assign(to: &$expansionsStatus)
            expansionsProgress.send(.started)

            var fetches = [
                SomeAPI.shared.fetchCollection(url1, progress: gamesProgress),
                SomeAPI.shared.fetchCollection(url2, progress: expansionsProgress)
            ]
        }
    }
}

Maybe there's even a smarter way that avoids having to declare two properties for each type (status var and subject). Here the subjects probably won't need to be @Published.

   

Hacking with Swift is sponsored by RevenueCat

SPONSORED In-app subscriptions are a pain to implement, hard to test, and full of edge cases. RevenueCat makes it straightforward and reliable so you can get back to building your app. Oh, and it's free if your app makes less than $10k/mo.

Learn more

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.