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

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

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.

Keyboard safe area wrong for iPad in multitasking Slide Over (SwiftUI/UIKit)

I've got a view with content on the bottom edge, which gets partially obscured by the keyboard when the app is in Slide Over. This isn't the case in side-by-side multitasking or when the app is full screen.
All other configurations are fine:
Side-by-side, with keyboard:
Slide over, without keyboard:
Here's the example view code:
struct ContentView: View {
#State private var text = ""
var body: some View {
VStack {
TextField("", text: $text)
.textFieldStyle(RoundedBorderTextFieldStyle())
Spacer()
Text("Hello, world!")
.font(.title)
}
}
}
I've noticed this issue in SwiftUI lifecycle apps, UIKit lifecycle apps, across different iOS versions (including iOS 14) and on all iPad sizes.
I've seen solutions that check the UIDevice.current.userInterfaceIdiom and the window bounds to check if the app is in Slide Over and adjust the UI, but I'm using SwiftUI and I'd like to avoid this kind of hack. Any advice on how to deal with this?

SwiftUI InputAccessoryView

I'm trying to build a chat view in SwiftUI and I want to append my input views to the keyboard, so that when I dismiss the keyboard by dragging my view gets moved with it.
When I was using UIKit I overwrote the inputAccessoryView of the ViewController. Is something similar possible with SwiftUI?
EDIT:
I already saw that I can add a UIKit TextField and add a InputAccessory for this text field. However that's not what I want to do. I want to have a global inputAccessoryView in my SwiftUI View and add my custom input view as a subview, so that it is always Visible and not an addition to my TextField.
I see two possible solutions to the behavior you want.
In some cases, SwiftUI views move out of the way of the keyboard automatically
in iOS 15 and later you can create an InputAccessoryView in Swiftui
1: In swiftUI, there are several safe areas which views lay themselves inside of by default. One of these is the keyboard safe area. This areas takes up the full screen of the device when the keyboard is hidden but shrinks to the non keyboard area of the screen when the keyboard is displayed. So in the example code below, the text field should move above the keyboard when it appears and drop down when the keyboard disappears (this does not work on an iPad when the keyboard is in the smaller floating mode).
VStack {
ScrollView {
ForEach(0 ..< 50) { item in
Text("Demo Text")
.frame(maxWidth: .infinity)
}
}
TextField("Enter Text", text: $messageText)
}
2: In iOS 15+, you can create a toolbar in the keyboard location. This essentially acts as an InputAccessoryView does in UIKit. The difference between this and method 1 is that a view in here will only appear when the keyboard is displayed. The one expiation to this is when a wired or wireless keyboard is attached to the iPhone or iPad, the toolbar view will still be displayed just at the bottom of the screen.
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Text("Apears at top of keyboard")
}
}
So putting 1 and 2 together, here is an example that implements both. You can run it in Xcode to help understand how both methods behave
VStack {
ScrollView {
ForEach(0 ..< 50) { item in
Text("Demo Text")
.frame(maxWidth: .infinity)
}
}
TextField("Enter Text", text: $messageText)
}
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Text("Apears at top of keyboard")
}
}

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.

Resources