Reputation: 101
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
Reputation: 36782
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