SwiftUI - TabView/NavigationLink navigation breaks when using a custom binding - swiftui

I'm having trouble with what I think may be a bug, but most likely me doing something wrong.
I have a slightly complex navigation state variable in my model that I'm using for tracking/setting state between tab and sidebar presentations when multitasking on iPad. That all works fine except in tab mode, once I use a navigation link once I can't seem to use one again, whether the binding is on my tab view or navigation links in a list.
Would really appreciate any thoughts on this,
Cheers!
Example
NavigationItem.swift
enum SubNavigationItem: Hashable {
case overview, user, hobby
}
enum NavigationItem: Hashable {
case home(SubNavigationItem)
case settings
}
Model.swift
final class Model: ObservableObject {
#Published var selectedTab: NavigationItem = .home(.overview)
}
SwiftUIApp.swift
#main
struct SwiftUIApp: App {
#StateObject var model = Model()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
}
}
}
ContentView.swift
struct ContentView: View {
var body: some View {
AppTabNavigation()
}
}
AppTabNavigation.swift
struct AppTabNavigation: View {
#EnvironmentObject private var model: Model
var body: some View {
TabView(selection: $model.selectedTab) {
NavigationView {
HomeView()
}
.tabItem {
Label("Home", systemImage: "house")
}
.tag(NavigationItem.home(.overview))
NavigationView {
Text("Settings View")
}
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(NavigationItem.settings)
}
}
}
HomeView.swift
I created a binding here because selection required an optional <NavigationItem?> not
struct HomeView: View {
#EnvironmentObject private var model: Model
var body: some View {
let binding = Binding<NavigationItem?>(
get: {
model.selectedTab
},
set: {
guard let item = $0 else { return }
model.selectedTab = item
}
)
List {
NavigationLink(
destination: Text("Users"),
tag: .home(.user),
selection: binding
) {
Text("Users")
}
NavigationLink(
destination: Text("Hobbies"),
tag: .home(.hobby),
selection: binding
) {
Text("Hobbies")
}
}
.navigationTitle("Home")
}
}
Second Attempt
I tried making the selectedTab property optional as #Lorem Ipsum suggested. Which means I can remove the binding there. But then the TabView doesn't work with the property. So I create a binding for that and have the same issue but with the tab bar!

Make the selected tab optional
#Published var selectedTab: NavigationItem? = .home(.overview)
And get rid of that makeshift binding variable. Just use the variable
$model.selectedTab
If the variable can never be nil then something is always selected IAW with that makeshift variable it will just keep the last value.

Related

Unable to RE-navigate through SwiftUI app

I’m writing a workout app for Apple Watch but am running into issues with navigation. I have a version which includes the behaviour I’m looking for but am trying to simplify the navigation through the app which is leading to issues.
The “working” version of the app (code below) has the user select a workout from a launch page. When the workout is started, a tabbed view is displayed which contains controls on one page and metrics on another, cut down here for the purpose of demonstration. When End on the controls page is pressed, a boolean is set to true forcing a summary view to be displayed. When Done is pressed here, the view is dismissed returning the user to the launch page. A new workout can then be initiated.
As mentioned above, I’m trying to simplify the app, doing away with the workout selection and immediately leading to a start page. From there the functionality is basically the same. However, when I return to the start page, I am unable to initiate another workout without re-launching the app.
I’m just starting out on my SwiftUI journey so can only presume I’m misusing the NavigationStack or misunderstanding the concepts behind it but have not been able to see the issue.
Any assistance would be greatly appreciated.
Thanks much.
struct ContentView: View {
#StateObject private var workoutManager = WorkoutManager()
var body: some View {
NavigationStack {
List(Workout.workouts) { workout in
NavigationLink(value: workout) {
Text(workout.shortName)
}
}
.navigationDestination(for: Workout.self) { workout in
WorkoutSetupView()
}
.navigationDestination(isPresented: $workoutManager.showingSummaryView) {
SummaryView()
}
}
.environmentObject(workoutManager)
}
}
struct Workout: Identifiable, Hashable {
var id: String
var shortName: String
static var workouts: [Workout] {
[
Workout(id: "WORKOUT1", shortName: "Workout 1"),
Workout(id: "WORKOUT2", shortName: "Workout 2")
]
}
}
class WorkoutManager: NSObject, ObservableObject {
#Published var showingSummaryView: Bool = false
func endWorkout() {
showingSummaryView = true
}
}
struct SummaryView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
ScrollView {
VStack{
Text("Results: 2")
Button("Done") {
dismiss()
}
}
}
.navigationTitle("Summary")
.navigationBarBackButtonHidden(true)
}
}
struct WorkoutSetupView: View {
var body: some View {
NavigationLink(
destination: SessionPagingView()) {
Image(systemName: "play")
}
}
}
struct SessionPagingView: View {
#EnvironmentObject var workoutManager: WorkoutManager
#State private var selection: Tab = .session
enum Tab {
case controls, session
}
var body: some View {
TabView(selection: $selection) {
Button {
workoutManager.endWorkout()
} label: {
Text("End")
}.tag(Tab.controls)
Text("0:10").tag(Tab.session)
}
.navigationBarBackButtonHidden(true)
}
}
If I replace the NavigationStack block in ContentView with the code below, the app works in a simplified way as expected but a new workout cannot be initiated without relaunching the app.
NavigationStack {
NavigationLink(
destination: SessionPagingView()) {
Image(systemName: "play")
}
.navigationDestination(isPresented: $workoutManager.showingSummaryView) {
SummaryView()
}
}
I've tried resetting showingSummaryView to false on pressing Done in SummaryView and also tried using a NavigationPath but neither had any noticeable effect.
Thanks

Make List items selectable in edit mode, EditMode example from documentation not working

I want to add the possibility to select items in a list when edit mode is selected, additionally to the delete and move option. Ideally I want to use the existing edit, delete and move buttons instead of writing my own. I tried the example from the documentation. It's not working for me. The value of editMode is always .inactive. I'm using XCode 14. The deployment target of my app is iOS 16.0.
This is my source code:
import SwiftUI
struct ContentView: View {
#Environment(\.editMode)
private var editMode
#State
private var name = "Maria Ruiz"
var body: some View {
NavigationView {
Form {
if editMode?.wrappedValue.isEditing == true {
TextField("Name", text: $name)
} else {
Text("test")
}
}
.animation(nil, value: editMode?.wrappedValue)
.toolbar { // Assumes embedding this view in a NavigationView.
EditButton()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
It always shows the test text. I also tried a variant with the .onChange modifier, with the same result.
Forwarding the fix from https://developer.apple.com/forums/thread/716434:
Try extracting the parts that access the editMode property from the
container that changes based on it, like List/Form.
struct ContentView: View {
var body: some View {
NavigationView {
Form {
MyForm()
}
.toolbar { // Assumes embedding this view in a NavigationView.
EditButton()
}
}
}
}
and
struct MyForm: View {
#Environment(\.editMode)
private var editMode
#State
private var name = "Maria Ruiz"
var body: some View {
Text(String(editMode!.wrappedValue.isEditing))
if editMode?.wrappedValue.isEditing == true {
TextField("Name", text: $name)
} else {
Text("test")
}
}
}

NavigationStack not affected by EnvironmentObject changes

I'm attempting to use #EnvironmentObject to pass an #Published navigation path into a SwiftUI NavigationStack using a simple wrapper ObservableObject, and the code builds without issue, but working with the #EnvironmentObject has no effect. Here's a simplified example that still exhibits the issue:
import SwiftUI
class NavigationCoordinator: ObservableObject {
#Published var path = NavigationPath()
func popToRoot() {
path.removeLast(path.count)
}
}
struct ContentView: View {
#StateObject var navigationCoordinator = NavigationCoordinator()
var body: some View {
NavigationStack(path: $navigationCoordinator.path, root: {
FirstView()
})
.environmentObject(navigationCoordinator)
}
}
struct FirstView: View {
var body: some View {
VStack {
NavigationLink(destination: SecondView()) {
Text("Go To SecondView")
}
}
.navigationTitle(Text("FirstView"))
}
}
struct SecondView: View {
var body: some View {
VStack {
NavigationLink(destination: ThirdView()) {
Text("Go To ThirdView")
}
}
.navigationTitle(Text("SecondView"))
}
}
struct ThirdView: View {
#EnvironmentObject var navigationCoordinator: NavigationCoordinator
var body: some View {
VStack {
Button("Pop to FirstView") {
navigationCoordinator.popToRoot()
}
}
.navigationTitle(Text("ThirdView"))
}
}
I am:
Passing the path into the NavigationStack path parameter
Sending the simple ObservableObject instance into the NavigationStack via the .environmentObject() modifier
Pushing a few simple child views onto the stack
Attempting to use the environment object in ThirdView
NOT crashing when attempting to use the environment object (e.g. "No ObservableObject of type NavigationCoordinator found")
Am I missing anything else that would prevent the deeply stacked view from using the EnvironmentObject to affect the NavigationStack's path? It seems like the NavigationStack just isn't respecting the bound path.
(iOS 16.0, Xcode 14.0)
The reason your code is not working is that you haven't added anything to your path, so your path is empty. You can simply verify this by adding print(path.count) in your popToRoot method it will print 0 in the console.
To work with NavigationPath you need to use navigationDestination(for:destination:) ViewModifier, So for your example, you can try something like this.
ContentView:- Change NavigationStack like this.
NavigationStack(path: $navigationCoordinator.path) {
VStack {
NavigationLink(value: 1) {
Text("Go To SecondView")
}
}
.navigationDestination(for: Int.self) { i in
if i == 1 {
SecondView()
}
else {
ThirdView()
}
}
}
SecondView:- Change NavigationLink like this.
NavigationLink(value: 2) {
Text("Go To ThirdView")
}
This workaround works with Int but is not a better approach, so my suggestion is to use a custom Array as a path. Like this.
enum AppView {
case second, third
}
class NavigationCoordinator: ObservableObject {
#Published var path = [AppView]()
}
NavigationStack(path: $navigationCoordinator.path) {
FirstView()
.navigationDestination(for: AppView.self) { path in
switch path {
case .second: SecondView()
case .third: ThirdView()
}
}
}
Now change NavigationLink in FirstView and SecondView like this.
NavigationLink(value: AppView.second) {
Text("Go To SecondView")
}
NavigationLink(value: AppView.third) {
Text("Go To ThirdView")
}
The benefit of the above is now you can use the button as well to push a new screen and just need to append in your path.
path.append(.second)
//OR
path.append(.third)
This will push a respected view.
For more details, you can read the Apple document of NavigationLink and NavigationPath.

PresentationMode.dismiss weird behaviour when using multiple NavigationLinks inside ForEach

My app has 4 views (let's call them View_A[root] -> View_B -> View_C -> View_D). The navigation between them was made using NavigationView/NavigationLink.
When I call self.presentationMode.wrappedValue.dismiss() from the last view(View_D) I expect it to dismiss the current view (D) only, but for some reason it dismissed ALL the views and stops at view A (root view).
That's weird.
I spent a couple of hours trying to figure out what's going on there and I found that
- if I remove "ForEach" from "View_A" it works correctly and only the last view is dismissed. Even though ForEach gets just 1 static object in this example.
The second weird thing is that
- if I don't change "self.thisSession.stats" to false it also works correctly dismissing only the last view.
This is super weird as View_A (as far as I understand) is not dependent on thisSession environment variable.
Any ideas on how to prevent View_C and View_B from being dismissed in this case? I wanna end up at View_C after clicking the link, not at View_A.
Any help is appreciated, it took me a while to find out where it comes from but I'm not smart enough to proceed any further ;)
import SwiftUI
struct A_View: View {
#EnvironmentObject var thisSession: CurrentSession
var body: some View {
NavigationView {
VStack {
Text("View A")
ForEach([TestObject()], id: \.id) { _ in
NavigationLink(destination: View_B() ) {
Text("Move to View B")
}
}
}
}
}
}
struct View_B: View {
var body: some View {
NavigationView {
NavigationLink(destination: View_C()
) {
Text("GO TO VIEW C")
}
}
}
}
struct View_C: View {
var body: some View {
ZStack {
NavigationView {
NavigationLink(destination: View_D()) {
Text("GO TO VIEW D")
}
}
}
}
}
struct View_D: View {
#EnvironmentObject var thisSession: CurrentSession
#Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
var body: some View {
ZStack {
VStack {
Button(action: {
self.thisSession.stats = false
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Return!")
}
}
}
}
}
class CurrentSession: ObservableObject {
#Published var stats: Bool = false
#Published var user: String = "user"
}
struct TestObject: Identifiable, Codable {
let id = UUID()
}
Your issue is with:
NavigationView
There is only supposed to be one NavigationView in an entire view stack. Try removing the NavigationView from views B and C

How to disable NavigationView push and pop animations

Given this simple NavigationView:
struct ContentView : View {
var body: some View {
NavigationView {
VStack {
NavigationLink("Push Me", destination: Text("PUSHED VIEW"))
}
}
}
}
Did anyone find a way of disabling the NavigationView animation when a destination view is pushed/popped into/from the stack?
This has been possible in UIKit since iOS2.0! I think it is not too much to ask from the framework. I tried all sorts of modifiers on all views (i.e., the NavigationView container, the destination view, the NavigationLink, etc)
These are some of the modifiers I tried:
.animation(nil)
.transition(.identity)
.transaction { t in t.disablesAnimations = true }
.transaction { t in t.animation = nil }
None made a difference. I did not find anything useful in the EnvironmentValues either :-(
Am I missing something very obvious, or is the functionality just not there yet?
Xcode 11.3:
Right now there is no modifier to disable NavigationView animations.
You can use your struct init() to disable animations, as below:
struct ContentView : View {
init(){
UINavigationBar.setAnimationsEnabled(false)
}
var body: some View {
NavigationView {
VStack {
NavigationLink("Push Me", destination: Text("PUSHED VIEW"))
}
}
}
}
First you need state for the NavigationLink to respond to, then set that state inside a transaction with animations disabled, as follows:
struct ContentView : View {
#State var isActive = false
var body: some View {
NavigationView {
VStack {
NavigationLink(isActive: $isActive, destination: {
Text("PUSHED VIEW")}) {
Text("Push Me")
}
Button("Navigate Without Animation") {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
isActive = true
}
}
}
}
}
}
I recently created an open source project called swiftui-navigation-stack (https://github.com/biobeats/swiftui-navigation-stack) that contains the NavigationStackView, a view that mimics the navigation behaviours of the standard NavigationView adding some useful features. For example, you could use the NavigationStackView and disable the transition animations as requested by Kontiki in the question. When you create the NavigationStackView just specify .none as transitionType:
struct ContentView : View {
var body: some View {
NavigationStackView(transitionType: .none) {
ZStack {
Color.yellow.edgesIgnoringSafeArea(.all)
PushView(destination: View2()) {
Text("PUSH")
}
}
}
}
}
struct View2: View {
var body: some View {
ZStack {
Color.green.edgesIgnoringSafeArea(.all)
PopView {
Text("POP")
}
}
}
}
PushView and PopView are two views that allow you push and pop views (similar to the SwiftUI NavigationLink). Here is the complete example:
import SwiftUI
import NavigationStack
struct ContentView : View {
var body: some View {
NavigationStackView(transitionType: .none) {
ZStack {
Color.yellow.edgesIgnoringSafeArea(.all)
PushView(destination: View2()) {
Text("PUSH")
}
}
}
}
}
struct View2: View {
var body: some View {
ZStack {
Color.green.edgesIgnoringSafeArea(.all)
PopView {
Text("POP")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
The result is:
It would be great if you guys joined me in improving this open source project.