I'm trying to implement a Coordinator for managing a flow. The state is stored inside the CoordinatorStore. There are 2 #Published properties for managing the flow. The screen property controls which View is currently shown and path controls the navigation stack of the stack view. Details of the implementation can be found below.
With the current implementation and after the following actions: showA -> showB -> showInitial -> Go to Stack
I would expect that StoreA and StoreB would be deallocated from memory since path, which holds StoreA and StoreB via enum associated values, gets emptied.
But that doesn't happen, and if I repeat the actions again there would be 2 StoreA and 2 StoreB in memory and so on. Am I missing something?
I will also attach a screenshot of the memory debugger snapshot after doing the initial set of actions.
enum Path: Hashable {
case a(StoreA)
case b(StoreB)
}
enum Screen {
case initial
case stack
}
final class CoordinatorStore: ObservableObject {
#Published var path: [Path] = []
#Published var screen: Screen = .stack
func showA() {
let store = StoreA()
path.append(.a(store))
}
func showB() {
let store = StoreB()
path.append(.b(store))
}
func showInitial() {
path = []
screen = .initial
}
func showStack() {
screen = .stack
}
}
struct Coordinator: View {
#ObservedObject var store: CoordinatorStore
var body: some View {
switch store.screen {
case .initial: initial
case .stack: stack
}
}
var stack: some View {
NavigationStack(path: $store.path) {
VStack {
Text("Root")
}
.toolbar {
Button(action: self.store.showA) {
Text("Push A")
}
}
.navigationDestination(for: Path.self) { path in
switch path {
case .a(let store):
ViewA(store: store)
.toolbar {
Button(action: self.store.showB) {
Text("Push B")
}
}
case .b(let store):
ViewB(store: store)
.toolbar {
Button(action: self.store.showInitial) {
Text("Show Initial")
}
}
}
}
}
}
var initial: some View {
VStack {
Text("Initial")
Button(action: store.showStack) {
Text("Go to Stack")
}
}
}
}
struct ViewA: View {
#ObservedObject var store: StoreA
var body: some View {
Text("View A")
}
}
final class StoreA: NSObject, ObservableObject {
deinit {
print("Deinit: \(String(describing: self))")
}
}
struct ViewB: View {
#ObservedObject var store: StoreB
var body: some View {
Text("View B")
}
}
final class StoreB: NSObject, ObservableObject {
deinit {
print("Deinit: \(String(describing: self))")
}
}
I believe this is related but not identical to:
Found a strange behaviour of #State when combined to the new Navigation Stack - Is it a bug or am I doing it wrong?
The Navigation api seems to be prioritizing efficiency (inits are expensive) and that SOMETHING must always be on screen. It doesn't seem to de-initialize views that have been disappeared until it has a replacement initialized and appeared.
That can lead to a memory leak (I believe) if you try to manage Navigation framework views with something outside of the Navigation framework, but it appears as long as the Navigation framework stays in charge things will be de-inted eventually, but not until the new view is init-ed.
NEWER VERSION
This version uses one coordinator, but preserves the separate enums and views for the initial vs main app pathways.
import Foundation
import SwiftUI
enum AppSceneTvTe:Hashable {
case setup
case app
}
enum PathTvTeOptions: Hashable {
case optionA(OptionAVM)
case optionB(OptionBVM)
}
struct SplashTVTEView: View {
#StateObject var oneCoordinator = CoordinatorTvTe()
var body: some View {
NavigationStack(path: $oneCoordinator.path) {
splash
.navigationDestination(for: AppSceneTvTe.self) { scene in
switch scene {
case .app:
SplashTvTeAppRootView().environmentObject(oneCoordinator)
default:
splash
}
}
}
}
var splash: some View {
VStack {
Text("Splash Page")
Button(action:navigateToApp) {
Text("Go App Root")
}
}.navigationBarBackButtonHidden(true)
}
func navigateToApp() {
oneCoordinator.showStack()
}
}
final class CoordinatorTvTe: ObservableObject {
#Published var path = NavigationPath()
func showA() {
path.append(PathTvTeOptions.optionA(OptionAVM()))
}
func showB() {
path.append(PathTvTeOptions.optionB(OptionBVM()))
}
func showInitial() {
unwindAll()
//path = NavigationPath()
}
func showStack() {
path = NavigationPath()
path.append(AppSceneTvTe.app)
}
func unwindAll() {
while !path.isEmpty {
path.removeLast()
}
}
}
struct SplashTvTeAppRootView: View {
#EnvironmentObject var navigation: CoordinatorTvTe
var body: some View {
VStack {
Text("Real Root")
}
.navigationBarBackButtonHidden(true)
.toolbar {
Button(action: self.navigation.showA) {
Text("Push A")
}
}
.navigationDestination(for: PathTvTeOptions.self) { path in
switch path {
case .optionA(let vm):
OptionAView(vm: vm)
.toolbar {
Button(action: self.navigation.showB) {
Text("Push B")
}
}
case .optionB(let vm):
OptionBView(vm: vm)
.toolbar {
Button(action: self.navigation.showInitial) {
Text("Show Initial")
}
}
}
}
}
}
OLDER VERSION
Currently the way out of this is to keep it all in the Navigation Stack so no separate Scene vs. Path.
This code uses a boolean to control the Initial screen, but it could be one of the path options - which is the commented out code.
EDITED TO ADD: Tuns out the boolean solution gets weird when you try to make the initial state true. The Stack keeps winning, so I've taken it out.
enum Path: Hashable {
case initial
case a(StoreA)
case b(StoreB)
}
final class CoordinatorStore: ObservableObject {
#Published var path: [Path] = [.initial]
func showA() {
let store = StoreA()
path.append(.a(store))
}
func showB() {
let store = StoreB()
path.append(.b(store))
}
func showInitial() {
path = []
path.append(.inital)
}
func showStack() {
path = []
}
}
struct Coordinator: View {
#ObservedObject var store: CoordinatorStore
var body: some View {
NavigationStack(path: $store.path) {
VStack {
Text("Real Root")
}
.toolbar {
Button(action: self.store.showA) {
Text("Push A")
}
}
.navigationDestination(for: Path.self) { path in
switch path {
case .a(let store):
ViewA(store: store)
.toolbar {
Button(action: self.store.showB) {
Text("Push B")
}
}
case .b(let store):
ViewB(store: store)
.toolbar {
Button(action: self.store.showInitial) {
Text("Show Initial")
}
}
case .initial:
initial
}
}
}
}
var initial: some View {
VStack {
Text("Initial")
Button(action: store.showStack) {
Text("Go to Stack")
}
}.navigationBarBackButtonHidden(true)
}
}
Related
I am trying to implement the behavior in a TabView when the user taps the same tab multiple times, such as in the iOS AppStore app. First tap: switch to that view, second tap: pop to root, third tap: scroll to the top if needed.
The code below works fine for switching and didTap() is called for every tap.
import SwiftUI
enum Tab: String {
case one
case two
}
struct AppView: View {
#State private var activeTab = Tab.one
var body: some View {
TabView(selection: $activeTab.onChange(didTap)) {
One()
.tabItem {
Label("one", systemImage: "1.lane")
}
.tag(Tab.one)
Two()
.tabItem {
Label("two", systemImage: "2.lane")
}
.tag(Tab.two)
}
}
func didTap(to value: Tab) {
print(value) // this captures every tap
}
}
extension Binding {
func onChange(_ handler: #escaping (Value) -> Void) -> Binding<Value> {
Binding(
get: { self.wrappedValue },
set: { newValue in
self.wrappedValue = newValue
handler(newValue)
}
)
}
}
What I am struggling with, is how to tell either One or Two that it was tapped for a second or third time? (How to pop and scroll is not the issue).
I have seen this: TabView, tabItem: running code on selection or adding an onTapGesture but it doesn't explain how to run code in one of the views.
Any suggestions?
You can record additional taps (of same value) in an array. The array count gives you the number of taps on the same Tab.
EDIT: now with explicit subview struct.
struct ContentView: View {
#State private var activeTab = Tab.one
#State private var tapState: [Tab] = [Tab.one] // because .one is default
var body: some View {
TabView(selection: $activeTab.onChange(didTap)) {
SubView(title: "One", tapCount: tapState.count)
.tabItem {
Label("one", systemImage: "1.lane")
}
.tag(Tab.one)
SubView(title: "Two", tapCount: tapState.count)
.tabItem {
Label("two", systemImage: "2.lane")
}
.tag(Tab.two)
}
}
func didTap(to value: Tab) {
print(value) // this captures every tap
if tapState.last == value {
tapState.append(value) // apped next tap if same value
print("tapped \(tapState.count) times")
} else {
tapState = [value] // reset tap state to new tab selection
}
}
}
struct SubView: View {
let title: String
let tapCount: Int
var body: some View {
VStack {
Text("Subview \(title)").font(.title)
Text("tapped \(tapCount) times")
}
}
}
Although the answer by #ChrisR did answer my question, I couldn't figure out the next step, i.e. the logic when to pop-to-root or scroll-to-the-top based on the number of taps for a SubView. After lots of reading and trial and error, I recently came across this article: https://notificare.com/blog/2022/11/25/a-better-tabview-in-swiftui/
Inspired by this article, but with some modifications, I came up with the following which does exactly what I was looking for.
The two main changes are:
An EmptyView with an id is added as the first (but invisible) row in the List to be used as an anchor by proxy.scrollTo().
Instead of the global #StateObject var appState that stores the navigation paths for the subviews, I added the paths as separate #State properties. This avoids the Update NavigationAuthority bound path tried to update multiple times per frame. warning.
Hopefully this is helpful for someone.
enum Tab: String {
case one
case two
}
struct ContentView: View {
#State var selectedTab = Tab.one
#State var oneNavigationPath = NavigationPath()
#State var twoNavigationPath = NavigationPath()
var body: some View {
ScrollViewReader { proxy in
TabView(selection: tabViewSelectionBinding(proxy: proxy)) {
SubView(title: "One", path: $oneNavigationPath)
.tabItem {
Label("one", systemImage: "1.lane")
}
.tag(Tab.one)
SubView(title: "Two", path: $twoNavigationPath)
.tabItem {
Label("two", systemImage: "2.lane")
}
.tag(Tab.two)
}
}
}
private func tabViewSelectionBinding(proxy: ScrollViewProxy) -> Binding<Tab> {
Binding<Tab>(
get: { selectedTab },
set: { newValue in
if selectedTab == newValue {
switch selectedTab {
case .one:
if oneNavigationPath.isEmpty {
withAnimation {
proxy.scrollTo(Tab.one, anchor: .bottom)
}
} else {
withAnimation {
oneNavigationPath = NavigationPath()
}
}
case .two:
if twoNavigationPath.isEmpty {
withAnimation {
proxy.scrollTo(Tab.two, anchor: .bottom)
}
} else {
withAnimation {
twoNavigationPath = NavigationPath()
}
}
}
}
selectedTab = newValue
}
)
}
}
struct SubView: View {
let title: String
let items = Array(1 ... 100)
#Binding var path: NavigationPath
var body: some View {
NavigationStack(path: $path) {
List {
EmptyView()
.id(Tab(rawValue: title.lowercased()))
ForEach(items, id: \.self) { item in
NavigationLink(value: item) {
Text("Item \(item)")
}
}
}
.navigationTitle(title)
.navigationDestination(for: Int.self) { item in
Text("Item \(item)")
}
}
}
}
I have three views A,B and C. User can navigate from A to B and from A to C. User can navigate from B to C. Now I want to differentiate if the user have come from A to C or from B to C so I was looking in how to pass extra data in NavigationStack which can help me differentiate
Below is my code
import SwiftUI
#main
struct SampleApp: App {
#State private var path: NavigationPath = .init()
var body: some Scene {
WindowGroup {
NavigationStack(path: $path){
A(path: $path)
.navigationDestination(for: ViewOptions.self) { option in
option.view($path)
}
}
}
}
enum ViewOptions {
case caseB
case caseC
#ViewBuilder func view(_ path: Binding<NavigationPath>) -> some View{
switch self{
case .caseB:
B(path: path)
case .caseC:
C(path: path)
}
}
}
}
struct A: View {
#Binding var path: NavigationPath
var body: some View {
VStack {
Text("A")
Button {
path.append(SampleApp.ViewOptions.caseB)
} label: {
Text("Go to B")
}
Button {
path.append(SampleApp.ViewOptions.caseC)
} label: {
Text("Go to C")
}
}
}
}
struct B: View {
#Binding var path: NavigationPath
var body: some View {
VStack {
Text("B")
Button {
path.append(SampleApp.ViewOptions.caseC)
} label: {
Text("Go to C")
}
}
}
}
struct C: View {
#Binding var path: NavigationPath
var body: some View {
VStack {
Text("C")
}
}
}
Instead of "pass extra data in NavigationStack" you can pass data in a NavigationRouter. It gives you much more control
#available(iOS 16.0, *)
//Simplify the repetitive code
typealias NavSource = SampleApp.ViewOptions
#available(iOS 16.0, *)
struct NavigationRouter{
var path: [NavSource] = .init()
///Adds the provided View to the stack
mutating func goTo(view: NavSource){
path.append(view)
}
///Searches the stack for the `View`, if the view is `nil`, the stack returns to root, if the `View` is not found the `View` is presented from the root
mutating func bactrack(view: NavSource?){
guard let view = view else{
path.removeAll()
return
}
//Look for the desired view
while !path.isEmpty && path.last != view{
path.removeLast()
}
//If the view wasn't found add it to the stack
if path.isEmpty{
goTo(view: view)
}
}
///Identifies the previous view in the stack, returns nil if the previous view is the root
func identifyPreviousView() -> NavSource?{
//1 == current view, 2 == previous view
let idx = path.count - 2
//Make sure idx is valid index
guard idx >= 0 else{
return nil
}
//return the view
return path[idx]
}
}
Once you have access to the router in the Views you can adjust accordingly.
#available(iOS 16.0, *)
struct SampleApp: View {
#State private var router: NavigationRouter = .init()
var body: some View {
NavigationStack(path: $router.path){
A(router: $router)
//Have the root handle the type
.navigationDestination(for: NavSource.self) { option in
option.view($router)
}
}
}
//Create an `enum` so you can define your options
//Conform to all the required protocols
enum ViewOptions: Codable, Equatable, Hashable{
case caseB
case caseC
//If you need other arguments add like this
case unknown(String)
//Assign each case with a `View`
#ViewBuilder func view(_ path: Binding<NavigationRouter>) -> some View{
switch self{
case .caseB:
B(router: path)
case .caseC:
C(router: path)
case .unknown(let string):
Text("View for \(string.description) has not been defined")
}
}
}
}
#available(iOS 16.0, *)
struct A: View {
#Binding var router: NavigationRouter
var body: some View {
VStack{
Button {
router.goTo(view: .caseB)
} label: {
Text("To B")
}
Button {
router.goTo(view: .caseC)
} label: {
Text("To C")
}
}.navigationTitle("A")
}
}
#available(iOS 16.0, *)
struct B: View {
#Binding var router: NavigationRouter
var body: some View {
VStack{
Button {
router.goTo(view: .caseC)
} label: {
Text("Hello")
}
}.navigationTitle("B")
}
}
#available(iOS 16.0, *)
struct C: View {
#Binding var router: NavigationRouter
//Identify changes based on previous View
var fromA: Bool{
//nil is the root
router.identifyPreviousView() == nil
}
var body: some View {
VStack{
Text("Welcome\(fromA ? " Back" : "" )")
Button {
//Append to the path the enum value
router.bactrack(view: router.identifyPreviousView())
} label: {
Text("Back")
}
Button {
//Append to the path the enum value
router.goTo(view: .unknown("\"some other place\""))
} label: {
Text("Next")
}
}.navigationTitle("C")
.navigationBarBackButtonHidden(true)
}
}
You can read the second-to-last item in the path property to learn what the previous screen was.
To do this, it's easier to use an actual array of ViewOptions as the path, instead of a NavigationPath.
For example:
struct SampleApp: App {
// Use your own ViewOptions enum, instead of NavigationPath
#State private var path: [ViewOptions] = []
var body: some Scene {
WindowGroup {
NavigationStack(path: $path){
A(path: $path)
.navigationDestination(for: ViewOptions.self) { option in
option.view($path)
}
}
}
}
}
struct C: View {
#Binding var path: [ViewOptions]
var previousView: ViewOptions? {
path
.suffix(2) // Get the last 2 elements of the path
.first // Get the first of those last 2 elements
}
var body: some View {
VStack {
Text("C")
}
}
}
Remember, a NavigationPath is nothing more than a type-erased array. It can be used to build a NavigationStack quickly without having to worry that all destination values have to match the same type. Since as you're controlling the navigation flow with your own type ViewOptions, it makes no sense to use NavigationPath.
The following code works perfectly fine on iOS, but not on iPadOS. When I tap on one of the items in the list, the corresponding detail view is shown, but it will not change if I tap on another item. When I change the model in the LanguageDetail view to #ObservedObject, it works. To be clear, this is only an example to illustrate the problem. In my actual project, I'm not able to make this change though. The code below demonstrates this problem.
struct ContentView: View {
let languages: [String] = ["Objective-C", "Java", "Python", "Swift", "Rust"]
#State var selectedLanguage: String?
var body: some View {
NavigationView {
List(languages, id: \.self) { language in
Button(action: {selectedLanguage = language}) {
Text(language)
.bold()
.padding()
}
}
.background {
NavigationLink(isActive: $selectedLanguage.isPresent()) {
if let lang = selectedLanguage {
LanguageDetail(model: LanguageDetailModel(languageName: lang))
} else {
EmptyView()
}
} label: {
EmptyView()
}
}
}
}
}
struct LanguageDetail: View {
#StateObject var model: LanguageDetailModel
var body: some View {
VStack {
Text(model.languageName)
.font(.headline)
Text("to rule them all...")
}
}
}
class LanguageDetailModel: ObservableObject {
#Published var languageName: String
init(languageName: String) {
self.languageName = languageName
}
}
This extension is needed:
/// This extension is from the [SwiftUI Navigation Project on Github](https://github.com/pointfreeco/swiftui-navigation)
extension Binding {
/// Creates a binding by projecting the current optional value to a boolean describing if it's
/// non-`nil`.
///
/// Writing `false` to the binding will `nil` out the base value. Writing `true` does nothing.
///
/// - Returns: A binding to a boolean. Returns `true` if non-`nil`, otherwise `false`.
public func isPresent<Wrapped>() -> Binding<Bool>
where Value == Wrapped? {
.init(
get: { self.wrappedValue != nil },
set: { isPresent, transaction in
if !isPresent {
self.transaction(transaction).wrappedValue = nil
}
}
)
}
}
import SwiftUI
enum Language: Int, Identifiable, CaseIterable {
case objc
case java
case python
case swift
case rust
var id: Self { self }
var localizedDescription: LocalizedStringKey {
switch self {
case .objc: return "Objective-C"
case .java: return "Java"
case .python: return "Python"
case .swift: return "Swift"
case .rust: return "Rust"
}
}
}
struct LanguagesTest: View {
#State private var selection: Language?
var body: some View {
NavigationSplitView {
List(Language.allCases, selection: $selection) { language in
NavigationLink(value: language) {
Text(language.localizedDescription)
}
}
} detail: {
if let language = selection {
LanguageDetail(language: language)
}
else {
Text("Select Language")
}
}
}
}
struct LanguageDetail: View {
let language: Language
var body: some View {
VStack {
Text(language.localizedDescription)
.font(.headline)
Text("to rule them all...")
}
}
}
Description
I've got simple Combat model which stores name and list of actors. When I delete the Combat from List using onDelete it looks like it's working. It removes the Combat from Realm (checked with RealmStudio) and updates the view. However, if view gets redrawn (for instance, when switching Apps), the "old" data is loaded again (the very first loaded on app initialization), so all deleted rows are back again. Of course, removing them again crashes the app, because they are not present in #ObservedResults combats anymore. Restarting the app fixes the issue, because new data is loaded to #ObservedResults combats and to List, but then again, when I removed something it will be back on review draw...
What I discovered is that removing .sheet() fixes the issue! (EDIT: clarification; it doesn't matter what's inside of the sheet, it may be even empty) The view is updated correctly on redraw! The Sheet is used to display form to add new Combat (nether to say that adding new combats or editing them does not update the view as well, but let's focus on deletion). I have no idea what adding sheet() changes in behaviour of the List and "listening" to #ObservedResults combats.
As a test I used simple array of Combat classes and everything worked. So it points me to issue with #ObservedResults.
I was using the Alert before and all changes to #ObservedResults combats were seen at glance. Now I wanted to replace Alert with Sheet and… That happened.
Also, I have subview where I have almost identical code for actor and there everything works, however I use #ObservedRealmObject var combat: Combat there, and I pass the combat #ObservedResults combats, like so:
NavigationLink(destination: CombatView(combat: combat)) { Text(combat.name) }
I removed unecessary code from below examples to keep it at minimum.
Model
The Combat model:
class Combat: Object, ObjectKeyIdentifiable {
#objc dynamic var id: String = UUID().uuidString
#objc dynamic var name: String = ""
var actors = List<Actor>()
}
Actual View Code (broken using Sheet)
#ObservedResults(
Combat.self,
sortDescriptor: SortDescriptor( keyPath: "name", ascending: true)
) var combats
struct CombatsListView: View {
#ObservedResults(
Combat.self,
sortDescriptor: SortDescriptor( keyPath: "name", ascending: true)
) var combats
var body: some View {
List {
ForEach(combats) { combat in
Text(combat.name)
}.onDelete(perform: $combats.remove)
}
.sheet(isPresented: $showAddCombat) {
AddCombatView( showAddCombat: $showAddCombat)
}
}
}
Old View Code (works using Alert)
struct CombatsListView: View {
#ObservedResults(
Combat.self,
sortDescriptor: SortDescriptor( keyPath: "name", ascending: true)
) var combats
#State private var showAddCombat = false
#State private var addCombatNewName = ""
var body: some View {
List(combats) { combat in
Text(combat.name)
.onDelete(perform: $combats.remove)
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showAlert = true
}) {
Image(systemName: "plus" )
.font(.title)
Text("New Combat")
}.alert("New Combat", isPresented: $showAlert) {
TextField("write name", text: $addCombatNewName)
Button("Close", role: .cancel) {
addCombatNewName = ""
}
Button("Add") {
addNewCombat(name: addCombatNewName)
addCombatNewName = ""
}
}
}
}
}
private func addNewCombat(name: String) {
let newCombat = Combat()
newCombat.name = name
do {
try self.realm.write {
realm.add(newCombat)
}
} catch {
fatalError("Error: \(error)")
}
}
}
EDITED
I just found some new behaviour. I made a new simple view which lists elements of Collection list and you can delete or add new Collection. It works just fine, but if I include this CollectionsView under the TabView, then the effect is exactly the same as in the example above. The view stops working properly: deleted items are added back on view redraw and adding new objects doesn't refresh the View.
This makes me think more of a bug in #ObservedResults().
Below is the source code.
class Collection: Object, ObjectKeyIdentifiable {
#objc dynamic var id: String = UUID().uuidString
#objc dynamic var name: String = ""
var actors = List<Actor>()
}
#main
struct CombatTrackerApp: App {
var body: some Scene {
WindowGroup {
Tabber() // will not work
// CollectionsView() // will work
}
}
}
struct CollectionsView: View {
#ObservedResults( Collection.self ) var collections
#State private var showNewCollectionForm = false
var body: some View {
NavigationStack {
List {
ForEach(collections) { collection in
Text(collection.name)
}.onDelete(perform: $collections.remove)
}
.listStyle(.inset)
.padding()
.navigationTitle("Collections")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button() {
self.showNewCollectionForm.toggle()
} label: {
Image(systemName: "plus")
Text("Add New Collection")
}
}
}
.sheet(isPresented: $showNewCollectionForm) {
NewCollectionView( showNewCollectionForm: $showNewCollectionForm )
}
}
}
}
struct NewCollectionView: View {
let realm = try! Realm()
#Binding var showNewCollectionForm: Bool
#State private var newCollectioName: String = ""
var body: some View {
NavigationStack {
VStack {
Text("Create new Collection").font(.title).padding()
Form {
TextField("Name", text: $newCollectioName)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close", role: .cancel) {
showNewCollectionForm.toggle()
}
}
ToolbarItem {
Button("Create") {
addCollection()
} .disabled(newCollectioName.isEmpty)
}
}
}
}
private func addCollection() {
let newCollection = Collection()
newCollection.name = newCollectioName
do {
try realm.write {
realm.add(newCollection)
}
} catch {
print("Cannot add new Collection", error)
}
showNewCollectionForm.toggle()
}
}
struct Tabber: View {
var body: some View {
TabView() {
NavigationStack {
CombatsListView()
}
.tabItem {
Text("Combats")
}
NavigationStack {
CollectionsView()
}
.tabItem {
Text("Collections")
}
SettingsView()
.tabItem {
Text("Settings")
}
}
}
}
I found out the solution (but I still don't understand why it's working).
The solution was to move NavigationStack from my TabView to the subviews. So instead of:
struct Tabber: View {
var body: some View {
TabView() {
NavigationStack {
CombatsListView()
}
.tabItem {
Text("Combats")
}
//...
I should do:
struct Tabber: View {
var body: some View {
TabView() {
CombatsListView()
.tabItem {
Text("Combats")
}
//...
struct CombatsListView: View {
var body: some View {
NavigationStack {
Confusing part was that all online tutorials and Apple Documentation suggests to wrap subviews with NavigationStack in TabView directly instead of adding NavigationStack in subviews. Maybe it's a bug, maybe it's a feature.
I have a 3 column layout and for some reason, the navigation bar in the middle column starts off buggy on the initial launch of the app, ie transparent and unresponsive. I have to navigate away and back in order for the navigation bar to function properly. This only happens when I run it on the iPhone simulator, iPad works fine.
Code:
struct ContentView: View {
var body: some View {
NavigationView {
Sidebar()
MainView()
Text("Select mail to read")
}
}
}
struct Sidebar: View {
var body: some View {
List {
Section(header: Text("Browse")
) {
NavigationLink(destination: MainView()) {
Text("Inbox")
}
NavigationLink(destination: SentView()) {
Text("Sent")
}
}
}
.listStyle(SidebarListStyle())
.navigationTitle("Mailboxes")
}
}
struct MainView: View {
#StateObject private var viewModel = ViewModel()
var body: some View {
Group {
if viewModel.isFetching {
ProgressView()
} else {
List {
ForEach(0..<20) { i in
NavigationLink(
destination: DetailView(item: "\(i)"),
label: {
Text("Inbox \(i)")
})
}
}
}
}
.navigationTitle("Inbox")
.listStyle(PlainListStyle())
}
}
struct SentView: View {
var body: some View {
List {
ForEach(0..<5) { i in
NavigationLink(
destination: DetailView(item: "\(i)"),
label: {
Text("Sent \(i)")
})
}
}
.navigationTitle("Sent")
.listStyle(PlainListStyle())
}
}
class ViewModel: ObservableObject {
#Published var isFetching = false
init() {
fetchData()
}
func fetchData() {
isFetching = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.isFetching = false
}
}
}
When running on iPhone:
When running on iPad:
EDIT:
Replaced code and gif with a more generic implementation for brevity.