Using URLSession to load JSON data for SwiftUI Views - ios

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?

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

Async update in MVVM and animation on state change

I trying to understand how Combine and SwiftUI works in combination with MVVM and clean architecture, but I encountered a problem with using withAnimation once my view model has an async method that updated published value. I was able to solve it, but I'm pretty sure it's not the correct way and I'm missing something fundamental. Here it is how it looks my solution, starting with my data manager:
protocol NameManaging {
var publisher: AnyPublisher<[Name], Never> { get }
func fetchNames() async
}
class MockNameManager: NameManaging {
var publisher: AnyPublisher<[Name], Never> {
names.eraseToAnyPublisher()
}
func fetchNames() async {
var values = await heavyAsyncTask()
names.value.append(contentsOf: values)
}
private func heavyAsyncTask() async -> [Name] {
// do some heavy async task
}
private var names = CurrentValueSubject<[Name], Never>([])
}
Then view models:
class NameListViewModel: ObservableObject {
#Published var names = [Name]()
private var anyCancellable: AnyCancellable?
private var nameManager: NameManaging
init(nameManager: NameManaging = MockNameManager()) {
self.nameManager = nameManager
self.anyCancellable = nameManager.publisher
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] values in
withAnimation {
self?.names = values
}
})
}
func fetchNames() async {
await nameManager.fetchNames()
}
}
Lastly my view:
struct NameList: View {
#StateObject private var nameListViewModel = NameListViewModel()
var body: some View {
VStack {
VStack {
HStack {
Button(action: updateNames) {
Text("Fetch some more names")
}
}
}
.padding()
List {
ForEach(nameListViewModel.names) {
NameRow(name: $0)
}
}
}
.navigationTitle("Names list")
.onAppear(perform: updateNames)
}
func updateNames() {
Task {
await nameListViewModel.fetchNames()
}
}
}
What I did is use withAnimation inside my view model in .sink() method of data manager publisher. This works as expected, but it indroduce view function inside view model. How can I do it in a way that inside updateNames in my view I'll use withAnimation? Or maybe I should do it in completely different way?
You are mixing up technologies. The point of async/await is to remove the need for a state object (i.e. a reference type) and Combine to do async work. You can simply use the .task modifier to call any async func and set the result on an #State var. If the async func throws then you might catch the exception and set a message on another #State var. The great thing about .task is it's called when the UIView (that SwiftUI creates for you) appears and cancelled when it disappears (also if the optional id param changes). So no need for an object, which often is the cause of consistency/memory problems which Swift and SwiftUI's use of value types is designed to eliminate.
struct NameList: View {
#State var var names: [Name] = []
#State var fetchCount = 0
var body: some View {
VStack {
VStack {
HStack {
Button("Fetch some more names") {
fetchCount += 1
}
}
}
.padding()
List {
ForEach(names) { name in
NameRow(name: name)
}
}
}
.navigationTitle("Names list")
.task(id: fetchCount) {
let names = await Name.fetchNames()
withAnimation {
self.names = names
}
}
}
}

Issue passing data from API call in SwiftUI MVVM pattern

been going back and forth for 2 days trying to figure this out before posting and still hitting a wall.
Created an API specific class, a ViewModel, and a View and trying to shuttle data back and forth and while I see the API call is successful and I decode it without issue on logs, it never reflects on the UI or View.
As far as I see I appear to be trying to access the data before it's actually available. All help greatly appreciated!
API Class:
import Combine
import Foundation
class CrunchbaseApi:ObservableObject
{
#Published var companies:[Company] = [Company]()
#Published var singleCompany:Company?
func retrieve(company:String) async
{
let SingleEntityURL:URL = URL(string:"https://api.crunchbase.com/api/v4/entities/organizations/\(company)?card_ids=fields&user_key=**********REMOVED FOR SECURITY*****************")!
let task = URLSession.shared.dataTask(with:SingleEntityURL){ data, response, error in
let decoder = JSONDecoder()
if let data = data{
do {
self.singleCompany = try decoder.decode(Company.self, from: data)
} catch {
print(error.localizedDescription)
}
}
}
task.resume()
}
func retrieveCompanyList()
{
//declare
}
}
ViewModel:
import Combine
import Foundation
class CompanyViewModel: ObservableObject
{
var crunchbase:CrunchbaseApi = CrunchbaseApi()
#Published var singleCompany:Company?
func retrieveCompany(company:String) async
{
await self.crunchbase.retrieve(company: company)
self.singleCompany = crunchbase.singleCompany
}
}
View:
import SwiftUI
struct CompanyView: View
{
#State var companyViewModel:CompanyViewModel = CompanyViewModel()
var body: some View
{
NavigationView
{
VStack
{
Text("Company ID: \(companyViewModel.singleCompany?.id ?? "NOTHING")")
// Text("Company Name: \(companyViewModel.companyName)")
// Text("Company Summary: \(companyViewModel.companyDescription)")
// Text("Logo URL: \(companyViewModel.companyLogoURL)")
}.navigationTitle("Company")
}
}
}
Your assumption about accessing the data to early is correct. But there are more things going on here.
just declaring a function async like your retrieve func doesn´t make it async.
using a nested Observable class with #Published will not update the view
Observable classes should have either an #StateObject or an #ObservableObject property wrapper. Depending on if the class is injected or created in the view
Possible solution:
Move the function into the viewmodel:
class CompanyViewModel: ObservableObject
{
#Published var singleCompany:Company?
func retrieve(company:String)
{
let SingleEntityURL:URL = URL(string:"https://api.crunchbase.com/api/v4/entities/organizations/\(company)?card_ids=fields&user_key=**********REMOVED FOR SECURITY*****************")!
let task = URLSession.shared.dataTask(with:SingleEntityURL){ data, response, error in
let decoder = JSONDecoder()
if let data = data{
do {
self.singleCompany = try decoder.decode(Company.self, from: data)
} catch {
print(error.localizedDescription)
}
}
}
task.resume()
}
}
Change the View to hold the viewmodel as #StateObject, also add an .onApear modifier to load the data:
struct CompanyView: View
{
#StateObject var companyViewModel:CompanyViewModel = CompanyViewModel()
var body: some View
{
NavigationView
{
VStack
{
Text("Company ID: \(companyViewModel.singleCompany?.id ?? "NOTHING")")
// Text("Company Name: \(companyViewModel.companyName)")
// Text("Company Summary: \(companyViewModel.companyDescription)")
// Text("Logo URL: \(companyViewModel.companyLogoURL)")
}.navigationTitle("Company")
.onAppear {
companyViewModel.retrieve(company: "whatever")
}
}
}
}

How to retrieve and show data on screen in SwiftUI?

I am storing simple data in core data which i want to retrieve and show on screen, but I am not sure how and where should I write that code to show it on screen as I am always get out of bound error..
Also not all data is saving at time its only saving when i scroll til bottom
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.title, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
#StateObject private var viewModel = HomeViewModel()
var body: some View {
GeometryReader { geometry in
NavigationView {
ScrollView {
LazyVGrid(columns: Array(repeating: .init(.flexible()),
count: UIDevice.current.userInterfaceIdiom == .pad ? 4 : 2)) {
ForEach(viewModel.results, id: \.self) {
let viewModel = ResultVM(model: $0)
NavigationLink(destination: {
DetailView(data: viewModel.trackName)
}, label: {
SearchResultRow(resultVM: viewModel, coreDM: PersistenceController())
})
}
}
}
}
.onAppear(perform: {
viewModel.performSearch()
})
}
}
}
struct SearchResultRow: View {
let resultVM: ResultVM
let coreDM: PersistenceController
var body: some View {
HStack {
RoundedRectangle(cornerRadius: 16).fill(.yellow)
.frame(maxWidth: .infinity).aspectRatio(1, contentMode: .fit)
.overlay(Text(resultVM.trackName)) // want to show data from Core data here
}.padding()
.background(Color.red)
.onAppear(perform: {
coreDM.saveResult(title: resultVM.trackName)
})
}
}
Data showing from API which same data storing in CoreData
Method to save and retrieve (which is working fine)
func saveResult(title: String) {
let result = Item(context: container.viewContext)
result.title = title
do {
try container.viewContext.save()
}
catch {
print("error")
}
}
func getResult() -> [Item] {
let fetchRequest: NSFetchRequest<Item> = Item.fetchRequest()
do {
return try container.viewContext.fetch(fetchRequest)
}
catch {
return []
}
}
API call
import Foundation
import CoreData
class HomeViewModel: ObservableObject {
#Published var results = [ResultItem]()
func performSearch() {
guard let gUrl = URL(
string: "https://api.artic.edu/api/v1/artworks"
) else { return }
Task {
do {
let (data, _) = try await URLSession.shared.data(from: gUrl)
let response = try JSONDecoder()
.decode(ResponseData.self, from: data)
DispatchQueue.main.async { [weak self] in
self?.results = response.data ?? []
}
} catch {
print("*** ERROR ***")
}
}
}
}
Remove the view model we don't use view model objects in SwiftUI. The View struct stores the view data and does dependency tracking, and the property wrappers give it external change tracking features like an object. If you add an actual object on top you'll get consistency bugs that SwiftUI was designed to eliminate. So first remove this:
#StateObject private var viewModel = HomeViewModel()
You already have the fetch request property wrapper and as I said that gives the View change tracking, so when the items change (e.g. an item is added, removed or moved), the body will be called, so simply use it in your ForEach like this:
ForEach(items) { item in
NavigationLink(destination: {
DetailView(item: item)
}, label: {
SearchResultRow(item: item)
})
}
Then use #ObservedObject in your subview, this gives the View the ability to track changes to the item so body will be called when the item's properties change, like item.title. Do the same in the DetailView.
struct SearchResultRow: View {
#ObservedObject var item: Item
It seems to me you are trying to download and cache data in Core Data. That is unnecessary since you can simply set a NSURLCache on the NSURLSession and it will automatically cache it for you, it even works if offline. However if you really want to cache it yourself in Core Data then the architecture should be your UI fetches it from Core Data and then you have another object somewhere responsible for syncing down the remote data into Core Data. Normally this would be at the App struct level not in the View appearing. When the data is saved to core data the managed object context context will be changed which the #FetchRequest is listening for and body will be called.

Build and update lists automatically with SwiftUI and Combine

I'm starting to learn SwiftUI development, I'm making my first basic SwiftUI based news application which I plan on open sourcing but I'm currently stuck. I've been reading Apple's documentation and looking at examples on how to automatically handle data changes in SwiftUI using combine etc. I've found an article, that's suppose to automatically update the list. I haven't been able to see any immediate data changes or anything being logged.
I'm using the same structure as NewsAPI but as an example I've uploaded it to a GitHub repo. I've made a small project and tried updating the data in my repo and trying to see any changes made in my data. I'm honestly trying my best and could really use some pointers or corrections in what my errors may be. I think my confusion lies in #ObservedObject and #Published and how to handle any changes in my content view. The article doesn't show anything they did to handle data changes so maybe I'm missing something?
import Foundation
import Combine
struct News : Codable {
var articles : [Article]
}
struct Article : Codable,Hashable {
let description : String?
let title : String?
let author: String?
let source: Source
let content: String?
let publishedAt: String?
}
struct Source: Codable,Hashable {
let name: String?
}
class NewsData: ObservableObject {
#Published var news: News = News(articles: [])
init() {
guard let url = URL(string: "https://raw.githubusercontent.com/ca13ra1/data/main/data.json") else { return }
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
if let response = try? JSONDecoder().decode(News.self, from: data) {
DispatchQueue.main.async() {
self.news = response
print("data called")
}
}
}
}
.resume()
}
}
My View
import SwiftUI
import Combine
struct ContentView: View {
#ObservedObject var data: NewsData
var body: some View {
List(data.news.articles , id: \.self) { article in
Text(article.title ?? "")
}
}
}
The data binding in SwiftUI does no extend to synching state with the server. If thats what you want then you need to use some other mechanism to tell the client that there is new data in the server (ie GraphQL, Firebase, send a push, use a web socket, or poll the server).
A simple polling solution would look like this and note you should not be doing network requests in init, only when you get an on appear from the view because SwiftUI eager hydrates its views even when you cannot see them. Similarly you need to cancel polling when you are off screen:
struct Article: Codable, Identifiable {
var id: String
}
final class ViewModel: ObservableObject {
#Published private(set) var articles: [Article] = []
private let refreshSubject = PassthroughSubject<Void, Never>()
private var timerSubscription: AnyCancellable?
init() {
refreshSubject
.map {
URLSession
.shared
.dataTaskPublisher(for: URL(string: "someURL")!)
.map(\.data)
.decode(type: [Article].self, decoder: JSONDecoder())
.replaceError(with: [])
}
.switchToLatest()
.receive(on: DispatchQueue.main)
.assign(to: &$articles)
}
func refresh() {
refreshSubject.send()
guard timerSubscription == nil else { return }
timerSubscription = Timer
.publish(every: 5, on: .main, in: .common)
.autoconnect()
.sink(receiveValue: { [refreshSubject] _ in
refreshSubject.send()
})
}
func onDisappear() {
timerSubscription = nil
}
}
struct MyView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
List(viewModel.articles) { article in
Text(article.id)
}
.onAppear { viewModel.refresh() }
.onDisappear { viewModel.onDisappear() }
}
}
I've found a nifty swift package which allows me to easily repeat network calls. It's called swift-request. Thanks to #pawello2222 for helping me solve my dilemma.
import Request
class NewsData: ObservableObject {
#Published var news: News = News(articles: [])
init() {
test()
}
func test() {
AnyRequest<News> {
Url("https://raw.githubusercontent.com/ca13ra1/data/main/data.json")
}
.onObject { data in
DispatchQueue.main.async() {
self.news = data
}
}
.update(every: 300)
.update(publisher: Timer.publish(every: 300, on: .main, in: .common).autoconnect())
.call()
}
}
It's now working as expected, probably the easier option.
Demo:

Best data-binding practice in Combine + SwiftUI?

In RxSwift it's pretty easy to bind a Driver or an Observable in a View Model to some observer in a ViewController (i.e. a UILabel).
I usually prefer to build a pipeline, with observables created from other observables, instead of "imperatively" pushing values, say via a PublishSubject).
Let's use this example: update a UILabel after fetching some data from the network
RxSwift + RxCocoa example
final class RxViewModel {
private var dataObservable: Observable<Data>
let stringDriver: Driver<String>
init() {
let request = URLRequest(url: URL(string:"https://www.google.com")!)
self.dataObservable = URLSession.shared
.rx.data(request: request).asObservable()
self.stringDriver = dataObservable
.asDriver(onErrorJustReturn: Data())
.map { _ in return "Network data received!" }
}
}
final class RxViewController: UIViewController {
private let disposeBag = DisposeBag()
let rxViewModel = RxViewModel()
#IBOutlet weak var rxLabel: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
rxViewModel.stringDriver.drive(rxLabel.rx.text).disposed(by: disposeBag)
}
}
Combine + UIKit example
In a UIKit-based project it seems like you can keep the same pattern:
view model exposes publishers
view controller binds its UI elements to those publishers
final class CombineViewModel: ObservableObject {
private var dataPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>
var stringPublisher: AnyPublisher<String, Never>
init() {
self.dataPublisher = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
.eraseToAnyPublisher()
self.stringPublisher = dataPublisher
.map { (_, _) in return "Network data received!" }
.replaceError(with: "Oh no, error!")
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
final class CombineViewController: UIViewController {
private var cancellableBag = Set<AnyCancellable>()
let combineViewModel = CombineViewModel()
#IBOutlet weak var label: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
combineViewModel.stringPublisher
.flatMap { Just($0) }
.assign(to: \.text, on: self.label)
.store(in: &cancellableBag)
}
}
What about SwiftUI?
SwiftUI relies on property wrappers like #Published and protocols like ObservableObject, ObservedObject to automagically take care of bindings (As of Xcode 11b7).
Since (AFAIK) property wrappers cannot be "created on the fly", there's no way you can re-create the example above using to the same pattern.
The following does not compile
final class WrongViewModel: ObservableObject {
private var dataPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>
#Published var stringValue: String
init() {
self.dataPublisher = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
.eraseToAnyPublisher()
self.stringValue = dataPublisher.map { ... }. ??? <--- WRONG!
}
}
The closest I could come up with is subscribing in your view model (UGH!) and imperatively update your property, which does not feel right and reactive at all.
final class SwiftUIViewModel: ObservableObject {
private var cancellableBag = Set<AnyCancellable>()
private var dataPublisher: AnyPublisher<URLSession.DataTaskPublisher.Output, URLSession.DataTaskPublisher.Failure>
#Published var stringValue: String = ""
init() {
self.dataPublisher = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
.eraseToAnyPublisher()
dataPublisher
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: {_ in }) { (_, _) in
self.stringValue = "Network data received!"
}.store(in: &cancellableBag)
}
}
struct ContentView: View {
#ObservedObject var viewModel = SwiftUIViewModel()
var body: some View {
Text(viewModel.stringValue)
}
}
Is the "old way of doing bindings" to be forgotten and replaced, in this new UIViewController-less world?
An elegant way I found is to replace the error on the publisher with Never and to then use assign (assign only works if Failure == Never).
In your case...
dataPublisher
.receive(on: DispatchQueue.main)
.map { _ in "Data received" } //for the sake of the demo
.replaceError(with: "An error occurred") //this sets Failure to Never
.assign(to: \.stringValue, on: self)
.store(in: &cancellableBag)
I think the missing piece here is that you are forgetting that your SwiftUI code is functional. In the MVVM paradigm, we split the functional part into the view model and keep the side effects in the view controller. With SwiftUI, the side effects are pushed even higher into the UI engine itself.
I haven't messed much with SwiftUI yet so I can't say I understand all the ramifications yet, but unlike UIKit, SwiftUI code doesn't directly manipulate screen objects, instead it creates a structure that will do the manipulation when passed to the UI engine.
After posting previous answer read this article: https://nalexn.github.io/swiftui-observableobject/
and decide to do same way. Use #State and don't use #Published
General ViewModel protocol:
protocol ViewModelProtocol {
associatedtype Output
associatedtype Input
func bind(_ input: Input) -> Output
}
ViewModel class:
final class SwiftUIViewModel: ViewModelProtocol {
struct Output {
var dataPublisher: AnyPublisher<String, Never>
}
typealias Input = Void
func bind(_ input: Void) -> Output {
let dataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
.map{ "Just for testing - \($0)"}
.replaceError(with: "An error occurred")
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
return Output(dataPublisher: dataPublisher)
}
}
SwiftUI View:
struct ContentView: View {
#State private var dataPublisher: String = "ggg"
let viewModel: SwiftUIViewModel
let output: SwiftUIViewModel.Output
init(viewModel: SwiftUIViewModel) {
self.viewModel = viewModel
self.output = viewModel.bind(())
}
var body: some View {
VStack {
Text(self.dataPublisher)
}
.onReceive(output.dataPublisher) { value in
self.dataPublisher = value
}
}
}
I ended up with some compromise. Using #Published in viewModel but subscribing in SwiftUI View.
Something like this:
final class SwiftUIViewModel: ObservableObject {
struct Output {
var dataPublisher: AnyPublisher<String, Never>
}
#Published var dataPublisher : String = "ggg"
func bind() -> Output {
let dataPublisher = URLSession.shared.dataTaskPublisher(for: URL(string: "https://www.google.it")!)
.map{ "Just for testing - \($0)"}
.replaceError(with: "An error occurred")
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
return Output(dataPublisher: dataPublisher)
}
}
and SwiftUI:
struct ContentView: View {
private var cancellableBag = Set<AnyCancellable>()
#ObservedObject var viewModel: SwiftUIViewModel
init(viewModel: SwiftUIViewModel) {
self.viewModel = viewModel
let bindStruct = viewModel.bind()
bindStruct.dataPublisher
.assign(to: \.dataPublisher, on: viewModel)
.store(in: &cancellableBag)
}
var body: some View {
VStack {
Text(self.viewModel.dataPublisher)
}
}
}
You can also extend CurrentValueSubject to expose a Binding as demonstrated in this Gist. Namely thus:
extension CurrentValueSubject {
var binding: Binding<Output> {
Binding(get: {
self.value
}, set: {
self.send($0)
})
}
}

Resources