Dismiss a SwiftUI navigation view on an iPad in portrait mode when a selection is made - ipad

On my SwiftUI app, I have a navigation view on the left side on an iPad in portrait mode, and after I make a selection, I want the navigation view to disappear. It does this for the first time, but subsequent times, I have to click on the view on the right for it to disappear. I have no idea how to make this happen consistently.
Here is a very simple chunk of code that demonstrates the problem. When you run it and click "< Back", it will show the menu of options on the left, and when you first click an option, it disappears. The next time you click the "< Back" at the top left and then click another selection, it will continue to show that list until you click the pane on the right. It does update the pane on the right with your choice each time, as it should.
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: Text("You selected option 1")) {
Text("Option 1")
}
NavigationLink(destination: Text("You selected option 2")) {
Text("Option 2")
}
NavigationLink(destination: Text("You selected option 3")) {
Text("Option 3")
}
}
}
}
}
Thanks for your help!

Related

SwiftUI: strange offset on tap when reopening app with a `sheet` open

I am facing out a strange behavior that really looks like a SwiftUI bug.
When I leave the app with a .sheet open and reopen it, all content from parent has an offset on tap. It is difficult to explain (and English is not my mother tongue) so here is a really simple example:
struct ContentView: View {
#State private var isOpen = false
var body: some View {
Button(action: {
isOpen.toggle()
}, label: {
Text("Open sheet")
.foregroundColor(.white)
.padding()
.background(.blue)
})
.sheet(isPresented: $isOpen, content: {
Text("Sheet content")
})
}
}
To reproduce the issue follow those steps:
Tap just below to the top border of blue button Open sheet: the sheet opens as expected.
When the sheet is open, close the app (go back to Springboard, cmd+shift+H on iOS Simulator).
Reopen the app. You're still on the sheet view.
Close the sheet. You're back on main view with blue button. Here is the bug:
Tap again on the top of blue button, right below the top border. Nothing happens. You have to click few pixels below. There is an offset that makes all tappable items on main view not aligned.
Does anyone have seen this bug also? Is there something I do wrong?
Other notices:
When closing the app from main view, the bug doesn't appear. And even when the bug is here and I close the app from main view and reopen, the bug disappears.
If I use a .fullScreenCover instead of .sheet, the bug doesn't appear.
It really looks like a bug with .sheets open.
EDIT:
I have tried two workarounds but both don't work:
Embed the Button in an external View.
Replace Button with only the Text and add .onTapGesture{ ... } modifier to toggle isOpen #State property.
EDIT 2:
After hours of tries I could find something interesting: if, in the sheet content, I add a button to dismiss the sheet, the bug doesn't appear anymore. But if I dismiss the sheet with finger (drag from top to bottom), it still appears.
Here is modified code:
struct ContentView: View {
#State private var isOpen = false
var body: some View {
Button(action: {
isOpen.toggle()
}, label: {
Text("Open sheet")
.foregroundColor(.white)
.padding()
.background(.blue)
})
.sheet(isPresented: $isOpen, content: {
SheetContent()
})
}
}
struct SheetContent: View {
#Environment(\.dismiss) var dismiss
var body: some View {
Button(action: { dismiss() }, label: {
Text("Dismiss sheet")
})
}
}
It looks like there is something with calling (or not) the #Environment(\.dismiss) var dismiss.
The current state is a bit better as few days ago as the bug only appears when user dismiss the sheet by dragging down. But there is still something wrong.
Is there a way to programmatically call dismiss() when sheet is closed by dragging down?

Why do SwiftUI Buttons that use .buttonStyle see taps when a Menu is presented?

Goal Display a list of "card like" rows where a tap highlights the entire card and then performs an action. The rows need to support a Menu in their content.
The code below does this and at first seems to work fine. However, long pressing on a row while the menu is presented breaks the SwiftUI view hierarchy and requires the app be restarted to restore normal functionality.
struct ContentView: View {
let strings: [String] = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot"]
#State var shouldDoSomething = false
var body: some View {
List(strings, id: \.self) { string in
HStack {
Text(string)
Spacer()
Menu {
Button {} label: { Label("Show Less", systemImage: "hand.thumbsdown") }
Button {} label: { Label("Show More", systemImage: "hand.thumbsup") }
} label: { Image(systemName: "ellipsis.circle") }
}
.padding()
// Make entire card tappable and highlight on tap
.background() {
Button(action: {
shouldDoSomething = true
}, label: { Color.white })
.buttonStyle(cardButtonStyle())
}
// Card style
.listRowSeparator(.hidden)
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.gray, lineWidth: 0.5))
.cornerRadius(12)
.shadow(color: Color.black.opacity(0.1), radius: 10, x: 0, y: 5)
}
.listStyle(.plain)
.alert(isPresented: $shouldDoSomething) {
Alert(title: Text("Row Tapped"), message: Text("Do something useful here."), dismissButton: .cancel())
}
}
}
struct cardButtonStyle: ButtonStyle {
func makeBody(configuration: Self.Configuration) -> some View {
configuration.label
.overlay {
configuration.isPressed ? Color.black.opacity(0.25) : Color.clear
}
}
}
The Problem
If the user taps the ellipses to bring up the Menu, then taps on any card. The Menu is dismissed and an Alert is displayed. The tap should not present the alert, it should only dismiss the Menu. This is an annoying tiger trap, but I could convince myself to live with it.
Worse, when the user taps the ellipses to bring up the Menu, then long presses on any card. Three things are different from the use case above.
The Alert does not appear (it shouldn't)
The Menu does not dismiss (it doesn't in normal cases either)
The console prints an error Attempt to present...which is already presenting... (Uh oh...)
The app is now broken, tap to dismiss the Menu and now the card rows are no longer tappable unless the app is restarted.
What I have tried to resolve the issue:
A. Dispatch the shouldDoSomething = true on the main queue, with and without a delay. This did not help.
B. I tried manually dismissing the Menu by declaring the dismiss environment variable and calling dismiss() when the button is pressed. This did not help.
C. Using .onAppear() and added tap and long press gestures, I tried to keep track of when the Menu was being displayed and using that information, disable the background button. Unfortunately, I was never able to detect the Menu is being displayed when triggered by a long press and when it is dismissed.
D. I tried a standard button in the row instead of a button in the view's background. This did not help.
E: Removed the four lines of "Card Style" code and .listStyle to see if they contributed to the problem. This did not help.
F. Through trial and error. I commented out the .buttonStyle(cardButtonStyle()) line and the problem went away. I switched the buttonStyle to an Apple standard one and the problem still persists. So .buttonStyle manifests the problem, why, is the question.

How do I arrange SwiftUI Toolbar items?

I have a SwiftUI ToolBar with 4 buttons, however the code I implemented is not correct because the buttons end up in weird places when changing the device type in simulator.
Even worse, when viewed on iPhone 8 / 8 Plus, 2 of the buttons are on the far edges of the window.
How do I properly apply spacing/padding to ToolBar buttons so they are consistent across different iOS devices?
Thank you!
// This code spaces the buttons but they change positions depending on the iOS device
ToolbarItem {
HStack {
HStack {
ProfileUploadMediaButton()
}.padding([.trailing], 85)
HStack {
ProfileUsernameButton()
}.padding([.trailing], 84)
HStack {
ProfileLiveButton()
}.padding([.trailing], 6)
HStack {
AccountButton()
}.padding([.trailing], 12)
}
}
})
// I was thinking code like this but all buttons are bunched together on the right-side of // the screen...
ToolbarItem {
HStack {
ProfileUploadMediaButton()
ProfileUsernameButton()
ProfileLiveButton()
AccountButton()
}
}
When you add ToolbarItems, there is an initializer where you can explicitly set the placement of each item. For your case, you would add 3 ToolbarItems, for the left, center, and right. I'd mention that the toolbar is meant to be dynamic, so it may look different on different devices on purpose.
struct ToolbarView: View {
var body: some View {
NavigationView {
VStack {
Text("Hello, world!")
}
.navigationTitle("Test")
.toolbar(content: {
ToolbarItem(placement: .navigationBarLeading) {
Image(systemName: "camera.fill")
}
ToolbarItem(placement: .principal) {
Text("Username")
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Image(systemName: "dot.radiowaves.left.and.right")
Image(systemName: "heart.fill")
}
}
})
}
}
}
Per the documentation, here are the placement options. I'm guessing that when you don't explicitly add a placement, they default to .automatic.
automatic:
The item is placed automatically, depending on many factors including the platform, size class, or presence of other items.
bottomBar:
The item is placed in the bottom toolbar. Applies to iOS, iPadOS, and Mac Catalyst.
cancellationAction:
The item represents a cancellation action for a modal interface.
confirmationAction:
The item represents a confirmation action for a modal interface.
destructiveAction:
The item represents a destructive action for a modal interface.
navigation:
The item represents a navigation action.
navigationBarLeading:
The item is placed in the leading edge of the navigation bar. Applies to iOS, iPadOS, tvOS, and Mac Catalyst.
navigationBarTrailing:
The item is placed in the trailing edge of the navigation bar. Applies to iOS, iPadOS, tvOS, and Mac Catalyst.
primaryAction:
The item represents a primary action.
principal:
The item is placed in the principal item section.
ToolbarItemPlacement:
The item represents a change in status for the current context.

Unable to present ActionSheet via a NavigationBarItem in SwiftUI on an iPad

First, I have looked at a similar question, but it does not address my use case.
Present ActionSheet in SwiftUI on iPad
My issue is that I have a NavigationBarItem in my NavigationView that will toggle an ActionSheet when pressed. This behavior works properly when used on an iPhone.
However, when I use this on an iPad, both buttons on my screen will gray out and nothing happens. Clicking the buttons again will make them active (blue), but again, no sheet is presented.
Finally, if I select the button in the middle of the screen (Show Button), then an ActionSheet is properly presented on an iPad.
I have tested with Xcode 11 & iOS 13.5 and Xcode 12 & iOS 14. There is no change in behavior.
import SwiftUI
struct ContentView: View {
#State private var isButtonSheetPresented = false
#State private var isNavButtonSheetPresented = false
var body: some View {
NavigationView {
Button(action: {
// Works on iPad & iPhone
self.isButtonSheetPresented.toggle()
}) {
Text("Show Button")
}
.actionSheet(isPresented: $isButtonSheetPresented,
content: {
ActionSheet(title: Text("ActionSheet"))
})
.navigationBarTitle(Text("Title"),
displayMode: .inline)
.navigationBarItems(trailing:
Button(action: {
// Works on iPhone, fails on iPad
self.isNavButtonSheetPresented.toggle()
}) {
Text("Show Nav")
}
.actionSheet(isPresented: $isNavButtonSheetPresented,
content: {
ActionSheet(title: Text("ActionSheet"))
})
)
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
Finally, this is how it appears on an iPad when clicking on "Show Nav":
This is a simplified setup for the screen where this issue occurs. I will need to retain the navigation settings shown, but have included them for clarity.
*** UPDATED ***
While it is not possible for the real app behind this, I did remove the .navigationViewStyle(StackNavigationViewStyle()) setting, which did make an ActionSheet appear, although in the wrong spot as seen below.
This also results in bizarre placement for the Button one accessed via "Show Button".
Yes, it is a bug, but probably different - that Apple does not allow to change anchor and direction of shown ActionSheet, because it is shown, but always to the right of originated control on iPad. To prove this it is enough to change location of button in Navigation
Here is example of placing at .leading position. Tested with Xcode 12 / iOS 14
.navigationBarItems(leading:
Button(action: {
// Works on iPhone, fails on iPad
self.isNavButtonSheetPresented.toggle()
}) {
Text("Show Nav")
}
.actionSheet(isPresented: $isNavButtonSheetPresented,
content: {
ActionSheet(title: Text("ActionSheet"))
})
)
Note: SwiftUI 2.0 .toolbar behaves in the same way, ie. has same bug.
This is an old question but if someone is interested in a turnaround that works on iOS 14:
I have two navigation bar trailing buttons inside .toolbar() and they should open action sheets. I placed an invisible "bar" at the top of the view to use it as an anchor:
var body: some View {
VStack {
HStack {
Spacer()
Color.clear.frame(width: 1, height: 1, alignment: .center)
.actionSheet(/*ActionSheet for first button*/)
Spacer().frame(width: 40)
Color.clear.frame(width: 1, height: 1, alignment: .center)
.actionSheet(/*ActionSheet for second button*/)
Spacer().frame(width: 40)
}.frame(height: 1)
}
}
Cons:
There's a tiny bar/extra space at the top, noticeable especially during scrolling (Maybe putting the Stack in the background with a Stack could remove it?).
You might need to adjust the Spacers' width to try and align the ActionSheets to their respective button.
You can't force the action sheet arrows to always point upwards, I tested this on another simulator and the rightmost ActionSheet had its arrow pointing to the right (the 'illusion' that it came from the button was still there)
Here's how it looks

SwiftUI show/hide title issues with NavigationBar

I have the following code construct which gives me a lot of trouble:
//Main View
struct ContentView: View {
var body: some View {
NavigationView{
ZStack(alignment: .center){
CarouselBuilder()
ProfileInvoke().navigationBarTitle("").navigationBarHidden(true)
}
}
}
}
//Carousel filled with Cards from a DB
...code irrelevant for my problem
//Profile Invoke -> Invokes a slide out menu called Menu that has NavigationLinks in it
struct Menu: View {
var body: some View {
ZStack{
VStack(alignment: .center){
MenuButton(buttonText: "Settings", buttonCallView: AnyView(SettingsView() ))
MenuButton(buttonText: "My Favourites", buttonCallView: AnyView(MyFavouritesView()))
MenuButton(buttonText: "Sign Out", buttonCallView: AnyView(SignOutView()))
}.frame(width: UIScreen.main.bounds.width/1.2,alignment: .top)
}
}
}
//MenuButtons are basic NavigationLinks linking to certain Views given as argument when calling them
I now do wrap the ZStack in the Main View in a NavigationView which I need to to in order for the NavigationLinks to work. I also have to do this on this "top" level as I need the new View that will be invoked by the links in the slide out menu to take the entire screen and not only the width the slide out view is being displayed.
My issue is now that I certainly do not want the navigation bar to take up space in the main view. For this I set the hidden attribute for it to true. This tho, carries through the entire app and also disables the navigation view in the subviews linked to by the buttons in the menu. Which gives me no way of going back.
My question would be:
1) Is there a more elegant way of doing all of this?
2) How can I re-invoke the navigation bar in sub views? (Setting the hidden navigation bar attribute on them back to false did not work.
Below is a possible approach to hide navigation bar in root view and show in child subviews. The only needed modifications is in root view.
Tested with Xcode 11.4 / iOS 13.4
Here is a root only, child sub-views are regular and do not require special code for this case. See important notes inline.
struct RootNavigationView: View {
#State private var hideBar = true // << track hide state, and default
var body: some View {
NavigationView {
VStack {
Text("I'm ROOT")
Divider()
NavigationLink("Goto Child", destination: NextChildView(index: 1))
.simultaneousGesture(TapGesture().onEnded {
self.hideBar = false // << show, here to be smooth !!
})
}
.navigationBarHidden(hideBar)
// .navigationBarTitle("Back to Root") // << optional
.onAppear {
self.hideBar = true // << hide on back
}
}
}
}

Resources