Group Group Group Group Group Group Group Group Group

SwiftUI NavigationLink and View Model Issues

Context

I’m porting functionality of my app to SwiftUI and ran into an issue with navigation. When an NavigationLink is selected from the List it loads the Details view and onAppear fetches data that then updates the Details view model and correspondingly updates the view with the newly fetched data.

Problem

The problem then arises that the NavigationLink is re-rendered for some reason causing the re-initialization of the Details view model setting the data back to empty. From the user perspective ,the fetched data flashes on the screen then disappears.

I checked that none of my published properties on the observable object were updated and I added print to show that that the NavigationView is updating for some reason. Not sure if it updates and reloads the view when an item is selected.

BusinessList

struct BusinessList: View {
    @ObservedObject private var viewModel: BusinessListViewModel
    @Environment(\.colorScheme) var colorScheme: ColorScheme

    @State private var isShowingCitySelector: Bool = false

    init(viewModel: BusinessListViewModel) {
        self.viewModel = viewModel
    }

    func toggleCitySelector() {
        withAnimation {
            self.isShowingCitySelector.toggle()
        }
    }

    var body: some View {
        print("BusinessList body called: businesses: \(viewModel.businesses.count), cities: \(viewModel.cities.count), selectedCity: \(viewModel.selectedCity?.id), isShowingCitySelector: \(isShowingCitySelector)")
        return NavigationView {
            GeometryReader { geometry in
                VStack(spacing: -2) {
                    self.header
                    self.businessList
                    if self.isShowingCitySelector {
                        self.citySelector(height: geometry.size.height / 2)
                            .edgesIgnoringSafeArea(.bottom)
                            .frame(height: geometry.size.height / 2)
                            .transition(.move(edge: .bottom))
                            .animation(.easeInOut)
                    }
                }.padding(.top, geometry.safeAreaInsets.top)
                    .background(Color(UIColor.systemBackground))
                    .edgesIgnoringSafeArea(.top)
                    .navigationBarTitle("")
                    .navigationBarHidden(true)
            }
        }
    }

    private func fetchCities() {
        DispatchQueue.main.async {
            self.viewModel.fetchCities()
        }
    }
}

// Components
private extension BusinessList {
    var header: some View {
        HeaderView(city: self.viewModel.selectedCity, onTriggerCityChange: {
            withAnimation {
                self.isShowingCitySelector.toggle()
            }
        }).onAppear(perform: self.fetchCities)
    }

    var businessList: some View {
        print("BusinessList businessList called: businesses: \(viewModel.businesses.count), cities: \(viewModel.cities.count), selectedCity: \(viewModel.selectedCity?.id), isShowingCitySelector: \(isShowingCitySelector)")
        return List {
            Text("Shop offers from you favorite local breweries")
                .font(.custom("AvenirNext-Regular", size: 14))
                .frame(maxWidth: .infinity, alignment: .center)
            ForEach(self.viewModel.businesses) { business in
                ZStack {
                    // TODO: Get subscription status for brewery
                    NavigationLink(destination: BusinessDetails(viewModel: BusinessDetailsViewModel(business: business, isSubscribed: false, offers: []))) {
                        BusinessRow(viewModel: BusinessRowViewModel(name: business.name, logoUrl: business.logoUrl!, coverUrl:business.coverUrl!, offerCount: business.offerIds?.count ?? 0, isNew: business.isNew()))
                        .padding(.bottom, 16)
                    }.buttonStyle(PlainButtonStyle())
                }
           }
        }
    }

    func citySelector(height: CGFloat) -> some View {
        CitySelector(viewModel: CitySelectorViewModel(cities: self.viewModel.cities, onSelect: { city in
            DispatchQueue.main.async {
                UserPreferences.setSelectedCityId(cityId: city.id)
                self.viewModel.selectedCity = city
                self.toggleCitySelector()
            }
        }))
    }
}

class BusinessListViewModel: ObservableObject {
    @Published var businesses: [Business]
    @Published var cities: [BusinessGrouping]
    @Published var selectedCity: BusinessGrouping?

    private var disposables = Set<AnyCancellable>()

    init(cities: [BusinessGrouping], businesses: [Business]) {
        self.cities = cities
        self.businesses = businesses

        $selectedCity
            .compactMap{ $0?.id }
            .sink(receiveValue: selectCity(for:))
            .store(in: &disposables)
    }

    func getDefaultCity() -> BusinessGrouping? {
        return self.cities.first(where: {
            $0.id == UserPreferences.getSelectedCityId()
        }) ?? self.cities.first
    }

    func selectCity(for cityId: Int) {
        UserPreferences.setSelectedCityId(cityId: cityId)
        self.fetchBusinesses(for: cityId)
    }

    func fetchCities() {
        ApiService.listCities()
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: {[weak self] value in
                guard let self = self else { return }
                switch value {
                case .failure:
                    self.cities = []
                case.finished:
                    break
                }
                }, receiveValue: { [weak self] response in
                    guard let self = self else { return }
                    self.cities = response.groupInfos
                    if let defaultCity = self.getDefaultCity() {
                        self.selectedCity = defaultCity
                    }
            })
            .store(in: &disposables)
    }

    func fetchBusinesses(for groupId: Int) {
        // TODO: Add caching and error handling
        ApiService.listBusinesses(for: groupId)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: {[weak self] value in
                guard let self = self else { return }
                switch value {
                case .failure:
                    self.businesses = []
                case.finished:
                    break
                }
                }, receiveValue: { [weak self] response in
                    guard let self = self else { return }
                    self.businesses = response.sorted()
            })
            .store(in: &disposables)
    }
}

##BusinessDetails

struct BusinessDetails: View {
    @Environment(\.presentationMode) var presentationMode
    @ObservedObject private var viewModel: BusinessDetailsViewModel

    init(viewModel: BusinessDetailsViewModel) {
        self.viewModel = viewModel
    }

    func getOffersLabel() -> String {
        if (viewModel.business.offerIds!.count == 1) {
            return "offer"
        }
        return "offers"
    }

    var body: some View {
        print("BusinessDetails body called: offers: \(viewModel.offers.count), business: \(viewModel.business.id), isSubscribed: \(viewModel.isSubscribed)")
        return GeometryReader { geometry in
            VStack {
                    ForEach(self.viewModel.offers) { offer in
                        OfferRow(viewModel: OfferRowViewModel(title: offer.title, subtitle: offer.subtitle, imageUrl: offer.imageUrl!, price: offer.price, tag: offer.getLabelTagProps()))
                    }
                }
            }
        }.onAppear(perform: self.fetchOffers)
        .navigationBarBackButtonHidden(true)
        .navigationBarItems(leading: BackButton(onClick: goBack))
        .background(Color(UIColor.systemBackground))
        .edgesIgnoringSafeArea(.vertical)

    }

    private func goBack() {
        self.presentationMode.wrappedValue.dismiss()
    }

    private func fetchOffers() {
        DispatchQueue.main.async {
            self.viewModel.fetchOffers()
        }
    }
}

class BusinessDetailsViewModel: ObservableObject {
    @Published var offers: [Offer]
    let business: Business
    var isSubscribed: Bool

    private var disposables = Set<AnyCancellable>()

    init(business: Business, isSubscribed: Bool, offers: [Offer]) {
        self.business = business
        self.isSubscribed = isSubscribed
        self.offers = offers
    }

    func fetchOffers() {
        // TODO: Add caching and error handling
        ApiService.listOffers(for: business.id)
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: {[weak self] value in
                guard let self = self else { return }
                switch value {
                case .failure:
                    self.offers = []
                case.finished:
                    break
                }
                }, receiveValue: { [weak self] response in
                    guard let self = self else { return }
                    self.offers = response.sorted()
            })
            .store(in: &disposables)
    }
}

Console output

Notice how the BusinessList print statements are triggered after the offers are fetched, which is reinitializing the model and setting offers back to any empty list. Any ideas on why the NavigationView is updating on NavigationLink selection and any way to fix the issue?

BusinessDetails body called: offers: 0, business: 8, isSubscribed: false
BusinessList called: businesses: 11, cities: 4, selectedCity: Optional(41402), isShowingCitySelector: false
BusinessDetails body called: offers: 0, business: 8, isSubscribed: false
Fetch Offers for business: 8
BusinessDetails body called: offers: 4, business: 8, isSubscribed: false
BusinessList called: businesses: 11, cities: 4, selectedCity: Optional(41402), isShowingCitySelector: false
BusinessDetails body called: offers: 0, business: 8, isSubscribed: false
1 Like

@cgray9 Do you still have issues with this?