I'm showing a fullScreenCover with SwiftUI. Under certain flows, I want to dismiss the cover and show another from the Root ContentView. When I call
#Environment(\.dismiss) var dismiss
Button {
dismiss()
NotificationCenter.default.post(name: NSNotification.showNewScreen, object: nil, userInfo: nil)
} label: {
Text("This is a test")
}
I get the error:
[Presentation] Attempt to present xxx from yyy which is already presenting zzz
UIKit had the helpful completion handler so I could wait until the ViewController was dismissed before adding logic:
func dismiss(
animated flag: Bool,
completion: (() -> Void)? = nil
)
Is there something similar with SwiftUI? My current workaround is to add a delay to avoid presenting on the same view... which feels wrong.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: NSNotification.showNewScreen, object: nil, userInfo: nil)
}
It's not pretty to mess with modals in this way, but if you want to present the next modal from the presenting view then you can pass a binding isPresented and use the onDismiss argument for the fullScreenCover view modifier:
.fullScreenCover(isPresented: $isPresented, onDismiss: DispatchQueue.main.async { presentNextThingy }) { firstThingyContent }
or my preference use a binding that when changed will dismiss and load the next modal view, if needed. This allows the first view to determine what is presented when it is dismissed by changing the binding directly.
.fullScreenCover(item: $thingyBeingPresented) { thingy in
content(for: Thingy)
}
Related
I'm looking to show an Alert without needing to press a button, i.e. programmatically. In Swift 5 / SwiftUI in 2022.
Searching has shown me this
let alert = UIAlertController(title: "alert", message: "message", preferredStyle: UIAlertController.Style.alert)
self.present(alert, animated: true, completion: nil)
When trying the above code it has issue with the nil in the completion block, but when changing it to curlys it says the view file doesn't have present. Looks to not be for SwiftUI.
How can I show an Alert in Swift 5/SwiftUI without needing a button press?
Following this Link I was able to get the following code working
struct ContentView: View {
// pass this var as binding to other views
#State var showAlert = false
func notificationReminder() -> Alert {
Alert(
title: Text("Notifications Required"),
message: Text("Please authorize notifications by going to Settings > Notifications > Remindr"),
dismissButton: .default(Text("Okay")))
}
var body: some View {
VStack {
Text("this is my main view")
}
.alert(isPresented: self.$showAlert,
content: { self.notificationReminder() })
}
}
using code I then flipped showAlert
I have a button in a view (inside a NavigationView) that opens a full screen cover - a loading screen while some data is processing. When the cover is dismissed, I want to automatically route to the next view programmatically. I'm using a NavigationLink with a tag and selection binding, and the binding value updates when the cover is dismissed, but the routing doesn't happen unless I tap that same "open modal" button again.
import SwiftUI
struct OpenerView: View {
#EnvironmentObject var viewModel: OpenerViewModel
#State private var selection: Int? = nil
#State private var presentLoadingScreen = false
var body: some View {
VStack {
NavigationLink(destination: SecondScreen(), tag: 1, selection: $selection) { EmptyView() }
Button(action: {
viewModel.frequency = 0
self.presentLoadingScreen.toggle()
}, label: {
Text("Open cover")
}).buttonStyle(PlainButtonStyle())
}
.navigationBarTitle("Nav title", displayMode: .inline)
.fullScreenCover(isPresented: $presentLoadingScreen, onDismiss: {
self.selection = 1
}, content: ModalView.init)
}
}
struct ModalView: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
Text("Howdy")
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
presentationMode.wrappedValue.dismiss()
}
}
}
}
The first time I hit the Button, the cover opens. Inside the cover is just a DispatchQueue.main.asyncAfter which dismisses it after 2 seconds. When it's dismissed, the onDismiss fires, but then I have to hit the button again to route to SecondScreen.
Any ideas?
Edit: Added the modal's View
I got it working with some changes to the code, and I'm sharing here along with what I think is happening.
I believe the problem is a race condition using a #State boolean to toggle the cover and the navigation. When the cover is being dismissed, my main OpenerView is being recreated - to be expected with state changes. Because of this, I try to set the #State var selection to trigger the navigation change, but before it can do so, the view is recreated with selection = nil.
There seem to be two ways to solve it in my case:
Move the cover boolean to my view model - this worked, but I didn't want it there because it only applied to this view and it's a shared view model for this user flow. Plus, when the modal is dismissed, you see the current OpenerView for a brief flash and then get routed to the SecondScreen.
Keep the cover boolean in #State, but trigger the navigation change in the button immediately after setting the boolean to open the modal. This worked better for my use case because the modal opens, and when it closes, the user is already on the next screen.
I had a similar problem where I was trying to draw a view after dismissing a fullScreenCover. I kept getting an error that said that the view had been deallocated since it was trying to draw to the fullScreenCover.
I used Joe's hints above to make this work. Specifically:
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
viewToShow()
}
I had previously tried onChange, onDisappear, onAppear - but none of those fit the use case I needed.
I have referred to this Stack Overflow thread SwiftUI How to Pop to root view.
However it doesn't work for my case, of course no answer fits each persons use case perfectly so one always has to modify the answer slightly but of course keeping to the overall outline of the solution.
I have looked at this thread How to popup multiple view off navigation stack, but I am not sure resetting the scene is the best option? There has got to be a "normal" way?
The solution I went with is use an ObservableObject and set it as an EnvironmentObject of the root view.
The navigation stack grows by 4 views:
RootView().environmentObject(AppSettings.shared)
FirstView()
SecondView()
ThirdView()
FourthView()
The NavigationLink isActive state for the FirstView is defined in AppSettings.shared, all the other states are found in the subsequent view and not in AppSettings.
For example:
FirstView -> SecondView the SecondView isActive state is defined in the ViewModel of the FirstView, and so on and so forth.
What I am trying to achieve is to pop to RootView from the FourthView. So on the FourthView there is an environmentObject variable of type AppSettings (passed down as an EnvironmentObject from RootView) and on button press, toggle RootView -> FirstView isActive state to false.
It toggles but there's no navigation.
However, in the debug console this is the error
Trying to pop to a missing destination at /Library/Caches/com.apple.xbs/Sources/Monoceros/Monoceros-42.24.100/Shared/NavigationBridge_PhoneTV.swift:205
From my understanding toggling that state to false should trigger a navigation back, but in the Stack Overflow thread there was a post to use #State variable from RootView -> FirstView and in the EnvironmentObject have a variable moveToDashbord. Then on the RootView add a .onReceive modifier to listen to moveToDashboard publisher and then trigger #State variable. But again that also results in the same debug console message.
In short all solutions result in a missing destination console message. Is this because the navigation is too deep?
This is an iPad only project, and the navigationView style is set to StackedNavigationStyle.
System Details:
Xcode 11.6
iOS/ PadOS Target 13.0 (so not using SwiftUI 2.0, if that is a thing)
Code examples:
This is the SceneDelegate which sets the AppSettings as system wide EnvironmentObject.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = LoginView().environmentObject(AppSettings.shared)
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
This is an example of the navigationLink to the RootView
NavigationLink(destination: RootView().navigationBarBackButtonHidden(true), isActive: self.$loginViewModel.loginSuccess) {
EmptyView()
}
This is an example of navigationLink from RootView -> FirstView:
NavigationLink(destination: FirstView().environmentObject(FirstViewModel()), isActive: self.$appSettings.firstView) {
EmptyView()
}.isDetailLink(false)
Note. I had to change the actual names of the Views for clarification sake.
I found the issue, on one of the views there was a UIViewControllerRepresentable wrapped UIAlertController. It was structured as a view modifier with provision for content.
This basically wrapped over the original view which ended up breaking the navigation stack.
The solution was to rather wrap a UIViewController which is presented by the View and in that ViewController resides the UIAlertController.
This solution for the UIAlertController was used and adapted SwiftUI - UIAlertController
I do some tests. If I just make a button like this:
Button(action: {
self.showActionSheet = true
}) {
Text("Click")
}.actionSheet(isPresented: $showActionSheet) {
ActionSheet(title: Text("This is a test"))
}
It works!
But if I put it in NavigationView, the bug appears! The ActionSheet will pop up again when I clicked the Cancel.
In SwiftUI the view is a function of the state, so if your flag that is linked to a modal, an action sheet or an alert appearing is not set back to false the overlay will keep on being presented. When the user dismisses the action sheet, your ContentView gets redrawn and because showActionSheet is still true it gets shown again.
ActionSheet
.actionSheet(isPresented: $showActionSheet) {
ActionSheet(title: Text("This is a test"),
buttons: [ActionSheet.Button.cancel({
self.showActionSheet = false })])
}
Modal
.sheet(item: $showModal,
onDismiss: {
self.showModal = false
}) { Text("Modal") }
I post my question on Swift Forum and receive an answer.
https://forums.swift.org/t/actionsheet-and-modal-seems-have-bug-in-navigationview/29590
If I put the ActionSheet or Modal outside NavigationView, it works perfectly!
I am creating an App where the login / register part is inside a modal, which is shown if the user is not logged in.
The problem is, that the user can dismiss the modal by swiping it down...
Is it possible to prevent this?
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) { // This needs to be non-dismissible
LoginRegister()
}
}
Second example:
I am using a modal to ask for information. The user should not be able to quit this process except by dismissing the modal with save button. The user has to input information before the button works. Unfortunately the modal can be dismissed by swiping it down.
Is it possible to prevent this?
iOS 15 and later:
Use .interactiveDismissDisabled(true) on the sheet, that's all.
Prev iOS 15:
You can try to do this by using a highPriorityGesture. Of course the blue Rectangle is only for demonstration but you would have to use a view which is covering the whole screen.
struct ModalViewNoClose : View {
#Environment(\.presentationMode) var presentationMode
let gesture = DragGesture()
var body: some View {
Rectangle()
.fill(Color.blue)
.frame(width: 300, height: 600)
.highPriorityGesture(gesture)
.overlay(
VStack{
Button("Close") {
self.presentationMode.value.dismiss()
}.accentColor(.white)
Text("Modal")
.highPriorityGesture(gesture)
TextField("as", text: .constant("sdf"))
.highPriorityGesture(gesture)
} .highPriorityGesture(gesture)
)
.border(Color.green)
}
}
This is a common problem and a "code smell"... well not really code but a "design pattern smell" anyway.
The problem is that you are making your login process part of the rest of the app.
Instead of presenting the LoginRegister over the App you should really be showing either App or LoginRegister.
i.e. you should have some state object like userLoggedIn: Bool or something and depending on that value you should show either App or LoginRegister.
Just don't have both in the view hierarchy at the same time. That way your user won't be able to dismiss the view.
If you dont mind using Introspect:
import Introspect
#available(iOS 13, *)
extension View {
/// A Boolean value indicating whether the view controller enforces a modal behavior.
///
/// The default value of this property is `false`. When you set it to `true`, UIKit ignores events
/// outside the view controller's bounds and prevents the interactive dismissal of the
/// view controller while it is onscreen.
public func isModalInPresentation(_ value: Bool) -> some View {
introspectViewController {
$0.isModalInPresentation = value
}
}
}
Usage:
.sheet {
VStack {
...
}.isModalInPresentation(true)
}
iOS 15+
Starting from iOS 15 you can use interactiveDismissDisabled.
You just need to attach it to the sheet:
var body: some View {
TabView(selection: $selection) {
App()
}.sheet(isPresented: self.$showSheet) {
LoginRegister()
.interactiveDismissDisabled(true)
}
}
Regarding your second example, you can pass a variable to control when the sheet is disabled:
.interactiveDismissDisabled(!isAllInformationProvided)
You can find more information in the documentation.
theoretically this may help you (I didn't tryed it)
private var isDisplayedBind: Binding<Bool>{ Binding(get: { true }, set: { _ = $0 }) }
and usage:
content
.sheet(isPresented: isDisplayedBind) { some sheet }