Petr Smejkal
Petr Smejkal

Reputation: 101

NavigationStackView DetailView not reseting States and StateObjects when changing selection

I have a 3 column NavigationSplitView where a detailView takes a flight which is a Core Data model like so.

import SwiftUI

enum SideBarMenuCategory: Int, CaseIterable, Identifiable {
    case flights
    var id: Int { rawValue }
}

class FlightModel: ObservableObject {
    let id: UUID
    let text: String

    init(text: String) {
        self.id = UUID()
        self.text = text
    }
}

class RouteManager: ObservableObject {
    @Published var selectedCategory: SideBarMenuCategory? = .flights
    @Published var visibility: NavigationSplitViewVisibility = .automatic
    @Published var selectedFlight: FlightModel? = nil

    @Published var selectedFlights = Set<UUID>() {
        didSet {
            updateSelectedFlight()
        }
    }

    let flightStore = FlightStore.instance

    func updateSelectedFlight() {
        guard selectedFlights.count == 1,
              let flightID = selectedFlights.first
        else {
            selectedFlight = nil
            return
        }
        selectedFlight = flightStore.findFlight(by: flightID)
    }
}

class FlightStore: ObservableObject {
    static let instance = FlightStore()

    private init() {}

    @Published var flights: [FlightModel] = [FlightModel(text: "Test"), FlightModel(text: "Test 2")]

    func findFlight(by id: UUID?) -> FlightModel? {
        flights.first { $0.id == id }
    }
}

struct SidebarView: View {
    @Binding var selectedCategory: SideBarMenuCategory?

    var body: some View {
        List(selection: $selectedCategory) {
            NavigationLink(value: SideBarMenuCategory.flights) {
                Label("Flights", systemImage: "book")
            }
        }
    }
}

struct FlightsOverView: View {
    @EnvironmentObject var routeManager: RouteManager
    @EnvironmentObject var flightStore: FlightStore

    var body: some View {
        List(flightStore.flights, id: \.id, selection: $routeManager.selectedFlights) { flight in
            HStack {
                Text(flight.id.uuidString)
                Spacer()
                Text(flight.text)
            }
            .tag(flight.id)
        }
    }
}

struct SplitView: View {
    @StateObject var routeManager = RouteManager()
    @StateObject var flightStore = FlightStore.instance

    var body: some View {
        NavigationSplitView(columnVisibility: $routeManager.visibility) {
            SidebarView(selectedCategory: $routeManager.selectedCategory)
                .navigationTitle("Menu")
        } content: {
            SecondColumnView()
        } detail: {
            ThirdColumnView()
        }
        .environmentObject(routeManager)
        .environmentObject(flightStore)
    }
}

struct SecondColumnView: View {
    @EnvironmentObject var routeManager: RouteManager

    var body: some View {
        if let selectedCategory = routeManager.selectedCategory {
            switch selectedCategory {
            case .flights:
                FlightsOverView()
            }
        } else {
            EmptyView()
        }
    }
}

struct ThirdColumnView: View {
    @EnvironmentObject var routeManager: RouteManager

    var body: some View {
        if let category = routeManager.selectedCategory {
            switch category {
            case .flights:
                if let flight = routeManager.selectedFlight {
                    FlightDetailView(flight: flight)
                }
            }
        } else {
            Text("Select View")
        }
    }
}

class DetailVM: ObservableObject {
    init() {
        print("DetailVM is Init")
    }

    deinit {
        print("DetailVM is Deinit")
    }
}

struct FlightDetailView: View {
    @ObservedObject var flight: FlightModel
    @StateObject var detailVM = DetailVM()

    @State var isOn = false

    var body: some View {
        VStack(alignment: .center) {
            Text(flight.text)
            Toggle("", isOn: $isOn)
        }
        .task {
            print("I want to set something up")
        }
        .onDisappear {
            print("I want to perform some checks")
        }
    }
}

FlightsOverView holds List with selection. That selection returns a Set<UUID> which I filter and return a selectedFlight. The change takes place as expected and view updates. However, all @StateObject and @State held by FlightDetailView do not reset when changing selection (.task runs exactly once on the initial selection and .onDisappear is never invoked). I am migrating from NavigationStack where this isn't an issue as the view disappears before another one can be selected. How could this be fixed in NavigationSplitView? I tried assigning a .id(flight.id) to the view but this only causes in @StateObjects of that view to init each time flight selection is changed but no deinit takes place.

EDIT: Added minimal reproducible code

2nd EDIT: After quite some digging I have found a culprit - Menu in the toolBar of the detail view. The Menu in the toolbar is for some reason causing to hold all @StateObjects for that view even if the user navigates to a different view.

struct FlightDetailView: View {
    @ObservedObject var flight: FlightModel
    @StateObject var detailVM = DetailVM()

    @State var isOn = false

    var body: some View {
        Form {
            VStack(alignment: .center) {
                Text(selectedFlight?.text ?? "no selectedFlight").foregroundStyle(.blue)
                Toggle("", isOn: $isOn)
            }
        }
        .toolbar {
            ToolbarItem(placement: .topBarTrailing){
// without this Menu, the StateObjects are deinit when navigating away
                Menu {
                    Button("Flight") {
                        selectedFlight?.text = "Is Flying Now"
                    }
                }label: {
                    Label("Test", systemImage: "book.fill")
                }
                
                
            }
        }
        .task {
            print("I want to set something up")
        }
        .onDisappear {
            print("I want to perform some checks")
        }
    }
}

This wasn't in the original post, since I didn't suspect the code in detail view to have anything to do with this, but rather my data flow structure.

Upvotes: 1

Views: 117

Answers (1)

You could try this approach, where you have all the data manipulation/fetching etc... in one source of truth class FlightStore: ObservableObject, and let the Views do the user interface, the selections and the navigation using @State variables, as shown in the example code:

struct ContentView: View {
    var body: some View {
        SplitView()
    }
}

enum SideBarMenuCategory: Int, CaseIterable, Identifiable {
    case flights
    var id: Int { rawValue }
}

struct FlightModel: Identifiable, Hashable {
    let id: UUID = UUID()
    var isFlying: Bool = false
    var text: String
}

class FlightStore: ObservableObject {
    @Published var flights: [FlightModel] = [FlightModel(text: "Test 1"), FlightModel(text: "Test 2")]
}

struct SidebarView: View {
    @Binding var selectedCategory: SideBarMenuCategory?

    var body: some View {
        List(selection: $selectedCategory) {
            NavigationLink(value: SideBarMenuCategory.flights) {
                Label("Flights", systemImage: "book")
            }
        }
    }
}

struct SplitView: View {
    @StateObject private var flightStore = FlightStore()
    
    @State private var visibility: NavigationSplitViewVisibility = .automatic
    
    @State private var selectedCategory: SideBarMenuCategory? = .flights
    @State private var selectedFlight: FlightModel?

    var body: some View {
        NavigationSplitView(columnVisibility: $visibility) {
            SidebarView(selectedCategory: $selectedCategory)
                .navigationTitle("Menu")
        } content: {
            SecondColumnView(selectedCategory: $selectedCategory, selectedFlight: $selectedFlight)
        } detail: {
            ThirdColumnView(selectedCategory: $selectedCategory, selectedFlight: $selectedFlight)
        }
        .environmentObject(flightStore)
        .onAppear {
            selectedFlight = flightStore.flights.first
        }
    }
}

struct SecondColumnView: View {
    @Binding var selectedCategory: SideBarMenuCategory?
    @Binding var selectedFlight: FlightModel?

    var body: some View {
        if let selectedCategory = selectedCategory {
            switch selectedCategory {
            case .flights:
                FlightsOverView(selectedFlight: $selectedFlight)
            }
        } else {
            Text("in SecondColumnView")
        }
    }
}

struct FlightsOverView: View {
    @EnvironmentObject var flightStore: FlightStore
    @Binding var selectedFlight: FlightModel?

    var body: some View {
        List(flightStore.flights, selection: $selectedFlight) { flight in
            HStack {
                Text(flight.id.uuidString)
                Spacer()
                Text(flight.text)
                Text(flight.isFlying ? " flying" : " on ground").foregroundStyle(.red)
                Spacer()
            }.tag(flight)
        }
    }
}

struct ThirdColumnView: View {
    @Binding var selectedCategory: SideBarMenuCategory?
    @Binding var selectedFlight: FlightModel?

    var body: some View {
        if let category = selectedCategory {
            switch category {
            case .flights:
                FlightDetailView(selectedFlight: $selectedFlight)
                    .id(selectedFlight?.id)
            }
        } else {
            Text("ThirdColumnView")
        }
    }
}

struct FlightDetailView: View {
    @EnvironmentObject var flightStore: FlightStore
    @Binding var selectedFlight: FlightModel?

    @State private var isOn = false

    var body: some View {
        VStack(alignment: .center) {
            Text(selectedFlight?.text ?? "no selectedFlight").foregroundStyle(.blue)
            Toggle("", isOn: $isOn)
        }
        .onAppear {
            isOn = selectedFlight?.isFlying ?? false
        }
        .onChange(of: isOn) {
            selectedFlight?.isFlying = isOn
            if let flight = selectedFlight, let index = flightStore.flights.firstIndex(where: { $0.id == flight.id }) {
                flightStore.flights[index].isFlying = flight.isFlying
            }
        }
        .task {
            print("I want to set something up")
        }
        .onDisappear {
            print("I want to perform some checks")
        }
    }
}

If you are targeting ios-17+, then I recommend you use Observable, see Managing model data in your app

Upvotes: 1

Related Questions