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