Navigationbar- / Toolbar Button not working reliable when #State variable refresh the view with a high frequency - swift

I noticed that Navigationbar- / Toolbar Buttons are not working properly when there is at least one #State variable refreshing the View very often.
I created a simple app to test it.
With below Code example you have 3 options to trigger a modal sheet. One Button in the main View, one in the toolbar and one in the navigationbar.
When my timer doesn't update "number" all 3 buttons are working properly. When i start the Timer which will refresh the view every 0,1 second only the button in the main view will work every time. The buttons in toolbar / navigationbar do not work most of the time. (The shorter the TimeInterval of my timer the less the buttons are working)
import SwiftUI
struct ContentView: View {
#State private var number = 0
#State private var showModal = false
func startTimer() {
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { (_) in
number += 1
}
}
var body: some View {
NavigationView {
VStack {
Text("Count \(number)")
Button(action: startTimer, label: {
Text("Start Timer")
})
Button(action: { showModal.toggle() }, label: {
Text("Open Modal")
})
}
.navigationBarTitle("Home", displayMode: .inline)
.navigationBarItems(leading:
Button(action: {
showModal.toggle()
}, label: {
Text("Open Modal")
}))
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button(action: {
showModal.toggle()
}, label: {
Text("Open Modal")
})
}
}
}
.sheet(isPresented: $showModal, content: {
Text("Hello, World!")
})
}
}
Does anyone have an idea if there is a way to make the 2 buttons work properly?
This issue occurs with Xcode 12 beta 5 and Xcode Xcode 11.6 (without toolbar as it's not available there)

The number state in provided variant refreshes entire view, that is a result of issue, and not very optimal for UI.
The solution for navigation bar, and in general good style, is to separate refreshing part into standalone view, so SwiftUI rendering engine rebuild only it.
Tested with Xcode 12b3 / iOS 14
struct TestOftenUpdate: View {
#State private var showModal = false
var body: some View {
NavigationView {
VStack {
QuickTimerView()
Button(action: { showModal.toggle() }, label: {
Text("Open Modal")
})
}
.navigationBarTitle("Home", displayMode: .inline)
.navigationBarItems(leading:
Button(action: {
showModal.toggle()
}, label: {
Text("Open Modal")
}))
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button(action: {
showModal.toggle()
}, label: {
Text("Open Modal")
})
}
}
}
.sheet(isPresented: $showModal, content: {
Text("Hello, World!")
})
}
}
struct QuickTimerView: View {
#State private var number = 0
var body: some View {
VStack {
Text("Count \(number)")
Button(action: startTimer, label: {
Text("Start Timer")
})
}
}
func startTimer() {
Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { (_) in
number += 1
}
}
}
With toolbar is different issue - it does not work after first sheet open even without timer view, and here is why
as it is seen the button layout has corrupted and outside of toolbar area, that is why hit testing fails - and this is Apple defect that should be reported.
Workaround 1:
Place toolbar outside navigation view (visually toolbar is smaller, but sometimes might be appropriate, because button works)
NavigationView {
// ... other code
}
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button(action: {
showModal.toggle()
}, label: {
Text("Open Modal")
})
}
}

Xcode 13.1, iPhone 13 simulator target (and canvas preview).
None of the other solutions worked for me, but I did discover that this only affects top navigation buttons AND if there is a large nav bar title.
So the things that worked seem to be:
Setting an inline title
.navigationBarTitleDisplayMode(.inline)
No title
No top edge toolbar (bottom bar only)
If the problem did manifest, I could pull down on the list contained within the view (no need for refreshable to be enabled) and this would also restore the button functionality.

Related

Interacting with a confirmationDialog or alert is causing the parent view to pop

When you navigate and open the confirmation dialog. When you select Yes, No or Cancel the page that the app was on is dismissed and it takes you back to the form on the previous page.
We also found this happens with alerts too.
It's a simple enough app structure, top level tabs then a menu which links to sub pages.
Here is a quick demo of the bug:
We put together an example app that demonstrates this.
How can we prevent this from happening while also maintaining the app structure?
import SwiftUI
#main
struct testApp: App {
var body: some Scene {
WindowGroup {
NavigationView {
TabView() {
Form {
NavigationLink(destination: SubPage()) {
Image(systemName: "clock")
Text("Sub Page")
}
// This is where more menu options would be
}
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
struct SubPage: View {
#State private var confirmDialogVisible = false
var body: some View {
VStack {
Button{
confirmDialogVisible = true
} label: {
Text("popup")
}
}
.confirmationDialog("Confirm?", isPresented: $confirmDialogVisible) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
}
We are using XCode 14.1
And running on iOS 16.1
I usually use ViewModifer to keep consistency between tabs. One modifier for the root of the tab and one for the children.
///Modifier that uses `ToolbarViewModifier` and includes a `NavigationView`
struct NavigationViewModifier: ViewModifier{
func body(content: Content) -> some View {
NavigationView{
content
.modifier(ToolbarViewModifier())
}
}
}
///`toolbar` that can be used by the root view of the navigation
///and the children of the navigation
struct ToolbarViewModifier: ViewModifier{
let title: String = "Company Name"
func body(content: Content) -> some View {
content
.toolbar {
ToolbarItem(placement: .principal) {
VStack{
Image(systemName: "sparkles")
Text(title)
}
}
}
}
}
Then the Views use it something like this.
import SwiftUI
struct CustomTabView: View {
var body: some View {
TabView{
ForEach(0..<4){ n in
CustomChildView(title: n.description)
//Each tab gets a `NavigationView` and the shared toolbar
.modifier(NavigationViewModifier())
.tabItem {
Text(n.description)
}
}
}
}
}
struct CustomChildView: View {
let title: String
#State private var showConfirmation: Bool = false
var body: some View {
VStack{
Text(title)
NavigationLink {
CustomChildView(title: "\(title) :: \(UUID().uuidString)")
} label: {
Text("open child")
}
Button("show confirmation") {
showConfirmation.toggle()
}
.confirmationDialog("Confirm?", isPresented: $showConfirmation) {
Button("Yes") {
print("yes")
}
Button("No", role: .destructive) {
print("no")
}
}
}
//Each child uses the shared toolbar
.modifier(ToolbarViewModifier())
}
}
I stuck with NavigationView since that is what you have in your code but
if we take into consideration the new NavigationStack the possibilities of these two modifiers become exponentially better.
You can include custom back buttons that only appear if the path is not empty, return to the root from anywhere, etc.
Apple says that
Tab bars use bar items to navigate between mutually exclusive panes of content in the same view
Make sure the tab bar is visible when people navigate to different areas in your app
https://developer.apple.com/design/human-interface-guidelines/components/navigation-and-search/tab-bars/
Having the NavigationView or NavigationStack on top goes against these guidelines and therefore are the source of endless bugs, Especially when you take into consideration iPadOS.
Simple solution would be to use navigationDestination.
struct testApp: App {
#State var goToSubPage = false
var body: some Scene {
WindowGroup {
NavigationStack {
TabView() {
Form {
VStack {
Image(systemName: "clock")
Text("Sub Page")
}
.onTapGesture {
goToSubPage = true
}
// This is where more menu options would be
}
.navigationDestination(isPresented: $goToSubPage, destination: {
SubPage()
})
.tag(1)
.tabItem {
Image(systemName: "square.grid.2x2")
Text("Tab 1")
}
// This is where more tab pages would be
}
}
}
}
}
I tested it and it won't popped off itself anymore.

Why doesn't the button's image background show up in toolbar using SwiftUI?

In Apple's calendar app, they provide a toolbar item that toggles its style based on some state. It essentially acts as a Toggle. I'm trying to re-create this same thing in SwiftUI and make it work well in both light and dark mode. I was able to make a view that works as intended, until I put it into the toolbar and it no longer shows the selected state. Here is my attempt:
struct ToggleButtonView: View {
#State private var isOn = false
var body: some View {
Button(action: {
isOn.toggle()
}, label: {
if isOn {
Image(systemName: "list.bullet.below.rectangle")
.accentColor(Color(.systemBackground))
.background(RoundedRectangle(cornerRadius: 5.0)
.fill(Color.accentColor)
.frame(width: 26, height: 26))
} else {
Image(systemName: "list.bullet.below.rectangle")
}
})
.accentColor(.red)
}
}
And here is how I am actually placing the button into the toolbar:
struct TestView: View {
var body: some View {
NavigationView {
ScrollView {
ForEach(0..<5) { number in
Text("Number \(number)")
}
}
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
ToggleButtonView()
Button(action: {}, label: {
Image(systemName: "plus")
})
}
}
.navigationTitle("Plz halp")
}
.accentColor(.red)
}
}
Here are screenshots from the calendar app. Notice the toolbar item to the left of the search icon.
you could try this:
.toolbar {
// placement as you see fit
ToolbarItem(placement: .navigationBarTrailing) {
ToggleButtonView()
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {}, label: {
Image(systemName: "plus")
})
}
}
This looks like an issue with how SwiftUI handles ToolbarItems before iOS 15. According to Asperi's answer to a similar question, "...all standards types (button, image, text, etc) are intercepted by ToolbarItem and converted into an appropriate internal representation."
Toggle buttons in SwiftUI iOS 15
Interestingly enough, iOS 15 now provides a standard solution to the use-case above using the .button toggle style, as shown in the following code:
struct ContentView: View {
#State private var isOn = false
var body: some View {
Toggle(isOn: $isOn) {
Image(systemName: "list.bullet.below.rectangle")
}
}
}

SwiftUI sheet not animating dismissal on macOS Big Sur

I would like the sheet dismissal animated just like the appearance but reversed. I think this is also the standard behavior. You can see it in Xcode for example when you create a new file.
But as you can see it just disappears without animation
Here's my code:
struct ContentView: View {
#State var isAnotherViewPresented: Bool = false
var body: some View {
HStack {
Button(action: {
isAnotherViewPresented.toggle()
}, label: {
Text("Button")
}).sheet(isPresented: $isAnotherViewPresented, content: {
AnotherView()
})
}
.frame(width: 500, height: 300, alignment: .center)
}
}
struct AnotherView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
Text("Close")
})
}.padding()
}
}
I'm on
Mac mini (M1, 2020)
macOS Big Sur 11.1 (20C69)
Xcode 12.3 (12C33)
But I can reproduce this on a
Mac mini (2018)
macOS Big Sur 11.0.1 (20B29)
Xcode 12.2 (12B45b)
I finally figured out how to do it, in my SwiftUI app it works if I do this while closing the sheet:
isSheetVisible = false
NSApp.mainWindow?.endSheet(NSApp.keyWindow!)
Example:
struct SheetView: View {
#Binding var isSheetVisible: Bool
var body: some View {
Button("Close") {
isSheetVisible = false
NSApp.mainWindow?.endSheet(NSApp.keyWindow!)
}
}
}
Disclaimer: I had/have the same problem where if I try to dismiss a sheet through a binding, it just disappears instead of having an animation. The below solution worked for me but I am unclear as to why its working.
Solution
Apparently the view you "attach" a modal to has an impact on how it transitions from being presented to not. For instance, in your code the sheet is attached to the button view:
Button(action: {
isAnotherViewPresented.toggle()
}, label: {
Text("Button")
// sheet is attached here
}).sheet(isPresented: $isAnotherViewPresented, content: {
AnotherView()
})
When you call presentationMode.wrappedValue.dismiss() in the second view the modal jolts and disappears instead of sliding away. However, if you attach the sheet to the outer HStack view, then it works and it slides away as expected:
var body: some View {
HStack {
Button(action: {
isAnotherViewPresented.toggle()
}, label: {
Text("Button")
})
}
.frame(width: 500, height: 300, alignment: .center)
.sheet(isPresented: $isAnotherViewPresented, content: {
AnotherView()
})
// sheet is now here
}
For me as long as the sheet wasn't attached to the button the animation worked. I don't know why this works but it did for me and hopefully it will for you as well.
I like this mix:
assuming you have:
#Environment(\.presentationMode) var presentationMode
then:
presentationMode.wrappedValue.dismiss() // this updates the binding from .sheet(isPresented: ...) to false
NSApp.mainWindow?.endSheet(NSApp.keyWindow!) // this runs the animation

SwiftUI - Navigation bar button not clickable after sheet has been presented

I have just started using SwiftUI a couple of weeks ago and i'm learning. Today I ran into a into an issue.
When I present a sheet with a navigationBarItems-button and then dismiss the ModalView and return to the ContentView I find myself unable to click on the navigationBarItems-button again.
My code is as follows:
struct ContentView: View {
#State var showSheet = false
var body: some View {
NavigationView {
VStack {
Text("Test")
}.sheet(isPresented: self.$showSheet) {
ModalView()
}.navigationBarItems(trailing:
Button(action: {
self.showSheet = true
}) {
Text("SecondView")
}
)
}
}
}
struct ModalView: View {
#Environment(\.presentationMode) var presentation
var body: some View {
VStack {
Button(action: {
self.presentation.wrappedValue.dismiss()
}) {
Text("Dismiss")
}
}
}
}
I think this happens because the presentationMode is not inherited from the presenter view, so the presenter didn't know that the modal is already closed. You can fix this by adding presentationMode to presenter, in this case to ContentView.
struct ContentView: View {
#Environment(\.presentationMode) var presentation
#State var showSheet = false
var body: some View {
NavigationView {
VStack {
Text("Test")
}.sheet(isPresented: self.$showSheet) {
ModalView()
}.navigationBarItems(trailing:
Button(action: {
self.showSheet = true
}) {
Text("SecondView")
}
)
}
}
}
Tested on Xcode 12.5.
Here is the full working
example.
This seems to be a bug in SwiftUI. I am also still seeing this issue with Xcode 11.5 / iOS 13.5.1. The navigationBarMode didn't make a difference.
I filed an issue with Apple:
FB7641003 - Taps on a navigationBarItem Button presenting a sheet sometimes not recognized
You can use the attached example project SwiftUISheet (also available via https://github.com/ralfebert/SwiftUISheet) to reproduce the issue. It just presents a sheet from a navigation bar button.
Run the app and tap repeatedly on the 'plus' button in the nav bar. When the sheet pops up, dismiss it by sliding it down. Only some taps to the button will be handled, often a tap is ignored.
Tested on Xcode 11.4 (11E146) with iOS 13.4 (17E255).
Very hacky but this worked for me:
Button(action: {
self.showSheet = true
}) {
Text("SecondView")
.frame(height: 96, alignment: .trailing)
}
I'm still seeing this issue with Xcode 13 RC and iOS 15. Unfortunately the solutions above didn't work for me. What I ended up doing is adding a small Text view to the toolbar whose content changes depending on the value of the .showingSheet property.
struct ContentView: View {
#State private var showingSheet = false
var body: some View {
NavigationView {
VStack {
Text("Content view")
Text("Swift UI")
}
.sheet(isPresented: $showingSheet) {
Text("This is a sheet")
}
.navigationTitle("Example")
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
// Text view workaround for SwiftUI bug
// Keep toolbar items tappable after dismissing sheet
Text(showingSheet ? " " : "").hidden()
Button(action: {
self.showingSheet = true
}) {
Label("Show Sheet", systemImage: "plus.square")
}
}
}
}
}
}
I realize it's not ideal but it's the first thing that worked for me. My guess is that having the Text view's content change depending on the .showingSheet property forces SwiftUI to fully refresh the toolbar group.
So far, I can still observe the disorder of navi buttons right after dismissing its presented sheet.
FYI, I am using a UINavigationController wrapper instead as workaround. It works well.
Unfortunately, I sure that the more that kind of bugs, the farther away the time of using SwiftUI widely by the ios dev guys. Because those are too basic to ignore.
Very hacky but this worked for me:
I had the same problem. this solution worked for me.
struct ContentView: View {
#State var showSheet = false
var body: some View {
NavigationView {
VStack {
Text("Test")
}.sheet(isPresented: self.$showSheet) {
ModalView()
}.navigationBarItems(trailing:
Button(action: {
self.showSheet = true
}) {
Text("SecondView")
// this is a workaround
.frame(height: 96, alignment: .trailing)
}
)
}
}
}
Only #adamwjohnson5's answer worked for me. I don't like doing it but it's the only solution that works as of Xcode 13.1 and iOS 15.0. Here is my code for anyone interested in seeing iOS 15.0 targeted code:
var body: some View {
NavigationView {
mainContentView
.navigationTitle(viewModel.navigationTitle)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
PlusButton {
viewModel.showAddDialog.toggle()
}
.frame(height: 96, alignment: .trailing) // Workaroud, credit: https://stackoverflow.com/a/62209223/5421557
.confirmationDialog("CatalogView.Add.DialogTitle", isPresented: $viewModel.showAddDialog, titleVisibility: .visible) {
Button("Program") {
viewModel.navigateToAddProgramView.toggle()
}
Button("Exercise") {
viewModel.navigateToAddExerciseView.toggle()
}
}
}
}
.sheet(isPresented: $viewModel.navigateToAddProgramView, onDismiss: nil) {
Text("Add Program View")
}
.sheet(isPresented: $viewModel.navigateToAddExerciseView, onDismiss: nil) {
AddEditExerciseView(viewModel: AddEditExerciseViewModel())
}
}
.navigationViewStyle(.stack)
}

NavigationLink isActive does not work inside navigationBarItems(trailing:) modifier

I am using the newest versions of Xcode (11 Beta 16) and macOS (10.15 Beta 6)
I am trying to create two views. From the first one view you should be able to navigate to the second one view via the trailing navigation bar item and to navigate back you should be able to use the system generated back button (which works) and additionally a trailing navigation bar button (which has some additional functionality like saving the data but this is not important for my problem).
Option 1 does work but if you comment out option 1 and uncomment option 2 (my wanted layout) the done button just does not navigate back.
struct ContentView1: View {
#State var show = false
var body: some View {
NavigationView {
Form {
Text("View 1")
// Option 1 that does work
NavigationLink(destination: ContentView2(show: $show), isActive: $show) {
Text("Move")
}
}
.navigationBarTitle(Text("Title"))
// Option 2 that does NOT work
// .navigationBarItems(trailing: NavigationLink(destination: ContentView2(show: $show), isActive: $show) {
// Text("Move")
// })
}
}
}
struct ContentView2: View {
#Binding var show: Bool
var body: some View {
Form {
Text("View 2")
Text(show.description)
}
.navigationBarItems(trailing: Button(action: {
self.show = false
}, label: {
Text("Done")
}))
}
}
Any suggestions how to fix that?
Option 2 plays nicely with presentationMode:
struct ContentView2: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Form {
Text("View 2")
}
.navigationBarItems(trailing: Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Done")
}))
}
}