SwiftUI StackNavigationViewStyle will not update NavigationLink selection state - ios

I recognized that my iOS app does have a double column navigation view style resp. split view for larger iPhones, like the iPhone 11 Pro Max, compared to a single column navigation.
I tried to get rid of this unwanted split view according to SwiftUI: unwanted split view on iPad by applying the .navigationViewStyle(StackNavigationViewStyle()) modifier to the NavigationView.
However, that introduces a new issue, where SwiftUI does not update the NavigationLink selection state after returning from a detail view. After coming back, the link is still shown to be active. After removing the .navigationViewStyle(StackNavigationViewStyle()) again, the selection state is updated correctly, so I think I am missing something.
I created a minimal reproducible example project. Please see the code below.
This image demonstrates the issue of a non-updating NavigationLink selection state after returning from a detail view when using the .navigationViewStyle(StackNavigationViewStyle()) modifier.
Minimal reproducible example project:
import SwiftUI
#main
struct TestSwiftUIApp: App {
var body: some Scene {
WindowGroup {
View1()
}
}
}
struct View1: View {
var body: some View {
NavigationView {
List {
NavigationLink(
destination: View2(),
label: {
Text("View2")
}
).isDetailLink(false)
NavigationLink(
destination: View2(),
label: {
Text("View2")
}
).isDetailLink(false)
}.listStyle(InsetGroupedListStyle())
.navigationTitle("View1")
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct View2: View {
#State var presentView3: Bool = false
var body: some View {
List {
Text("Foo")
NavigationLink("View3",
destination: View3(presentView3: $presentView3),
isActive: $presentView3
).isDetailLink(false)
Text("Bar")
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("View 2")
}
}
struct View3: View {
#Binding var presentView3: Bool
#State
var isAddViewPresented: Bool = false
var body: some View {
List {
Button(action: {presentView3 = false}, label: {
Text("Dismiss")
})
}
.listStyle(InsetGroupedListStyle())
.navigationTitle("View3")
.toolbar {
ToolbarItem {
Button(action: {isAddViewPresented.toggle()}, label: {
Label("Add", systemImage: "plus.circle.fill")
})
}
}
.sheet(isPresented: $isAddViewPresented, content: {
Text("DestinationDummyView")
})
}
}

Related

SwiftUI NavigationView with List programmatic navigation does not work

I am trying to do programmatic navigation in NavigationView, but for some reason I am unable to switch between the views. When switching from the parent view everything works fine - but as soon as I am trying to switch while being in one of the child views I get this strange behaviour (screen is switching back and forth). I tried disabling animations, but this did not help. Strangely enough, if I remove a list together with .navigationViewStyle(StackNavigationViewStyle()) everything starts to work - but I need a list.
This seems to be somewhat similar to Deep programmatic SwiftUI NavigationView navigation but I do not have deep nesting and it still does not work.
I am using iOS 14.
struct TestView: View {
#State private var selection: String? = nil
var body: some View {
VStack {
NavigationView {
VStack {
List {
NavigationLink(destination: Text("View A"), tag: "A", selection: self.$selection) { Text("A") }
NavigationLink(destination: Text("View B"), tag: "B", selection: self.$selection) { Text("B") }
}
}
.navigationTitle("Navigation")
}
.navigationViewStyle(StackNavigationViewStyle())
Button("Tap to show A") {
selection = "A"
}.padding()
Button("Tap to show B") {
selection = "B"
}.padding()
}
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
Here is the behaviour i get:
Navigation View/Link is meant to operate from parent to child directly, if you break that order then you should not use navigate via NavLink.
What you need to do is use a fullScreenCover which I think solves your problem nicely. Copy and paste the code to see what I mean.
import SwiftUI
struct TestNavView: View {
#State private var selection: String? = nil
#State private var isShowing = false
#Environment(\.presentationMode) var pMode
var body: some View {
VStack {
NavigationView {
VStack {
List {
NavigationLink(destination: Text("View A"), tag: "A", selection: self.$selection) { Text("A") }
NavigationLink(destination: Text("View B"), tag: "B", selection: self.$selection) { Text("B") }
}.fullScreenCover(isPresented: $isShowing, content: {
CView()
})
}
.navigationTitle("Navigation")
}
.navigationViewStyle(StackNavigationViewStyle())
Button("Tap to show A") {
selection = "A"
}.padding()
Button("Tap to show B") {
isShowing = true
selection = "B"
}.padding()
Button("Tap to show C") {
isShowing = true
}.padding()
}
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestNavView()
}
}
struct CView: View {
#Environment(\.presentationMode) var pMode
var body: some View {
VStack {
Button("Back") {self.pMode.wrappedValue.dismiss() }
Spacer()
Text("C")
Spacer()
}
}
}
If you are only wanting the presented view to take up half the screen, I would recommend using a ZStack to present the view over top of the main window.
You can add your own custom back button to the top left corner (or elsewhere).
This would allow both views to presented and switched between easily.
You can also add a withAnimation() to have the overlayed views to present nicely.

SwiftUI updating #Binding value in NavigationLink(destination:label) causes destination to reappear

I have a weird issue, where I navigate to EditView and edit a state variable from ContentView. After that I dismiss EditView with presentation.wrappedValue.dismiss(). The problem is, as soon as the view is dismissed, it reappears again.
I'm using XCode 12.4 and my iOS deployment target is set to 14.4
Observations:
EditView doesn't reappear if the value isn't edited
removing the changed value Text("Value: \(title)") >> Text("Value") from ContentView tree resolves the issue, but that obviously isn't a solution.
moving the NavigationLink from .toolbar e.g.
VStack {
Text("Value: \(title)")
NavigationLink(destination: EditView(title: $title)){
Text("Edit")
}
}
also resolves the issue, but that seems like a hack. Besides, I'd like to keep using .toolbar because I like the .navigationTitle animation and I can't have a button in the upper-right corner of the screen if I have a navigation title without the toolbar.
Here's the full code:
import SwiftUI
struct ContentView: View {
#State var title: String = "Title"
#State var isActive: Bool = false
var body: some View {
NavigationView {
Text("Value: \(title)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
NavigationLink(destination: EditView(title: $title, isActive: $isActive), isActive: $isActive){
Text("Edit")
}
}
}
}
}
}
struct EditView: View {
#Environment(\.presentationMode) var presentation
#Binding var title: String
#Binding var isActive: Bool
var body: some View {
Button(action: {
title = "\(Date().timeIntervalSince1970)"
// presentation.wrappedValue.dismiss()
isActive = false
}){
Text("Done")
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
As far as I can tell this is a .toolbar bug, and if it turns out that way I'll report it to Apple, but in the meantime, does anyone have a better solution and/or explanation for this?
Cheers!
EDIT:
I updated the code with isActive value for the NavigationLink. It doesn't work when written like that, but uncommenting the commented out line makes it work. But that's quite hacky.
You are mixing up 2 things NavigationLink will push the view on stack, just like NavigationController in swift. That’s why you can see back button after navigating to second view. When you hit back button, it will pop top most view out of stack. presentationMode is not needed , dismissing presented view will not pop it of the stack.
To present a view and dismiss it you can check below code.
import SwiftUI
struct ContentViewsss: View {
#State var title: String = "Title"
#State var isPresented = false
var body: some View {
NavigationView {
Text("Value: \(title)")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
isPresented.toggle()
}){
Text("Edit")
}
}
}
}.sheet(isPresented: $isPresented, content: {
EditView(title: $title, state: $isPresented)
})
}
}
struct EditView: View {
#Binding var title: String
#Binding var state: Bool
var body: some View {
Button(action: {
title = "\(Date().timeIntervalSince1970)"
state.toggle()
}){
Text("Done")
}
}
}
If you want NavigationLink functionality, you can just remove presentationMode code from second view, and keep ContentView as it is -:
struct EditView: View {
//#Environment(\.presentationMode) var presentation
#Binding var title: String
var body: some View {
Button(action: {
title = "\(Date().timeIntervalSince1970)"
// presentation.wrappedValue.dismiss()
}){
Text("Done")
}
}
}

NavigationTitle visual glitches - transparent and not changing state from .large to .inline on scroll

The .navigationTitle on some views seem to be having some problems. On some views (and only some of the time), the .navigationTitle will not change from .large to .inline as would be expected. Instead, the title stays in place when scrolling up, and the navigation bar is completely invisible (as outlined in the video below). This is all reproducible every time.
Video of reproducible .navigationTitle bugs
I haven't found any people on stack overflow or the Apple Developer forums who have run into this exact issue. There have some people who have produced similar results as this, but those were all fixed by removing some stylizing code to the .navigationbar, of which I am not making any modifications to it anywhere in my code.
Below are some snippets of my code:
import SwiftUI
struct WelcomeUI: View {
var body: some View {
NavigationView {
VStack {
//NavigationLink(destination: SignupUI(), label: {
//Text("Sign Up")
//}
NavigationLink(destination: LoginUI(), label: {
Text("Log In")
})
}
}
}
}
struct LoginUI: View {
var body: some View {
VStack {
NavigationLink(destination: MainUI(), label: { Text("Log In") })
//Button(action: { ... }
}
.navigationBarHidden(false)
}
}
struct MainUI: View {
#State var selectedTab: Views = .add
var body: some View {
TabView(selection: $selectedTab) {
SpendingView()
.tabItem {
Image(systemName: "bag.circle")
Text("Spending")
}.tag(Views.spending)
Text("Adding View")
.tabItem {
Image(systemName: "plus")
Text("Add")
}.tag(Views.add)
Text("Edit View")
.tabItem {
Image(systemName: "pencil")
Text("Edit")
}.tag(Views.edit)
SettingsView()
.tabItem {
Image(systemName: "gear")
Text("Settings")
}.tag(Views.settings)
}
.navigationBarTitle(Text(selectedTab.rawValue))
.navigationBarBackButtonHidden(true)
}
}
enum Views: String {
case spending = "Spending"
case add = "Add"
case edit = "Edit"
case settings = "Settings"
}
struct SettingsView: View {
var body: some View {
VStack{
ZStack {
Form {
Section(header: Text("Section Header")) {
NavigationLink(destination: WelcomeUI()) {
Text("Setting Option")
}
}
Section {
//Button("Log Out") {
//self.logout()
//}
Text("Log Out")
}
}
Button("say-high", action: {print("Hi")})
}
}
}
}
struct SpendingView: View {
var body: some View {
ScrollView{
Text("SpendingView")
NavigationLink("subSpending", destination: SubSpendingView())
}.padding()
}
}
struct SubSpendingView: View {
var body: some View {
ScrollView{
Text("SubSpendingView")
}.navigationBarTitle("SubSpending")
}
}
It almost seems like a bug in SwiftUI itself just because the fact that bringing down the control centre makes it kind of work, but with no animation (as seen in the video). Also, changing which view is selected first in #State var selectedTab: Views seems to let the view selected to work as expected, but lets the rest of the tabs mess up.
When I build and run the app on my iPad, it behaves as expected with no bugs, it's only when run on my iPhone and the iOS simulator on Mac that it does this, any way to fix this?
For this to work flawlessly the ScrollView needs to be the direct child of the NavigationView. I ran into a similar issue with wanting to dismiss the TabView when I navigating but SwiftUI won't let that happen. Each tab needs to be a NavigationView and you need to dismiss the TabView creatively if that is what you want.
TabView {
NavigationView {
ScrollView {
// your view here
}
}.tabItem {
// tab label
}
// etc
}
Essentially the navigation view needs to be a child (in the brackets) of the tab view and the scrollview needs to be the direct child of the navigation view.
Use navigationBarTitle("Title") and navigationBarBackButtonHidden(true) on the TabView's sub-view, not on itself.
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
}
.navigationBarTitle("Title")
.navigationBarBackButtonHidden(true)
}
}
}

Why onAppear called again after onDisappear while switching tab in TabView in SwiftUI?

I am calling API when tab item is appeared if there is any changes. Why onAppear called after called onDisappear?
Here is the simple example :
struct ContentView: View {
var body: some View {
TabView {
NavigationView {
Text("Home")
.navigationTitle("Home")
.onAppear {
print("Home appeared")
}
.onDisappear {
print("Home disappeared")
}
}
.tabItem {
Image(systemName: "house")
Text("Home")
}.tag(0)
NavigationView {
Text("Account")
.navigationTitle("Account")
.onAppear {
print("Account appeared")
}
.onDisappear {
print("Account disappeared")
}
}
.tabItem {
Image(systemName: "gear")
Text("Account")
}.tag(1)
}
}
}
Just run above code and we will see onAppear after onDisappear.
Home appeared
---After switch tab to Account---
Home disappeared
Account appeared
Home appeared
Is there any solution to avoid this?
It's very annoying bug, imagine this scenario:
Home view onAppear method contains a timer which is fetching data repeatedly.
Timer is triggered invisibly by switching to the Account view.
Workaround:
Create a standalone view for every embedded NavigationView content
Pass the current selection value on to standalone view as #Binding parameter
E.g.:
struct ContentView: View {
#State var selected: MenuItem = .HOME
var body: some View {
return TabView(selection: $selected) {
HomeView(selectedMenuItem: $selected)
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
VStack {
Image(systemName: "house")
Text("Home")
}
}
.tag(MenuItem.HOME)
AccountView(selectedMenuItem: $selected)
.navigationViewStyle(StackNavigationViewStyle())
.tabItem {
VStack {
Image(systemName: "gear")
Text("Account")
}
}
.tag(MenuItem.ACCOUNT)
}
}
}
enum MenuItem: Int, Codable {
case HOME
case ACCOUNT
}
HomeView:
struct HomeView: View {
#Binding var selectedMenuItem: MenuItem
var body: some View {
return Text("Home")
.onAppear(perform: {
if MenuItem.HOME == selectedMenuItem {
print("-> HomeView")
}
})
}
}
AccountView:
struct AccountView: View {
#Binding var selectedMenuItem: MenuItem
var body: some View {
return Text("Account")
.onAppear(perform: {
if MenuItem.ACCOUNT == selectedMenuItem {
print("-> AccountView")
}
})
}
}
To whom it may help.
Because this behaviour I only could reproduce on iOS 14+, I end up using https://github.com/NicholasBellucci/StatefulTabView (which properly only get called when showed; but don't know if it's a bug or not, but it works with version 0.1.8) and TabView on iOS 13+.
I'm not sure why you are seeing that behaviour in your App. But I can explain why I was seeing it in my App.
I had a very similar setup to you and was seeing the same behaviour running an iOS13 App on iOS14 beta. In my Home screen I had a custom Tab Bar that would animate in and out when a detail screen was displayed. The code for triggering the hiding of the Tab Bar was done in the .onAppear of the Detail screen. This was triggering the Home screen to be redrawn and the .onAppear to be called. I removed the animation and found a much better set up due to this bug and the Home screen .onAppear stopped being called.
So if you have something in your Account Screen .onAppear that has a visual effect on the Home Screen then try commenting it out and seeing if it fixes the issue.
Good Luck.
I have been trying to understand this behavior for a number of days now. If you are working with a TabView, all of your onAppears() / onDisapear() will fire immediately on app init and never again. Which actually makes since I guess?
This was my solution to fix this:
import SwiftUI
enum TabItems {
case one, two
}
struct ContentView: View {
#State private var selection: TabItems = .one
var body: some View {
TabView(selection: $selection) {
ViewOne(isSelected: $selection)
.tabBarItem(tab: .one, selection: $selection)
ViewTwo(isSelected: $selection)
.tabBarItem(tab: .two, selection: $selection)
}
}
}
struct ViewOne: View {
#Binding var isSelected: TabItems
var body: some View {
Text("View One")
.onChange(of: isSelected) { _ in
if isSelected == .one {
// Do something
}
}
}
}
struct ViewTwo: View {
#Binding var isSelected: TabItems
var body: some View {
Text("View Two")
.onChange(of: isSelected) { _ in
if isSelected == .two {
// Do something
}
}
}
}
View Modifier for custom TabView
struct TabBarItemsPreferenceKey: PreferenceKey {
static var defaultValue: [TabBarItem] = []
static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) {
value += nextValue()
}
}
struct TabBarItemViewModifer: ViewModifier {
let tab: TabBarItem
#Binding var selection: TabBarItem
func body(content: Content) -> some View {
content
.opacity(selection == tab ? 1.0 : 0.0)
.preference(key: TabBarItemsPreferenceKey.self, value: [tab])
}
}
extension View {
func tabBarItem(tab: TabBarItem, selection: Binding<TabBarItem>) -> some View {
modifier(TabBarItemViewModifer(tab: tab, selection: selection))
}
}

SwiftUI modal presentation works only once from navigationBarItems

Here is a bug in SwiftUI when you show modal from button inside navigation bar items.
In code below Button 1 works as expected, but Button 2 works only once:
struct DetailView: View {
#Binding var isPresented: Bool
#Environment (\.presentationMode) var presentationMode
var body: some View {
NavigationView {
Text("OK")
.navigationBarTitle("Details")
.navigationBarItems(trailing: Button(action: {
self.isPresented = false
// or:
// self.presentationMode.wrappedValue.dismiss()
}) {
Text("Done").bold()
})
}
}
}
struct ContentView: View {
#State var showSheetView = false
var body: some View {
NavigationView {
Group {
Text("Master")
Button(action: { self.showSheetView.toggle() }) {
Text("Button 1")
}
}
.navigationBarTitle("Main")
.navigationBarItems(trailing: Button(action: {
self.showSheetView.toggle()
}) {
Text("Button 2").bold()
})
}.sheet(isPresented: $showSheetView) {
DetailView(isPresented: self.$showSheetView)
}
}
}
This bug is from the middle of the last year, and it still in Xcode 11.3.1 + iOS 13.3 Simulator and iOS 13.3.1 iPhone XS.
Is here any workaround to make button work?
EDIT:
Seems to be tap area goes somewhere down and it's possible to tap below button to show modal.
Temporary solution to this is to use inline navigation bar mode:
.navigationBarTitle("Main", displayMode: .inline)
Well, the issue is in bad layout (seems broken constrains) of navigation bar button after sheet has closed
It is clearly visible in view hierarchy debug:
Here is a fix (workaround of course, but safe, because even after issue be fixed it will continue working). The idea is not to fight with broken layout but just create another button, so layout engine itself remove old-bad button and add new one refreshing layout. The instrument for this is pretty known - use .id()
So modified code:
struct ContentView: View {
#State var showSheetView = false
#State private var navigationButtonID = UUID()
var body: some View {
NavigationView {
Group {
Text("Master")
Button(action: { self.showSheetView.toggle() }) {
Text("Button 1")
}
}
.navigationBarTitle("Main")
.navigationBarItems(trailing: Button(action: {
self.showSheetView.toggle()
}) {
Text("Button 2").bold() // recommend .padding(.vertical) here
}
.id(self.navigationButtonID)) // force new instance creation
}
.sheet(isPresented: $showSheetView) {
DetailView(isPresented: self.$showSheetView)
.onDisappear {
// update button id after sheet got closed
self.navigationButtonID = UUID()
}
}
}
}

Resources