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.
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