Benjohn
Benjohn

Reputation: 13887

Simpler ViewModel implementation

I'm looking at an example of using SwiftUI with Combine: MVVM with Combine Tutorial for iOS at raywenderlich.com. A ViewModel implementation is given like this:

class WeeklyWeatherViewModel: ObservableObject, Identifiable {
  // 2
  @Published var city: String = ""

  // 3
  @Published var dataSource: [DailyWeatherRowViewModel] = []

  private let weatherFetcher: WeatherFetchable

  // 4
  private var disposables = Set<AnyCancellable>()

  init(weatherFetcher: WeatherFetchable) {
    self.weatherFetcher = weatherFetcher
  }
}

So, this makes some sense to me. In a view observing the model, an instance of the ViewModel is declared as an ObservedObject like this:

@ObservedObject var viewModel: WeeklyWeatherViewModel

And then it's possible to make use of the @Published properties in the model in the body definition of the View like this:

TextField("e.g. Cupertino", text: $viewModel.city)

In WeeklyWeatherViewModel Combine is used to take the city text, make a request on it, and turn this in to [DailyWeatherRowViewModel]. Up to here, everything is rosey and makes sense.

Where I become confused is that quite a lot of code is then used to:

It looks like this:

  // More in WeeklyWeatherViewModel
init(
  weatherFetcher: WeatherFetchable,
  scheduler: DispatchQueue = DispatchQueue(label: "WeatherViewModel")
) {
  self.weatherFetcher = weatherFetcher

  _ = $city
    .dropFirst(1)
    .debounce(for: .seconds(0.5), scheduler: scheduler)
    .sink(receiveValue: fetchWeather(forCity:))
}

func fetchWeather(forCity city: String) {
  weatherFetcher.weeklyWeatherForecast(forCity: city)
    .map { response in
      response.list.map(DailyWeatherRowViewModel.init)
    }
    .map(Array.removeDuplicates)
    .receive(on: DispatchQueue.main)
    .sink(
      receiveCompletion: { [weak self] value in
        guard let self = self else { return }
        switch value {
        case .failure:
          self.dataSource = []
        case .finished:
          break
        }
      },
      receiveValue: { [weak self] forecast in
        guard let self = self else { return }
        self.dataSource = forecast
    })
   .store(in: &disposables)
}

If I look in Combine for the definition of the @Published propertyWrapper, it seems like all does is provide projectedValue which is a Publisher, which makes it seem like it ought to be possible for WeeklyWeatherViewModel to simply provide the Publisher fetching weather data and for the view to make use of this directly. I don't see why the copying in to a dataSource is necessary.

Basically, what I'm expecting is there to be a way for SwiftUI to directly make use of a Publisher, and for me to be able to put that publisher externally from a View implementation so that I can inject it. But I've no idea what it is.

If this doesn't seem to make any sense, that figures, as I'm confused. Please let me know and I'll see if I can refine my explanation. Thanks!

Upvotes: 2

Views: 443

Answers (2)

Jim lai
Jim lai

Reputation: 1409

You can use URLSessionDataTaskPublisher and refactor out networking from all view models.

If you feel some part of the tutorial seems redundant, that is because it is.

MVVM in such usage is redundant and does not do the job better.

I have a refactored version (networking refactored, all view models removed) of that tutorial if you are interested in details.

Upvotes: 1

Benjohn
Benjohn

Reputation: 13887

I don't have a definitive answer to this and I didn't find a magic way to have SwiftUI make use of a Publisher directly – it's entirely possible that there is one that eludes me!

I have found a reasonably compact and flexible approach to achieving the desired result, though. It cut down the use of sink to a single occurrence that attaches to the input (@Published city in the original code), which substantially simplifies the cancelation work.


Here's a fairly generic model that has an @Published input attribute and a @Published output attribute (for which setting is private). It takes a transform as input, and this is used to transform the input publisher, and is then sinked in to the output publisher. The Cancelable of the sink is stored.

final class ObservablePublisher<Input, Output>: ObservableObject, Identifiable {

    init(
        initialInput: Input,
        initialOutput: Output,
        publisherTransform: @escaping (AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never>)
    {
        input = initialInput
        output = initialOutput

        sinkCancelable =
            publisherTransform($input.eraseToAnyPublisher())
            .receive(on: DispatchQueue.main)
            .sink(receiveValue: { self.output = $0 })
    }


    @Published var input: Input
    @Published private(set) var output: Output

    private var sinkCancelable: AnyCancellable? = nil
}

If you wanted a substantially less generic kind of model, you can see it's pretty easy to set up having the input (which is a publisher) be filtered in to the output.

In a view, you might declare an instance of the model and use it like this:

struct SimpleView: View {

    @ObservedObject var model: ObservablePublisher<String, String>

    var body: some View {
        List {
            Section {
                // Here's the input to the model taken froma text field.
                TextField("Give me some input", text: $model.input)
            }

            Section {
                // Here's the models output which the model is getting from a passed Publisher.
                Text(model.output)
            }
        }
        .listStyle(GroupedListStyle())
    }
}

And here's some silly setup of the view and its model taken from a "SceneDelegate.swift". The model just delays whatever is typed in for a bit.

let model = ObservablePublisher(initialInput: "Moo moo", initialOutput: []) { textPublisher in
    return textPublisher
        .delay(for: 1, scheduler: DispatchQueue.global())
        .eraseToAnyPublisher()
}

let rootView = NavigationView {
    AlbumSearchView(model: model)
}

I made the model generic on the Input and Output. I don't know if this will actually be useful in practice, but it seems like it might be.

I'm really new to this, and there might be some terrible flaws in this such as inefficiencies, memory leaks or retain cycles, race conditions, etc. I've not found them yet, though.

Upvotes: 1

Related Questions