Reputation: 13887
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:
city
is changed.AnyCancellable
that's looking up weather data.dataSource
by a sink
on the weather fetch Publisher`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
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
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 sink
ed 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