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

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?

Related

SwiftUI - Cannot show alert over sheet

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.

Keyboard not dismissing in SwiftUI with onTapGesture

I am trying to workout how to dismiss a keyboard in SwiftUI when the user taps outside a TextField. I followed some other posts that suggested using onTapGesture on a VStack and extending the View protocol to have a method to dismiss the keyboard that is called from the onTapGesture.
This is my content view:
struct ContentView: View {
#State var text = ""
var body: some View {
VStack{
Spacer()
TextField("Text", text: $text)
Text("\(text)")
Spacer()
}
.onTapGesture {
dismissKeyboard()
}
}
}
and I have extended the View protocol with:
extension View {
func dismissKeyboard() {
print("dismissing keyboard")
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
When I run the app, the keyboard is only dismissed when I tap in the area of the Text(). I would understand why this occurs if the Spacers are not there as the VStack only fills the area required by the views that it contains but I expected that the spacers would allow the onTapGesture to work for the whole screen as the VStack is pushed to the top and bottom of the screen but it still only works when tapping the Text().
Is there a reason why the onTapGesture doesn't work when I tap where the Spacers are and only works where the Text is?
You need to set a non-empty background like this:
ZStack {
Color.white.opacity(0.0000001)
VStack {
TextField("Text", text: $text)
Text("\(text)")
}
}
.onTapGesture {
dismissKeyboard()
}
SwiftUI always ignores gestures on top of things that it considers empty-space.
e.g. Color.white.opacity(0) will be considered empty-space as well and the tap gesture wont work if you do that.

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?

Detecting tap gesture on the background of a GeometryReader SwiftUI

I have a main view, and, within that view I have a little pop-up menu that is within a GeometryReader, like this:
if (self.show){
GeometryReader{_ in
Menu()
}.background(Color.black.opacity(0.65))
}
the line ~~~.background(Color.black.opacity(0.65))~~~ is essentially making the background (i.e. every part of the view that isn't in the pop up view, a bit dark. I would like to do something like this:
if (self.show){
GeometryReader{_ in
Menu()
}.background(Color.black.opacity(0.65))
.background(.onTapGesture{
print("asdf")
})
}
but this syntax isn't supported. Is there any way I can accomplish this? Essentially, I want it so that when I click outside of the GeometryReader, I can toggle a variable (in this case, get rid of the pop up view).
I tried just making a TapGesture Recognizer on the main view, but since the GeometryReader is part of the main view, when I tap on the GeometryReader pop up view itself, then it disappears.
Is there any way to accomplish something similar to the code I wrote above?
Thanks
Here is an example. I use three tapGestures:
one on the Main View to toggle the "Menu"
one on the "Menu" (do something there)
and one on the Background View to dismiss the "Menu" again,
like so:
struct ContentView: View {
#State private var showMenu: Bool = false
var body: some View {
ZStack {
// The Main View.
Text("Tap Me!")
.padding()
.onTapGesture {
showMenu.toggle()
print("Tapped Main View")
}
// The Menu View (shown on top of the Main View).
if showMenu {
GeometryReader { _ in
Text("Menu")
.padding()
.onTapGesture {
// do something here
print("Tapped Menu")
}
}
// The Background View that darkens the whole screen.
.background(
Color.gray.opacity(0.2)
.edgesIgnoringSafeArea(.all)
)
.onTapGesture {
showMenu.toggle()
print("Tapped Background")
}
}
}
}
}
A tap on the "Tap Me!" (main view) brings up the menu view. The "Menu" captures taps to act upon - do whatever you want to do there.
Whenever the user taps outside of the "Menu" the tapGesture on the background recognizes the tap and dismisses the "Menu" including the darkening background --> the main view lightens again.

SwiftUI: Button in NavigationBar won't fire after modal dismissal

I'm running into some weird behavior, trying to get a simple modal to pop after it has been dismissed.
I have an Add button in the NavigationBar that pops a modal. The modal has a button that will dismiss it, which works. However, I cannot interact with the Add button in the NavigationBar again until I interact with something else on the screen, such as scrolling the List below.
I have also placed another Add button, just for kicks, in the List itself, which always works.
Here's the code for the main view:
import SwiftUI
struct ContentView: View {
#State var displayModal: Bool = false
var body: some View {
NavigationView {
List {
Text("Hello again.")
Button(action: { self.displayModal = true }) {
Text("Add")
}
}
.sheet(isPresented: $displayModal) {
Modal(isPresented: self.$displayModal)
}
.navigationBarTitle("The Title")
.navigationBarItems(trailing: Button(action: { self.displayModal = true }) {
Text("Add")
})
}
}
}
And the modal, for completeness:
import SwiftUI
struct Modal: View {
#Binding var isPresented: Bool
var body: some View {
VStack {
HStack {
Button(action: {
self.isPresented = false
}) {
Text("Cancel")
}
.padding()
Spacer()
}
Text("I am the modal")
Spacer()
}
}
}
The only thing I can think of is that something invisible is preventing me from working with the NavigationBar button. So I fired up the UI Debugger, and here's what the ContentView looks like. Note the NavigationBar button.
Now, after I tap the button and display the modal, and then use the UI Debugger to see the ContentView again, all the same elements are in place, but the Button parent views are offset a bit, like this:
Once I drag the List up and down, the UI Debugger shows a view hierarchy identical to the first image.
Does anyone have any idea what's going on here?
I'm using Xcode 11.2.1 and iOS 13 on an iPhone 11 Pro simulator, but have also observed this on my iPhone.
It is really a bug. The interesting thing is that after 'drag to dismiss' the issue is not observed, so it is a kind of 'sync/async' state changing or something.
Workaround (temporary of course, decreases visibility almost completely)
.navigationBarItems(trailing: Button(action: { self.displayModal = true }) {
Text("Add").padding([.leading, .vertical], 4)
})
I ran into the same issue, and for me the workaround was to use an inline-style navigation bar title on the presenter.
.navigationBarTitle(Text("The Title"), displayMode: .inline)
HOWEVER, if you use a custom accent color on your ContentView (like .accentColor(Color.green)), this workaround no longer works.
Edit: the bug seems fixed in 13.4, and no workarounds are needed anymore.

Resources