SwiftUI ActionSheet position on iPad - ios

When using SwiftUI's ActionSheet inside a List on iPad, it appears in a wrong position, especially if the style of the list is set to GroupedListStyle.
With this code:
struct ContentView: View {
#State var presentAction: Bool = false
var body: some View {
NavigationView {
List {
Section {
Button(action: {
self.presentAction.toggle()
}) {
Text("Present")
}
.actionSheet(isPresented: $presentAction) {
ActionSheet(title: Text("This is an action sheet"), buttons: [
.destructive(Text("Destroy all"))
])
}
}
}.listStyle(GroupedListStyle())
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
The result is as in the following screen (iPad Pro 10.5"):
But in my current app, is even worse:

Related

SwiftUI dismissing .alert pops NavigationView

I've got the following sample code running on watchos 8+:
struct TestView: View {
#State private var showingAlert = false
var body: some View {
NavigationView {
NavigationLink {
Button(action: {
showingAlert = true
}, label: {
Text("Show dialog")
})
.alert("", isPresented: $showingAlert, actions: {
Button("refresh") {
}
})
} label: {
Text("Show detail")
}
}
}
}
It's a simple screen with a NavigationLinks that pushes into navigation a new view with a simple Button.
On button press I'm displaying an alert with a simple button. When the alert is dismissed, the detail view is also dismissed and the interface returns back to it's original state.
It looks like this:
I am wondering what am I missing here. How can I dismiss the alert but remain on the second view that shows the "Show dialog" button.
View separations and not needed NavigationView (watchOS only!).
So here is fixed code. Tested with Xcode 13.4 / watchOS 8.5
struct TestView: View {
var body: some View {
NavigationLink {
DestinationView()
} label: {
Text("Show detail")
}
}
struct DestinationView: View {
#State private var showingAlert = false
var body: some View {
Button(action: {
showingAlert = true
}, label: {
Text("Show dialog")
})
.alert("", isPresented: $showingAlert, actions: {
Button("refresh") {
}
})
}
}
}

SwiftUI Modal Inherits SearchBar during Sheet Presentation

Consider the following example with a list and a button wrapped in a HStack that opens up a sheet:
struct ContentView: View {
#State var text: String = ""
#State var showSheet = false
var body: some View {
NavigationView {
List {
HStack {
button
}
Text("Hello World")
}
.searchable(text: $text)
}
}
var button: some View {
Button("Press", action: { showSheet = true })
.sheet(isPresented: $showSheet) {
modalView
}
}
var modalView: some View {
NavigationView {
List {
Text("Test")
}
}
}
}
On press of the button, a modal is presented to the user. However, the searchable modifier gets passed to the modal, see this video.
Now if the HStack is removed, everything works fine:
List {
button
Text("Hello World")
}
In addition, everything works also fine if the modal is not a NavigationView:
var modalView: some View {
List {
Text("Test")
}
}
Does somebody know what the problem here might be or is it once again one of those weird SwiftUI bugs?
putting the sheet, outside of the button and the List, works for me. I think .sheet is not meant to be inside a List, especially where searchable is operating.
struct ContentView: View {
#State var text: String = ""
#State var showSheet = false
var body: some View {
NavigationView {
List {
HStack {
button
}
Text("Hello World")
}
.searchable(text: $text)
}
.sheet(isPresented: $showSheet) {
modalView
}
}
var button: some View {
Button("Press", action: { showSheet = true })
}
var modalView: some View {
NavigationView {
List {
Text("Test")
}
}
}
}
Another workaround is to use navigationBarHidden = true, but then you must live without the navigation bar in the sheet view.
var modalView: some View {
NavigationView {
List {
Text("Test")
}
.navigationBarHidden(true)
}
}
Btw, on iPadOS it helps to use .searchable(text: $text, placement: .sidebar)

SwiftUI StackNavigationViewStyle issue when rotating iPhone

I want to implement a Settings view which can be opened taping on a gear icon button in the navigation tool bar.
This button opens a SwiftUI sheet with on Ok button to validate settings and close the settings window.
It works well if you use it without rotating the iPhone.
But if you rotate the phone when the settings window is opened, the Ok button does not work anymore and the window stays on screen (even if you rotate back the phone).
In the console, an error appears when I rotate the phone. Here is the message:
[Presentation] Attempt to present <…> on <…> (from <…>) which is already presenting
This issue seems to be linked to StackNavigationViewStyle() modifier I use to not have 2 columns on landscape mode.
If I remove the following line, the bug disappears but the layout is no more the one I want.
.navigationViewStyle(StackNavigationViewStyle())
Here is a sample code I wrote to reproduce the problem:
import SwiftUI
struct ContentView: View {
// Size class
#Environment(\.verticalSizeClass) var sizeClassV
#State private var showGearView: Bool = false
var gearButton: some View {
HStack {
Button(action: {
self.showGearView.toggle()
}) {
Image(systemName: "gear")
.imageScale(.large)
.accessibility(label: Text("Settings"))
}
.sheet(isPresented: self.$showGearView, onDismiss: {
}, content: {
gearView()
})
}
}
var body: some View {
return NavigationView {
// if sizeClassV == .regular {
VStack {
Text("Click on Gear and rotate your iPhone: here is the bug when clicking on Ok: the sheet does not collapse!")
.multilineTextAlignment(.center)
.padding(.all)
}
.padding(.all)
.navigationTitle("Bug")
.navigationBarTitleDisplayMode(.inline)
.toolbar(content: { gearButton })
}
// The bug only happens when adding the StackNavigationViewStyle below
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct gearView: View {
#Environment(\.presentationMode) var presentationMode
var OKButton: some View {
HStack {
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("OK")
}
}
}
var body: some View {
return NavigationView {
VStack {
Form {
Section(header: Text("Settings")) {
Text("No Settings")
}
}
}
.navigationTitle(Text("Settings"))
.toolbar(content: {
ToolbarItem(placement: .primaryAction) {
OKButton
}
})
}
}
}

SwiftUI List Button with Disclosure Indicator

I have a SwiftUI view that consists of a list with some items. Some of these are links to other screens (so I use NavigationLink to do this) and others are actions I want to perform on the current screen (E.g. button to show action sheet).
I am looking for a way for a Button in a SwiftUI List to show with a disclosure indicator (the chevron at the right hand sign that is shown for NavigationLink).
Is this possible?
E.g.
struct ExampleView: View {
#State private var showingActionSheet = false
var body: some View {
NavigationView {
List {
NavigationLink("Navigation Link", destination: Text("xx"))
Button("Action Sheet") {
self.showingActionSheet = true
}
.foregroundColor(.black)
}
.listStyle(GroupedListStyle())
.actionSheet(isPresented: $showingActionSheet) {
ActionSheet(title: Text("Title"), buttons: [
.default(Text("Do Something")) { },
.cancel()
])
}
}
}
}
Current Behaviour:
Wanted Behaviour:
My answer uses the SwiftUI-Introspect framework, used to:
Introspect underlying UIKit components from SwiftUI
In this case, it is used to deselect the row after the NavigationLink is pressed.
I would think a button with the normal accent color and without the NavigationLink be more intuitive to a user, but if this is what you need, here it is. The following answer should work for you:
import Introspect
import SwiftUI
struct ExampleView: View {
#State private var showingActionSheet = false
#State private var tableView: UITableView?
var body: some View {
NavigationView {
List {
NavigationLink("Navigation Link", destination: Text("xx"))
NavigationLink(
destination: EmptyView(),
isActive: Binding<Bool>(
get: { false },
set: { _ in
showingActionSheet = true
DispatchQueue.main.async {
deselectRows()
}
}
)
) {
Text("Action Sheet")
}
}
.introspectTableView { tableView = $0 }
.listStyle(GroupedListStyle())
.actionSheet(isPresented: $showingActionSheet) {
ActionSheet(title: Text("Title"), buttons: [
.default(Text("Do Something")) { },
.cancel()
])
}
}
}
private func deselectRows() {
if let tableView = tableView, let selectedRow = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: selectedRow, animated: true)
}
}
}
The possible approach is to make row with custom chevron, like in demo below (tested with Xcode 12.1 / iOS 14.1)
struct ExampleView: View {
#State private var showingActionSheet = false
var body: some View {
NavigationView {
List {
HStack {
Text("Navigation Link")
// need to hide navigation link to use same chevrons
// because default one is different
NavigationLink(destination: Text("xx")) { EmptyView() }
Image(systemName: "chevron.right")
.foregroundColor(Color.gray)
}
HStack {
Button("Action Sheet") {
self.showingActionSheet = true
}
.foregroundColor(.black)
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(Color.gray)
}
}
.listStyle(GroupedListStyle())
.actionSheet(isPresented: $showingActionSheet) {
ActionSheet(title: Text("Title"), buttons: [
.default(Text("Do Something")) { },
.cancel()
])
}
}
}
}
I used a ZStack to put a NavigationLink with an empty label behind the actual label content. This way you get the correct chevron symbol.
For the isActive Binding, you can just use a .constant(false) Binding that will always return false and cannot be changed.
This will result in a row that looks exactly like a NavigationLink, but behaves like a Button. This unfortunately also includes the drawback that the user has to click on the label-content (the text) to activate the button and cannot just click on empty space of the cell.
struct ExampleView: View {
#State private var showingActionSheet = false
var body: some View {
NavigationView {
List {
NavigationLink("Navigation Link", destination: Text("xx"))
Button {
self.showingActionSheet = true
} label: {
// Put a NavigationLink behind the actual label for the chevron
ZStack(alignment: .leading) {
// NavigationLink that can never be activated
NavigationLink(
isActive: .constant(false),
destination: { EmptyView() },
label: { EmptyView() }
)
// Actual label content
Text("Action Sheet")
}
}
// Prevent the blue button tint
.buttonStyle(.plain)
}
.listStyle(GroupedListStyle())
.actionSheet(isPresented: $showingActionSheet) {
ActionSheet(title: Text("Title"), buttons: [
.default(Text("Do Something")) { },
.cancel()
])
}
}
}
}
struct NavigationButton: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
ZStack(alignment: .leading) {
NavigationLink(
isActive: .constant(false),
destination: { EmptyView() },
label: { EmptyView() }
)
configuration.label
}
}
}
Example:
NavigationView {
List {
Button(action: {
openURL(URL(string: "https://www.apple.com")!)
}) {
Text("Visit Apple")
}
.buttonStyle(NavigationButton())
}
}

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