Home > Software design >  SwiftUI: @State value doesn't update after async network request
SwiftUI: @State value doesn't update after async network request

Time:08-14

My aim is the change data in DetailView(). Normally in this case I'm using @State @Binding and it's works fine with static data, but when I trying to update ViewModel with data from network request I'm loosing functionality of @State (new data doesn't passing to @State value it's stays empty). I checked network request and decoding process - everything ok with it. Sorry my code example a bit long but it's the shortest way that I found to recreate the problem...

Models:

    struct LeagueResponse: Decodable {
        var status: Bool?
        var data: [League] = []
    }
    
    struct League: Codable, Identifiable {
        let id: String
        let name: String
        var seasons: [Season]?
        
    }
    
    struct SeasonResponse: Codable {
        var status: Bool?
        var data: LeagueData?
    }
    
    struct LeagueData: Codable {
        let name: String?
        let desc: String
        let abbreviation: String?
        let seasons: [Season]
    }
    
    struct Season: Codable {
        let year: Int
        let displayName: String
        
    }

ViewModel:

class LeagueViewModel: ObservableObject {
    @Published var leagues: [League] = []
    
    init() {
        Task {
            try await getLeagueData()
        }
    }
    
    private func getLeagueData() async throws {
        let (data, _) = try await URLSession.shared.data(from: URL(string: "https://api-football-standings.azharimm.site/leagues")!)
        guard let leagues = try? JSONDecoder().decode(LeagueResponse.self, from: data) else {
            throw URLError(.cannotParseResponse)
        }
        await MainActor.run {
            self.leagues = leagues.data
        }
    }
    
    func loadSeasons(forLeague id: String) async throws {
        let (data, _) = try await URLSession.shared.data(from: URL(string: "https://api-football-standings.azharimm.site/leagues/\(id)/seasons")!)
        guard let seasons = try? JSONDecoder().decode(SeasonResponse.self, from: data) else {
            throw URLError(.cannotParseResponse)
        }
        await MainActor.run {
            if let responsedLeagueIndex = leagues.firstIndex(where: { $0.id == id }),
               let unwrappedSeasons = seasons.data?.seasons {
                leagues[responsedLeagueIndex].seasons = unwrappedSeasons
                print(unwrappedSeasons) // successfully getting and parsing data
            }
        }
    }
}

Views:

struct ContentView: View {
    
    @StateObject var vm = LeagueViewModel()
    
    var body: some View {
        NavigationView {
            VStack {
                if vm.leagues.isEmpty {
                    ProgressView()
                } else {
                    List {
                        ForEach(vm.leagues) { league in
                            NavigationLink(destination: DetailView(league: league)) {
                                Text(league.name)
                            }
                        }
                    }
                }
            }
            .navigationBarTitle(Text("Leagues"), displayMode: .large)
        }
        .environmentObject(vm)
    }
}

struct DetailView: View {

    @EnvironmentObject var vm: LeagueViewModel
    @State var league: League
    
    var body: some View {
        VStack {
            if let unwrappedSeasons = league.seasons {
                List {
                    ForEach(unwrappedSeasons, id: \.year) { season in
                        Text(season.displayName)
                    }
                }
            } else {
                ProgressView()
            }
        }
        .onAppear {
            Task {
                try await vm.loadSeasons(forLeague: league.id)
            }
        }
        .navigationBarTitle(Text("League Detail"), displayMode: .inline)
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                ChangeButton(selectedLeague: $league)
            }
        }
    }
}

struct ChangeButton: View {
    
    @EnvironmentObject var vm: LeagueViewModel
    @Binding var selectedLeague: League // if remove @State the data will pass fine
    
    var body: some View {
        Menu {
            ForEach(vm.leagues) { league in
                Button {
                    self.selectedLeague = league
                } label: {
                    Text(league.name)
                }
            }
        } label: {
            Image(systemName: "calendar")
        }
    }
}

Main goals:

  1. Show selected league seasons data in DetailView()
  2. Possibility to change seasons data in DetailView() when another league was chosen in ChangeButton()

CodePudding user response:

You update view model but DetailView contains a copy of league (because it is value type)

The simplest seems to me is to return in callback seasons, so there is possibility to update local league as well

demo

func loadSeasons(forLeague id: String, completion: (([Season]) -> Void)?) async throws {

     // ...

    await MainActor.run {
        if let responsedLeagueIndex = leagues.firstIndex(where: { $0.id == id }),
           let unwrappedSeasons = seasons.data?.seasons {
            leagues[responsedLeagueIndex].seasons = unwrappedSeasons

            completion?(unwrappedSeasons) // << here !!
        }
    }
}

and make task dependent on league id so selection would work, like

struct DetailView: View {

    @EnvironmentObject var vm: LeagueViewModel
    @State var league: League

    var body: some View {
        VStack {
            // ... 
        }
        .task(id: league.id) {    // << here !!
            Task {
                try await vm.loadSeasons(forLeague: league.id) {
                    league.seasons = $0  // << update local copy !!
                }
            }
        }

Tested with Xcode 13.4 / iOS 15.5

Test module is here

  • Related