brettfazio
brettfazio

Reputation: 1156

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.

Upvotes: 3

Views: 870

Answers (2)

brettfazio
brettfazio

Reputation: 1156

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.

Upvotes: 0

Lincoln Anders
Lincoln Anders

Reputation: 91

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.

Upvotes: 1

Related Questions