I am new to Swift/SwiftUI and want to know how to dismiss modal/page from nested child view.
Firstly, I am calling from Flutter, UIHostingController, then SwiftUI page. (currently showing as modal...)
After Navigating to SwiftUI, I am not able to use #environment data from child view.
Is there any ways for this to work?
thanks in advance.
AppDelegate.swift
#UIApplicationMain
#objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
let controller : FlutterViewController = self.window?.rootViewController as! FlutterViewController
let channel = FlutterMethodChannel.init(name: "com.example.show", binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler({
(call, result) -> Void in
if call.method == "sample" {
let vc = UIHostingController(rootView: ContentView())
vc.modalPresentationStyle = .fullScreen
controller.present(vc, animated: true,completion: nil)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
ContentView.swift
struct ContentView: View{
#Environment(\.presentationMode) var presentation: Binding<PresentationMode>
private var childView: ChildView()
var body: some View{
NavigationView{
ZStack{
childView
Button(action: {
// This works ************************
self.presentation.wrappedValue.dismiss()
}, label: {
Text("close")
})
}
}
}
}
ChildView.swift
struct ChildView: View{
#Environment(\.presentationMode) var presentation: Binding<PresentationMode>
var body: some View{
Button(action: {
// This won't do anything *********************************
self.presentation.wrappedValue.dismiss()
// nor this↓ **********************************************
if #available(iOS 15.0, *) {
#Environment(\.dismiss) var dismiss;
dismiss()
dismiss.callAsFunction()
}
}, label: {
Text("close")
})
}
}
Since you had another NavigationView is your ContentView, the #Environment(\.presentation) inside ChildView is of a child and not the parent. Basically those two are from completely different Navigation stacks.
In order to still keep NavigationView inside your parent ContentView, you need to pass the presentation value from constructor of ChildView instead of environment:
ContentView.swift
struct ContentView: View{
#Environment(\.presentationMode) var presentation: Binding<PresentationMode>
var body: some View{
NavigationView{
ZStack{
ChildView(parentPresentation: presentation)
Button(action: {
self.presentation.wrappedValue.dismiss()
}, label: {
Text("close")
})
}
}
}
}
In child view, use normal property instead of #Environment
ChildView.swift
struct ChildView: View{
let parentPresentation: Binding<PresentationMode>
var body: some View{
Button(action: {
self.parentPresentation.wrappedValue.dismiss()
if #available(iOS 15.0, *) {
#Environment(\.dismiss) var dismiss;
dismiss()
dismiss.callAsFunction()
}
}, label: {
Text("Close")
})
}
}
For iOS 15.0 and above, we can use the new environment value dismiss, and for that to work with child view, we should also pass it from the parent view to the child view:
ContentView.swift
struct ContentView: View {
#Environment(\.dismiss) private var dismiss
var body: some View {
NavigationView {
ZStack {
ChildView(parentDismiss: dismiss)
Button {
dismiss()
} label: {
Text("close")
}
}
}
}
}
ChildView.swift
struct ChildView: View {
let parentDismiss: DismissAction
var body: some View {
Button {
parentDismiss()
} label: {
Text("Close")
}
}
}
I figured it out that NavigationView in ContentView.swift caused this issue.
Removing NavigationView, I could dismiss modal page from child view...
But this is not what I intended for :(...
var body: some View{
// NavigationView{ <--------
ZStack{
childView
Button(action: {
self.presentation.wrappedValue.dismiss()
}, label: {
Text("close")
})
}
// }
}
Related
I found a question like this on Stack Overflow however it did not seem to work. Maybe I'm implementing it wrong, or it no longer works. I have a #Binding showDetailView that when equals true, shows the detail view.
This detail view has a custom back button that works. I want to be able to do a swipe gesture that performs the same function as the back button.
The code I found that looks like it could help is:
extension UINavigationController: UIGestureRecognizerDelegate {
override open func viewDidLoad() {
super.viewDidLoad()
interactivePopGestureRecognizer?.delegate = self
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
return viewControllers.count > 1
}
}
The code for my main view (product page) is:
struct ProductPage: View {
#StateObject var MarketplaceModel = MarketplaceViewModel()
#State private var selectedMarketplaceFilter: MarketplaceFilterViewModel = .productList
#Namespace var animation : Namespace.ID
#State var showDetailProduct = false
#State var selectedProduct : Product!
var body: some View {
var columns = Array(repeating: GridItem(.flexible()), count: 2)
ZStack{
VStack(spacing: 10){
if MarketplaceModel.products.isEmpty{
Spacer()
ProgressView()
Spacer()
}
else{
ScrollView(.vertical, showsIndicators: false, content: {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(),spacing: 10), count: 2),spacing: 20){
ForEach(MarketplaceModel.filteredProduct){product in
ProductView(productData: product)
.onTapGesture {
withAnimation {
selectedProduct = product
showDetailProduct.toggle()
}
}
}
}
})
}
}
if selectedProduct != nil && showDetailProduct{
ProductDetailView(showDetailProduct: $showDetailProduct, productData: selectedProduct, product_id)
.transition(.move(edge: .trailing))
}
}
}
}
The code for my detail view (product detail view) is:
struct ProductDetailView: View {
#StateObject var MarketplaceModel = MarketplaceViewModel()
#Binding var showDetailProduct: Bool
#Namespace var animation: Namespace.ID
#EnvironmentObject var marketplaceData: MarketplaceViewModel
var productData : Product
var product_id: String
var body: some View {
NavigationView{
VStack{
VStack{
// Title Bar...
HStack {
Button(action: {
withAnimation{showDetailProduct.toggle()}
}) {
Image(systemName: "arrow.backward.circle.fill")
Spacer()
ForEach(MarketplaceModel.product_details_array){ items in
Text(items.product_name)
.font(.largeTitle)
.fontWeight(.heavy)
.foregroundColor(.black)
}
}
}
ScrollView {
VStack {
Text(productData.product_name)
Text(productData.product_details)
}
}
}
}
}
}
}
How would I go about adding the back swipe gesture? (Running Xcode 13.4.1)
Would like to return to my presenting UIKit View Controller after pressing the save button on my SwiftUI View (presented via a HostingViewController).
This is how a navigate to my SwiftUI view from my UIKit VC.
let profileView = suiProfileView().environmentObject(suiProfileViewModel())
let profileVC = UIHostingController(rootView: profileView)
let navVC = UINavigationController(rootViewController: profileVC)
navVC.modalPresentationStyle = .fullScreen
SideMenuManager.default.leftMenuNavigationController?.present(navVC, animated: true , completion: nil)
This is my SwiftUI View that i'd like to dismiss after pressing the Save button
import SwiftUI
import Combine
struct suiProfileView: View {
#SwiftUI.Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
#EnvironmentObject var profileViewModel: suiProfileViewModel
#State var suiTitleText = t("general.save")
var body: some View {
GeometryReader { geo in
VStack {
personalInfoSection
Spacer()
Button("Save", action: profileViewModel.saveProfile)
.frame(width: geo.size.width * 0.8, height: geo.size.height * 0.08, alignment: .center)
}
.onReceive(profileViewModel.viewDismissalModePublisher) { shouldDismiss in
if shouldDismiss {
self.presentationMode.wrappedValue.dismiss()
}
}
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
.padding()
}
}
}
This is my ProfileViewModel class which publishes the shouldDismissView variable using Combine after running some business logic.
class suiProfileViewModel: suiProfileViewModelProtocol {
//private var model: suiProfileFormProtocol
#Published var profile = suiProfileForm()
var viewDismissalModePublisher = PassthroughSubject<Bool, Never>()
private var shouldDismissView = false {
didSet {
viewDismissalModePublisher.send(shouldDismissView)
}
}
func businessLogicThatDeterminesIfShouldDismissView() {
//....
}
}
For some reason self.presentationMode.wrappedValue.dismiss() which was called in my SwiftUI view is not dismissing my SwiftUI view and not allowing me to go back to my initial UIKit View Controller who presented it in the first place. Any help would greatly appreciated. Thanks in advance
A couple problems that I see:
You're trying to dismiss your suiProfileView , but really, it's wrapped in a UINavigationController before you present it.
According to a basic test that I just did, even if your suiProfileView were presented without the navigation controller, presentationMode still doesn't work on it -- my suspicion is that it's only passed accurately when going SwiftUI->SwiftUI and can't be trusted to be communicated through the UIHostingController
I would suggest passing a closure to suiProfileView that can run the dismiss code from your original presenting view controller. Here's a simplified example:
import UIKit
import SwiftUI
class ViewController: UIViewController {
var presentedController : UINavigationController?
func dismissVC() {
presentedController?.dismiss(animated: true)
}
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
let swiftUIView = ContentView(dismiss: self.dismissVC)
let hostVC = UIHostingController(rootView: swiftUIView)
let navVC = UINavigationController(rootViewController: hostVC)
navVC.modalPresentationStyle = .fullScreen
self.present(navVC, animated: true , completion: nil)
self.presentedController = navVC
}
}
}
struct ContentView : View {
var dismiss : () -> Void
#Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
var body: some View {
Button(action: {
dismiss()
//presentationMode.wrappedValue.dismiss() <-- doesn't function
}) {
Text("Close")
}
}
}
The following code reproduced the error:
import SwiftUI
struct ContentView: View {
#State private var number: Int = 5
var body: some View {
NavigationView() {
VStack(spacing: 20) {
NavigationLink(destination: SecondView(bottles: $number)) {
Text("Click me")
}
}
}
}
}
struct SecondView: View {
#Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
#State private var color: UIColor = .black
#Binding var bottles: Int
var body: some View {
Text("I have \(bottles) in my bag")
.foregroundColor(Color(color))
.navigationBarTitle(Text("Water Bottle"))
.navigationBarItems(trailing:
Button("Click") {
self.someFunction()
}
)
}
func someFunction() {
if self.color == UIColor.black {
self.color = .red
} else {
self.color = .black
}
}
}
When sliding back from SecondView to ContentView but didn't complete the gesture, the app freezes. When deleting either #Environment or NavigationBarItem will fix this error.
For #Environment, it is needed for CoreData but used presentationMode for reproduction of error
adding ".navigationViewStyle(StackNavigationViewStyle())" to the NavigationView fix the problem for me. This is the code I use for testing this on real devices (iPhone, iPad) and various simulators. Using macos 10.15.5, Xcode 11.5 and 11.6 beta, target ios 13.5 and mac catalyst.
I have not tested this on all devices, so let me know if you find a device where this does not work.
import SwiftUI
struct ContentView: View {
#State private var number: Int = 5
var body: some View {
NavigationView() {
VStack(spacing: 20) {
NavigationLink(destination: SecondView(bottles: $number)) {
Text("Click me")
}
}
}.navigationViewStyle(StackNavigationViewStyle()) // <---
}
}
struct SecondView: View {
#Environment(\.presentationMode) private var presentationMode: Binding<PresentationMode>
#State private var color: UIColor = .black
#Binding var bottles: Int
var body: some View {
Text("I have \(bottles) in my bag")
.foregroundColor(Color(color))
.navigationBarTitle(Text("Water Bottle"))
.navigationBarItems(trailing:
Button("Click") {
self.someFunction()
}
)
}
func someFunction() {
if self.color == UIColor.black {
self.color = .red
} else {
self.color = .black
}
}
}
The interactive pop gesture recognizer should allow the user to go back the the previous view in navigation stack when they swipe further than half the screen (or something around those lines). In SwiftUI the gesture doesn't get canceled when the swipe wasn't far enough.
SwiftUI: https://imgur.com/xxVnhY7
UIKit: https://imgur.com/f6WBUne
Question:
Is it possible to get the UIKit behaviour while using SwiftUI views?
Attempts
I tried to embed a UIHostingController inside a UINavigationController but that gives the exact same behaviour as NavigationView.
struct ContentView: View {
var body: some View {
UIKitNavigationView {
VStack {
NavigationLink(destination: Text("Detail")) {
Text("SwiftUI")
}
}.navigationBarTitle("SwiftUI", displayMode: .inline)
}.edgesIgnoringSafeArea(.top)
}
}
struct UIKitNavigationView<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIViewController(context: Context) -> UINavigationController {
let host = UIHostingController(rootView: content())
let nvc = UINavigationController(rootViewController: host)
return nvc
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {}
}
I ended up overriding the default NavigationView and NavigationLink to get the desired behaviour. This seems so simple that I must be overlooking something that the default SwiftUI views do?
NavigationView
I wrap a UINavigationController in a super simple UIViewControllerRepresentable that gives the UINavigationController to the SwiftUI content view as an environmentObject. This means the NavigationLink can later grab that as long as it's in the same navigation controller (presented view controllers don't receive the environmentObjects) which is exactly what we want.
Note: The NavigationView needs .edgesIgnoringSafeArea(.top) and I don't know how to set that in the struct itself yet. See example if your nvc cuts off at the top.
struct NavigationView<Content: View>: UIViewControllerRepresentable {
var content: () -> Content
init(#ViewBuilder content: #escaping () -> Content) {
self.content = content
}
func makeUIViewController(context: Context) -> UINavigationController {
let nvc = UINavigationController()
let host = UIHostingController(rootView: content().environmentObject(nvc))
nvc.viewControllers = [host]
return nvc
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {}
}
extension UINavigationController: ObservableObject {}
NavigationLink
I create a custom NavigationLink that accesses the environments UINavigationController to push a UIHostingController hosting the next view.
Note: I didn't implement the selection and isActive that the SwiftUI.NavigationLink has because I don't fully understand what they do yet. If you want to help with that please comment/edit.
struct NavigationLink<Destination: View, Label:View>: View {
var destination: Destination
var label: () -> Label
public init(destination: Destination, #ViewBuilder label: #escaping () -> Label) {
self.destination = destination
self.label = label
}
/// If this crashes, make sure you wrapped the NavigationLink in a NavigationView
#EnvironmentObject var nvc: UINavigationController
var body: some View {
Button(action: {
let rootView = self.destination.environmentObject(self.nvc)
let hosted = UIHostingController(rootView: rootView)
self.nvc.pushViewController(hosted, animated: true)
}, label: label)
}
}
This solves the back swipe not working correctly on SwiftUI and because I use the names NavigationView and NavigationLink my entire project switched to these immediately.
Example
In the example I show modal presentation too.
struct ContentView: View {
#State var isPresented = false
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 30) {
NavigationLink(destination: Text("Detail"), label: {
Text("Show detail")
})
Button(action: {
self.isPresented.toggle()
}, label: {
Text("Show modal")
})
}
.navigationBarTitle("SwiftUI")
}
.edgesIgnoringSafeArea(.top)
.sheet(isPresented: $isPresented) {
Modal()
}
}
}
struct Modal: View {
#Environment(\.presentationMode) var presentationMode
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 30) {
NavigationLink(destination: Text("Detail"), label: {
Text("Show detail")
})
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}, label: {
Text("Dismiss modal")
})
}
.navigationBarTitle("Modal")
}
}
}
Edit: I started off with "This seems so simple that I must be overlooking something" and I think I found it. This doesn't seem to transfer EnvironmentObjects to the next view. I don't know how the default NavigationLink does that so for now I manually send objects on to the next view where I need them.
NavigationLink(destination: Text("Detail").environmentObject(objectToSendOnToTheNextView)) {
Text("Show detail")
}
Edit 2:
This exposes the navigation controller to all views inside NavigationView by doing #EnvironmentObject var nvc: UINavigationController. The way to fix this is making the environmentObject we use to manage navigation a fileprivate class. I fixed this in the gist: https://gist.github.com/Amzd/67bfd4b8e41ec3f179486e13e9892eeb
You can do this by descending into UIKit and using your own UINavigationController.
First create a SwipeNavigationController file:
import UIKit
import SwiftUI
final class SwipeNavigationController: UINavigationController {
// MARK: - Lifecycle
override init(rootViewController: UIViewController) {
super.init(rootViewController: rootViewController)
}
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
delegate = self
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
delegate = self
}
override func viewDidLoad() {
super.viewDidLoad()
// This needs to be in here, not in init
interactivePopGestureRecognizer?.delegate = self
}
deinit {
delegate = nil
interactivePopGestureRecognizer?.delegate = nil
}
// MARK: - Overrides
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
duringPushAnimation = true
super.pushViewController(viewController, animated: animated)
}
var duringPushAnimation = false
// MARK: - Custom Functions
func pushSwipeBackView<Content>(_ content: Content) where Content: View {
let hostingController = SwipeBackHostingController(rootView: content)
self.delegate = hostingController
self.pushViewController(hostingController, animated: true)
}
}
// MARK: - UINavigationControllerDelegate
extension SwipeNavigationController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
swipeNavigationController.duringPushAnimation = false
}
}
// MARK: - UIGestureRecognizerDelegate
extension SwipeNavigationController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer == interactivePopGestureRecognizer else {
return true // default value
}
// Disable pop gesture in two situations:
// 1) when the pop animation is in progress
// 2) when user swipes quickly a couple of times and animations don't have time to be performed
let result = viewControllers.count > 1 && duringPushAnimation == false
return result
}
}
This is the same SwipeNavigationController provided here, with the addition of the pushSwipeBackView() function.
This function requires a SwipeBackHostingController which we define as
import SwiftUI
class SwipeBackHostingController<Content: View>: UIHostingController<Content>, UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
swipeNavigationController.duringPushAnimation = false
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
guard let swipeNavigationController = navigationController as? SwipeNavigationController else { return }
swipeNavigationController.delegate = nil
}
}
We then set up the app's SceneDelegate to use the SwipeNavigationController:
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let hostingController = UIHostingController(rootView: ContentView())
window.rootViewController = SwipeNavigationController(rootViewController: hostingController)
self.window = window
window.makeKeyAndVisible()
}
Finally use it in your ContentView:
struct ContentView: View {
func navController() -> SwipeNavigationController {
return UIApplication.shared.windows[0].rootViewController! as! SwipeNavigationController
}
var body: some View {
VStack {
Text("SwiftUI")
.onTapGesture {
self.navController().pushSwipeBackView(Text("Detail"))
}
}.onAppear {
self.navController().navigationBar.topItem?.title = "Swift UI"
}.edgesIgnoringSafeArea(.top)
}
}
this is my ContentView.swift file:
struct ContentView: View {
var body: some View {
NavigationView {
MapView()
}
}
}
and this is my MapView.swift file:
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView(frame: .zero)
return mapView
}
func updateUIView(_ view: MKMapView, context: Context) {
print("test")
}
}
with these lines of codes, when I run app it prints twice test in console.
but when I change ContentView and hide NavigationView from it like this:
struct ContentView: View {
var body: some View {
// NavigationView {
MapView()
// }
}
}
now it prints one test in console.
actually it mean when we use NavigationView with UIViewRepresentable, it calls twice updateUIView func and it seem it is wrong.
how can we handle it?