SwiftUI - Cannot show alert over sheet - ios

The story is a little long. First, let me show some code that doesn't work as I expected.
import SwiftUI
struct ContentView: View {
#State var isPresentingAlert = false
#State var isPresentingSheet = false
var body: some View {
VStack {
Button("Show Sheet") {
isPresentingSheet = true
}
}
.alert("Alert", isPresented: $isPresentingAlert) {}
.sheet(isPresented: $isPresentingSheet) {
Button("Show Alert") {
isPresentingAlert = true
}
}
}
}
The code's intent is clear: the view should display a sheet when user press the "Show Sheet" button, and there is a "Show Alert" button on the sheet, which should cause the alert window to show after tapping. The code is simplified - in the original app, the sheet shows a form to the user, and the alert should show when user submit a form with some invalid data.
But actually when I pressed the "Show Alert" button, the alert wasn't shown. Instead, I got following error in the console:
2023-02-14 23:41:08.163349+0800 SheetAndAlert[8351:217492] [Presentation] Attempt to
present <SwiftUI.PlatformAlertController: 0x15b030600> on
<_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__:
0x15b00aa00> (from
<_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__:
0x15b00aa00>) which is already presenting
<_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_: 0x15b02e000>.
I had no idea what does this error means. Later I changed the code to use UIKIt's UIAlertController, wishing I can get around the error. (The code was adapted from this answer: How do I present a UIAlertController in SwiftUI?)
import SwiftUI
struct ContentView: View {
#State var isPresentingAlert = false
#State var isPresentingSheet = false
var body: some View {
VStack {
Button("Show Sheet") {
isPresentingSheet = true
}
}
.sheet(isPresented: $isPresentingSheet) {
Button("Show Alert") {
ContentView.alertMessage(title: "Alert", message: "Alert")
}
}
}
static func alertMessage(title: String, message: String) {
let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default) { (action: UIAlertAction) in
}
alertVC.addAction(okAction)
let viewController = UIApplication.shared.windows.first!.rootViewController!
viewController.present(alertVC, animated: true, completion: nil)
}
}
But still, it doesn't work. A similiar error is thrown:
2023-02-14 23:56:12.715039+0800 SheetAndAlert[9991:263667] [Presentation] Attempt to
present <UIAlertController: 0x13d80b800> on
<_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__:
0x139012600> (from
<_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__:
0x139012600>) which is already presenting
<_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_: 0x13b02fc00>.
Finally I found a working solution, just move .alert inside .sheet's content:
import SwiftUI
struct ContentView: View {
#State var isPresentingAlert = false
#State var isPresentingSheet = false
var body: some View {
VStack {
Button("Show Sheet") {
isPresentingSheet = true
}
}
.sheet(isPresented: $isPresentingSheet) {
Button("Show Alert") {
isPresentingAlert = true
}
.alert("Alert", isPresented: $isPresentingAlert) {}
}
}
}
Though works, but I still don't understand (at all!) why using UIAlertController cannot solve the problem, while just moving the modifier to another place can.

TL;DR: This behavior is a constraint of the UIKit layer used by SwiftUI and the solution is what you already found, to present the alert from the body of the .sheet modifier.
The behavior you're seeing is caused by the underlying UIKit layer that SwiftUI relies on.
In UIKit, content is managed by UIViewControllers, each one of these view controllers can present another view controller on top of it, but only one. This is often used to present "modals" (or sheets), "popovers" and "alerts".
View controllers are invisible to us when using SwiftUI, but they are still there. Some SwiftUI structs map to some UIKit view controllers, like NavigationView to UINavigationController, and TabView to UITabViewController.
As you may have guessed by now, the .sheet and the .alert modifiers map to their own view controllers. .sheet specifically creates a new view controller that is presented on top of the current view controller.
As I mentioned earlier, view controllers in UIKit can present only one view controller at any time. Because both modifiers are modifying the same content, and hence the same view controller, trying to activate both at the same time will give you that error.
The fix is what you already found: moving the .alert modifier inside the body of .sheet will cause the alert to be presented on the sheet view controller.

If you really need that, I think there is only a bit of hacky solution for that:
.sheet(isPresented: $isPresentingSheet) {
Button("Show Alert") {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isPresentingAlert = true
}
}
}
The other solution that comes to my mind is that you can watch .onChange for isPresentingSheet and call the alert from there (I haven't tested this one it might end up to need asyncAfter too). Obviously you need to add some more variables to know what has happened in your sheet as every dismiss shouldn't call the alert. It is less hacky but more complex to implement and less readable overall.

Related

How to dismiss a presenting view to the root view of tab view in SwiftUI?

I'm using TabView on my home page. Let's just say I have 4 tabs.
On second tab, i can go to another view using NavigationLink and I go to another 2 views using NavigationLink. Then on the latest view, there is a button to present a view and i use .fullScreenCover (since I want to present it full screen).
In the presenting view, I add an X mark on the left side of the navigationBarItems to dismiss. I use #Environment(\.presentationMode) var presentationMode and presentationMode.wrappedValue.dismiss() to dismiss. But it only dismiss the presenting view to the previous view, while actually I want to dismiss it to the root of my view which is the 2nd tab of my TabView.
Is there a way to do this? Because I have looked up to some articles and nothing relevant especially in TabView context.
I also have a question tho:
Is it a right approach to use .fullScreenCover? Or is there another possible solution for example presenting a modal with full screen style (if there's any cause i'm not sure either).
Any suggestions will be very appreciated, thankyou in advance.
The presentationMode is one-level effect value, ie changing it you close one currently presented screen.
Thus to close many presented screens you have to implement this programmatically, like in demo below.
The possible approach is to use custom EnvironmentKey to pass it down view hierarchy w/o tight coupling of every level view (like with binding) and inject/call only at that level where needed.
Demo tested with Xcode 12.4 / iOS 14.4
struct ContentView: View {
var body: some View {
TabView {
Text("Tab1")
.tabItem { Image(systemName: "1.square") }
Tab2RootView()
.tabItem { Image(systemName: "2.square") }
}
}
}
struct Tab2RootView: View {
#State var toRoot = false
var body: some View {
NavigationView {
Tab2NoteView(level: 0)
.id(toRoot) // << reset to root !!
}
.environment(\.rewind, $toRoot) // << inject here !!
}
}
struct Tab2NoteView: View {
#Environment(\.rewind) var rewind
let level: Int
#State private var showFullScreen = false
var body: some View {
VStack {
Text(level == 0 ? "ROOT" : "Level \(level)")
NavigationLink("Go Next", destination: Tab2NoteView(level: level + 1))
Divider()
Button("Full Screen") { showFullScreen.toggle() }
.fullScreenCover(isPresented: $showFullScreen,
onDismiss: { rewind.wrappedValue.toggle() }) {
Tab2FullScreenView()
}
}
}
}
struct RewindKey: EnvironmentKey {
static let defaultValue: Binding<Bool> = .constant(false)
}
extension EnvironmentValues {
var rewind: Binding<Bool> {
get { self[RewindKey.self] }
set { self[RewindKey.self] = newValue }
}
}
struct Tab2FullScreenView: View {
#Environment(\.presentationMode) var mode
var body: some View {
Button("Close") { mode.wrappedValue.dismiss() }
}
}
You have 2 options:
With .fullScreenCover you will have a binding that results in it being presented you can pass this binding through to the content and when the user taps on x set to to false
You can use the #Environment(\.presentationMode) var presentationMode then call presentationMode.wrappedValue.dismiss() in your button body.
Edit:
If you want to unwind all the way you should make the TabView be binding based. I like to use SceneStorage for this take a look at this post then you can access this SceneStorage value anywhere in your app to respond to it but also to update and change the navigation (this also has the benefit of providing you proper state restoration!)
If you make your TabView in this way:
struct ContentView: View {
#SceneStorage("selectedTab") var selectedTab: Tab = .car
var body: some View {
TabView(selection: $selectedTab) {
CarTrips()
.tabItem {
Image(systemName: "car")
Text("Car Trips")
}.tag(Tab.car)
TramTrips()
.tabItem {
Image(systemName: "tram.fill")
Text("Tram Trips")
}.tag(Tab.tram)
AirplaneTrips()
.tabItem {
Image(systemName: "airplane")
Text("Airplane Trips")
}.tag(Tab.airplaine)
}
}
}
enum Tab: String {
case car
case tram
case airplaine
}
Then deep within your app in the place you want to change the navigation you can create a button view.
struct ViewCarButton: View {
#SceneStorage("selectedTab") var selectedTab: Tab = .car
var body: some View {
Button("A Button") {
selectedTab = .car
}
}
}
This will forced the selected tab to be the car tab.
if instead of this you do not want to change tab but rather change what the navigation view is navigated to you can use the same concept for that, NavigationLink that's a binding if this binding is created using a #SceneStorage then in your ViewCarButton you can make changes to it that will change the navigation state.

SwiftUI from UIHostingController: showing ActionSheet gives warning about detached view controller

I have created a SwiftUI view which I show in a MKMapView's annotation callout. I use UIHostingController to wrap the SwiftUI view, and use annotationView's detailCalloutAccessoryView property to set the view and display it. This works fine for the most part. I then added some buttons to the SwiftUI view, which when tapped need to display an alert or an action sheet.
Here is the code:
struct ContactProfileButton: View {
#State var showActionSheet: Bool = false
var body: some View {
Button(action: {
print("Profile button tapped!")
self.showActionSheet.toggle()
}){
Image("profile").resizable()
}.frame(width: 26.0, height: 26.0)
.actionSheet(isPresented: $showActionSheet, content: {
print("Profile button - show action sheet")
return ActionSheet(title: Text("Profile title"),
message: Text("Profile message"),
buttons: [
.default(Text("Do something")),
.destructive(Text("Cancel"))
])
})
}
}
When I tap the button, it works fine and displays an action sheet, but generates this warning in the console:
[Presentation] Presenting view controller
<SwiftUI.PlatformAlertController: 0x7fac118b3a00> from detached view
controller <TtGC7SwiftUI19UIHostingControllerVS_7AnyView:
0x7fac1fac8280> is discouraged.
What is the best way to resolve this warning? Should I be showing an Alert or ActionSheet from within the SwiftUI view, if it's hosted inside a UIHostingController? Or should I be doing this some other way?

How to use UIAccessibility.post(notification: .layoutChanged, argument: nil) in SwiftUI to move focus to specific view

I have one button on screen and on that button tap it opens one modal (view). but after closing that view, the focus of accessibility voice over goes on top of screen. In UIKit we can use UIAccessibility.post(notification: .layoutChanged, argument: nil) and pass reference as an argument. But how can we achieve this same behaviour in SwiftUI.
How I managed this was using a .accessibilityHidden wrapper on the very top level of the parent view and then used a #State variable as the value to pass into accessibilityHidden. This way the parent view is ignored while the modal is showing. And then reintroduced into the view once the modal is closed again.
struct MainView: View {
#State var showingModal = false
var body: some View {
VStack {
Button(action: {
showingModal = true
}, label: {
Text("Open Modal")
})
.fullScreenCover(isPresented: $showingModal, onDismiss: {
print("Focus coming back to main view")
} content: {
Modal()
})
}
.accessibilityHidden(self.showingModal)
}
}
struct Modal: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
Text("Focus will move here")
Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
Text("Close Modal to Refocus Back")
}
}
}
}
You can also chain multiple modal / alerts as long at you have #State values to handle the changes to them so the focus moves properly
.accessibilityHidden(self.showingModel1 || self.showingModel2 || self.showingAlert1 || self.showingAlert2)
I know this question is really old, but I literally just was handling this and thought if someone else stumbled onto this question there would be an answer here.

why is #state being reset after alert dismissal

complete SwiftUI beginner here. I'm looking at this example and I'm trying to understand the lifecycle of #state variables.
showingAlert is initialized as false and is set to true when the button is tapped.
the part that I have trouble wrapping my head around is why does it reset back to false when the alert is dismissed? I do not set that to false anywhere.
I expected it to remain true
#State private var showingAlert = false
var body: some View {
Button(action: { self.showingAlert = true }
) {
Text("Show Alert")
}
.alert(isPresented: $showingAlert) {
Alert(title: Text("Important message"))
}
}
Because, by definition, if the alert is dismissed, the alert is no longer presented. $showingAlert is a binding — it moves data in both directions. Its value is always correlated to whether the alert is being presented; that's what it means to be a binding.

SwiftUI when dismissing a sheet the view behind it is unresponsive for a few seconds

When dismissing a .sheet view in SwiftUI the view behind it is unresponsive for a second.
During my investigation of this bug I found that the Presentation Controller of the sheet is backed by a UITransitionView, which doesn't dismiss as fast as the sheet itself and is therefore blocking the taps. The problem is that this UITransitionView isn't a accessible in SwiftUI and this makes configuring the transition impossible in a way where I could prevent UITransitionView from blocking the view hierarchy.
Here is a minimal example on how to reproduce this behavior:
struct ContentView: View {
#State var showSheet: Bool = false
var body: some View {
VStack {
Text("Show the sheeet").onTapGesture(perform: {
self.showSheet.toggle()
})
}
.sheet(isPresented: self.$showSheet, content: {
VStack {
Button("Dismiss Sheet", action: {
self.showSheet.toggle()
})
}
})
}
}
After dismissing the sheet the view isn't responsive for ~1 second.
So is there a way to prevent this or do I have to right my own custom sheet?

Resources