#Published variable doesn't reload my view after API call? - swift

I believe that I have set up my view and view model correctly. I have also confirmed that the network request returns data (via the console). I am confused on why my published property isn't updating my view with the fetched data.
Here is my view model:
class ProductViewModel: ObservableObject {
var didChange = PassthroughSubject<ProductViewModel, Never>()
#Published var mensProducts = [StripeProduct]()
init() {
}
func getMenItems() {
// hit the URL and
guard let url = URL(string: "http://127.0.0.1:3000/bags") else {
return
}
URLSession.shared.dataTask(with: url) { (data, response, err) in
// return the data asynchronously so that the call doesn't have to complete before loading the UI
DispatchQueue.main.async {
print("Decode data")
self.mensProducts = try! JSONDecoder().decode([StripeProduct].self, from: data!)
}
}
.resume()
}
}
Here is my view:
struct MenProducts: View {
#ObservedObject var productVM = ProductViewModel()
var body: some View {
GeometryReader { geometry in
ScrollView {
VStack {
ForEach(self.productVM.mensProducts) { item in
ProductView(productID: item.productID, photo: "menMerch", price: item.price, name: item.productName, height: geometry.size.height/2, width: geometry.size.width)
}
}
}
}
.onAppear(perform: self.productVM.getMenItems)
}
}

First, I advise you to move your getMenItems() call to the init() method of ProductViewModel.
Then, you can remove the .onAppear(perform: self.productVM.getMenItems) in your MenProducts view and mark the method private in the ProductViewModel as no outside class/struct will be calling it.
I would also recommend you to not explicitly call the background queue with DispatchQueue.main.async as URLSession data task operations are asynchronous already.
You can read more about JSON decoding to Models in this great article: https://www.avanderlee.com/swift/json-parsing-decoding/

Related

How to resolve the publishing changes from background threads is not allowed

I have been trying to create an application in swiftUI. Although my application is running properly but as soon as I run it in the emulator I am getting a message in my console stating that :-
2022-10-21 17:18:40.571733+0530 H4XOR News[87575:1413313] [SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
Here's how my code looks like.
NetworkManager.swift:-
import Foundation
class NetworkManager: ObservableObject {
#Published var posts = [Post]()
func fetchData() {
if let url = URL(string: "https://hn.algolia.com/api/v1/search?tags=front_page") {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) { (data, response, error) in
if error == nil {
let decoder = JSONDecoder()
if let safeData = data {
do {
let results = try decoder.decode(Results.self, from: safeData)
self.posts = results.hits
} catch {
print(error)
}
}
}
}
task.resume()
}
}
}
PostData.swift
import Foundation
struct Results: Decodable {
let hits: [Post]
}
struct Post: Decodable, Identifiable {
var id: String {
return objectID
}
let objectID: String
let points: Int
let title: String
let url: String?
}
ContentView.swift
import SwiftUI
struct ContentView: View {
#ObservedObject var networkManager = NetworkManager()
var body: some View {
NavigationView {
List(networkManager.posts) { post in
Text(post.title)
}
.navigationBarTitle("H4XOR NEWS")
}
.onAppear {
self.networkManager.fetchData()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
}
Can anyone tell me why am I getting such message and how to resolve it.
As a side-effect of assigning your posts property in self.posts = results.hits, the NetworkManager is sending an event on its objectWillChange publisher (which is auto-generated as part of the ObservableObject protocol). This happens on the same thread as the caller, and SwiftUI only wants this to happen on the main thread.
The completion handler of the data task is called on a background thread. And since you assign to posts on this thread, SwiftUI complains.
Simply dispatch to the main queue before assigning to this property:
DispatchQueue.main.async {
self.posts = results.hits
}

How to make an additional API call once inside a detail view?

I'm fetching books from an endpoint as such:
class APIManager: ObservableObject {
#Published var books = [Book]()
func fetchBooks() {
if let url = URL(string: urlEndpoint) {
let session = URLSession(configuration: .default)
let task = session.dataTask(with: url) { (data, response, error) in
if error == nil {
if let safeData = data {
do {
let response = try JSONDecoder().decode([Book].self, from: safeData)
DispatchQueue.main.async {
self.books = response
}
} catch {
print(error)
}
}
}
}
task.resume()
}
}
}
My BookView looks like this:
struct BookView: View {
#ObservedObject var apiManager = APIManager()
var body: some View {
NavigationView {
List {
ForEach(apiManager.books) { book in
NavigationLink(
destination: BookDetailView(id: book.id, chapter: book.chapters),
label: {
HStack {
Text(book.name)
Spacer()
Text(book.testament)
}
})
}.navigationBarTitle("Book Title Here")
}.onAppear {
self.apiManager.fetchBooks()
}
}
}
}
When navigating to BookDetailView - I need to make another API call to fetch additional details about the book (such as chapters), given the book id that is passed here:
...
destination: BookDetailView(id: book.id, chapter: book.chapters)
...
Do I simply repeat the process and make another function in my APIManager class and add another #Published var chapters = [Chapter]()
And inside BookDetailView go
// Loop through each chapter here
// I want to display chapter details in this view
Text("You are viewing book id \(id). Chapter: \(chapter)").onAppear {
self.apiManager.fetchChapterDetails()
}
Doing so returns UIScrollView does not support multiple observers implementing
Whats the procedure here?
I would suggest making a separate ViewModel for the Detail Page. In that ViewModel you can pass in the id of the Book and make a separate API function. Also think about extracting the API Calls into a Service class, which being called from the ViewModel.
The Detail View and ViewModel could look like that...
class BookDetailViewModel: ObservableObject {
let bookId: Int
init(withBookId bookId: Int) {
self.bookId = bookId
}
func fetchBookInfos() {
// ...
}
}
struct BookDetailView: View {
#ObservedObject var viewModel: BookDetailViewModel
var body: some View {
NavigationView {
List {
Text("Book id \(viewModel.bookId)")
}.onAppear {
self.viewModel.fetchBookInfos()
}
}
}
}
When creating the Detail View, pass in the ViewModel.

Firestore method in view model not invoking in SwiftUI onAppear method

I want to implement a Text field that displays the current user's existing score in the DB (Firestore). Because of the nature of async in Firebase query, I also need to do some adjustment in my codes. However, it seems that completion() handler does not work well:
// ViewModel.swift
import Foundation
import Firebase
import FirebaseFirestore
class UserViewModel: ObservableObject {
let current_user_id = Auth.auth().currentUser!.uid
private var db = Firestore.firestore()
#Published var xp:Int?
func fetchData(completion: #escaping () -> Void) {
let docRef = db.collection("users").document(current_user_id)
docRef.getDocument { snapshot, error in
print(error ?? "No error.")
self.xp = 0
guard let snapshot = snapshot else {
completion()
return
}
self.xp = (snapshot.data()!["xp"] as! Int)
completion()
}
}
}
// View.swift
import SwiftUI
import CoreData
import Firebase
{
#ObservedObject private var users = UserViewModel()
var body: some View {
VStack {
HStack {
// ...
Text("xp: \(users.xp ?? 0)")
// Text("xp: 1500")
.fontWeight(.bold)
.padding(.horizontal)
.foregroundColor(Color.white)
.background(Color("Black"))
.clipShape(CustomCorner(corners: [.bottomLeft, .bottomRight, .topRight, .topLeft], size: 3))
.padding(.trailing)
}
.padding(.top)
.onAppear() {
self.users.fetchData()
}
// ...
}
}
My result kept showing 0 in Text("xp: \(users.xp ?? 0)"), which represents that the step is yet to be async'ed. So what can I do to resolve it?
I would first check to make sure the data is valid in the Firestore console before debugging further. That said, you can do away with the completion handler if you're using observable objects and you should unwrap the data safely. Errors can always happen over network calls so always safely unwrap anything that comes across them. Also, make use of the idiomatic get() method in the Firestore API, it makes code easier to read.
That also said, the problem is your call to fetch data manually in the horizontal stack's onAppear method. This pattern can produce unsavory results in SwiftUI, so simply remove the call to manually fetch data in the view and perform it automatically in the view model's initializer.
class UserViewModel: ObservableObject {
#Published var xp: Int?
init() {
guard let uid = Auth.auth().currentUser?.uid else {
return
}
let docRef = Firestore.firestore().collection("users").document(uid)
docRef.getDocument { (snapshot, error) in
if let doc = snapshot,
let xp = doc.get("xp") as? Int {
self.xp = xp
} else if let error = error {
print(error)
}
}
}
}
struct ContentView: View {
#ObservedObject var users = UserViewModel()
var body: some View {
VStack {
HStack {
Text("xp: \(users.xp ?? 0)")
}
}
}
}
SwiftUI View - viewDidLoad()? is the problem you ultimately want to solve.

How to using the fetched data in swiftUI

I am currently working on an IOS app, I am trying to design a carousel with the fetched json data. This is how I get the targeted json data.
class loadDate: ObservableObject {
#Published var todos = [Result]()
init() {
let url = URL(string: "https://api.themoviedb.org/3/movie/now_playing?api_key=<api_key>&language=en-US&page=1")!
URLSession.shared.dataTask(with: url) { data, response, error in
// step 4
if let data = data {
if let decodedResponse = try? JSONDecoder().decode(Response.self, from: data) {
DispatchQueue.main.async {
self.todos = decodedResponse.results
}
// everything is good, so we can exit
return
}
}
print("Fetch failed: \(error?.localizedDescription ?? "Unknown error")")
}.resume()
}
}
It looks like I can only use the data in a list view, for example,
struct ContentView: View {
#ObservedObject var fetch = loadDate()
var body: some View {
HStack {
List(fetch.todos) { item in
HStack {
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.poster_path)
//
}
}
}
}
}
}
Within this list view, I can access all elements like using fetch.todos[0].title. However, if outside the list view, fetch.todos[0].title would fail, can someone give some advice on how to use the data outside the list view .
You should provide a working example. (https://stackoverflow.com/help/minimal-reproducible-example)
But if todos is an empty array, fetch.todos[0].title will always fail. So whenever you need to access your todos directly you can use something like this
Group {
if fetch.todos.count > 0 {
Text("Title of first todo \(fetch.todos[0].title)")
} else {
Text("Todos are empty")
}
}
Try this:
class loadDate: ObservableObject {
#Published var todos : Result? = nil // Step 1
// ...
}
struct ContentView: View {
#ObservedObject var fetch = loadDate()
var body: some View {
HStack {
if let todos = fetch.todos {
VStack{
Text(todos.title)
.font(.headline)
Text(todos.poster_path)
}
}
}
}
}
https://stackoverflow.com/a/66695683/14287797

SwiftUI identifiable ForEach doesn't update when initially starting with empty

I have a SwiftUI app that fetches some information from the backend when the view appears and then attempts to update the State by setting #Published vars in an ObservableObject. The problem I have is it doesn't update at first fetch (it remains empty since it was initialized with an empty array) but if I click to another view and come back it's updated (since the information was already fetched).
Obviously, the intended thing I'm going for with using #Published is for the view to update once the information is fetched. This is part of a larger app but I have the reduced version of what I have below.
First, we have a parent view that contains the view I want to update.
struct ParentView: View {
var body: some View {
NavigationView {
ScrollView {
VStack {
SummaryView()
// In real life I have various forms of summary
// but to simplify here I will just use this one SummaryView.
SummaryView()
SummaryView()
}
}
}
}
}
Here is the summary view itself:
struct SummaryView: View {
#ObservedObject var model = AccountsSummaryViewModel()
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("Accounts")
.font(.title)
Spacer()
NavigationLink(
destination: AccountView(),
label: {
Image("RightArrow")
})
}
if model.accounts.count > 0 {
Divider()
}
// And if I add the following line for debugging
//Text(model.accounts.count)
// It remains 0.
ForEach(model.accounts, id: \.id) { account in
Text(account.account.text)
}
}
.padding()
.onAppear() {
model.onAppear()
}
}
}
Here is it's simple view model:
class AccountsSummaryViewModel: ObservableObject, Identifiable {
#Published var accounts: [AccountIdentifiable] = []
func onAppear() {
AccountsService.accounts { (success, error, response) in
DispatchQueue.main.async {
// This always succeeds
if let response = response {
// All AccountIdentifiable does is make a struct that is Identifiable (has an account and a var id = UUID())
self.accounts = Array(response.accounts.map { AccountIdentifiable(account: $0) }.prefix(3))
}
}
}
}
}
Here is the contents of the AccountsService also, I will note that the URL is a localhost but I'm not sure if that matters:
public struct AccountsService {
public static func accounts(completion: #escaping ((Bool, Error?, AccountsResponse?) -> Void)) {
guard let url = getAllAccountsURL() else {
completion(false, nil, nil)
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.allHTTPHeaderFields = ["Content-Type": "application/json",
BusinessConstants.SET_COOKIE : CredentialsObject.shared.jwt]
let task = URLSession.shared.dataTask(with: request) { (data, urlResponse, error) in
guard let data = data else {
completion(false, error, nil)
return
}
guard let response = try? JSONDecoder().decode(AccountsResponse.self, from: data) else {
completion(false, error, nil)
return
}
// This does successfully decode and return here.
completion(true, nil, response)
return
}
task.resume()
}
private static func getAllAccountsURL() -> URL? {
let address = "\(BusinessConstants.SERVER)/plaid/accounts"
return URL(string: address)
}
}
I have read that there are issues with an empty ScrollView, however, my ScrollView is never empty as I have those static text elements. I also read that if you use a ForEach without the id it can fail - but you can see I am using the id so I'm kind of at a loss.
I have print statements in the onAppear() so I know it runs and successfully sets the #Published accounts but looking at the UI and putting breakpoints in the ForEach I can see the view does not update. However, if I navigate somewhere else in my app, and then come back to the ParentView then since the #Published accounts is non-empty (already fetched) it updates perfectly.
It looks like you're running into a problem because of the two levels of observed objects, with model: AccountsSummaryViewModel containing accounts: [AccountIdentifiable].
SwiftUI will only watch one level, leading to your ParentView not updating when accounts is set more than one UI level down.
As discussed here, one option is to use PublishedObject via the Swift Package Manager in Xcode. Changing model in your SummaryView to #PublishedObject may be all that's required to fix this.
The reason it was not working was due to the fact that I was using #ObservedObject instead of #StateObject in SummaryView. Making the change fixed the issue.