防止在 SwiftUI 中关闭模态视图控制器

Prevent dismissal of modal view controller in SwiftUI

在 WWDC 2019 上,Apple 宣布了一种新的 "card-style" 模态演示外观,它带来了内置手势,可以通过在卡片上向下滑动来关闭模态视图控制器。他们还在 UIViewController 上引入了新的 isModalInPresentation 属性,这样您就可以选择禁止这种解雇行为。

不过,到目前为止,我还没有找到在 SwiftUI 中模拟这种行为的方法。据我所知,使用 .presentation(_ modal: Modal?) 不允许您以相同的方式禁用关闭手势。我还尝试将模态视图控制器放在 UIViewControllerRepresentable View 中,但这似乎也无济于事:

struct MyViewControllerView: UIViewControllerRepresentable {
    func makeUIViewController(context: UIViewControllerRepresentableContext<MyViewControllerView>) -> UIHostingController<MyView> {
        return UIHostingController(rootView: MyView())
    }

    func updateUIViewController(_ uiViewController: UIHostingController<MyView>, context: UIViewControllerRepresentableContext<MyViewControllerView>) {
        uiViewController.isModalInPresentation = true
    }
}

即使在使用 .presentation(Modal(MyViewControllerView())) 进行演示后,我也能够向下滑动以关闭视图。目前有什么方法可以使用现有的 SwiftUI 结构来做到这一点吗?

通过更改您不想被拖动的任何视图的 gesture priority,您可以阻止任何视图上的 DragGesture。例如,对于 Modal,可以按以下方式完成:

也许这不是最佳做法,但效果很好

struct ContentView: View {

@State var showModal = true

var body: some View {

    Button(action: {
        self.showModal.toggle()

    }) {
        Text("Show Modal")
    }.sheet(isPresented: self.$showModal) {
        ModalView()
    }
  }
}

struct ModalView : View {
@Environment(\.presentationMode) var presentationMode

let dg = DragGesture()

var body: some View {

    ZStack {
        Rectangle()
            .fill(Color.white)
            .frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height)
            .highPriorityGesture(dg)

        Button("Dismiss Modal") {
            self.presentationMode.wrappedValue.dismiss()
        }
    }
  }
}

注意:为清楚和简洁起见,已编辑此代码。

使用一种方法从 you can get the top view controller by this extension here from @Bobj-C

获取当前window场景
extension UIApplication {

    func visibleViewController() -> UIViewController? {
        guard let window = UIApplication.shared.windows.first(where: { [=10=].isKeyWindow }) else { return nil }
        guard let rootViewController = window.rootViewController else { return nil }
        return UIApplication.getVisibleViewControllerFrom(vc: rootViewController)
    }

    private static func getVisibleViewControllerFrom(vc:UIViewController) -> UIViewController {
        if let navigationController = vc as? UINavigationController,
            let visibleController = navigationController.visibleViewController  {
            return UIApplication.getVisibleViewControllerFrom( vc: visibleController )
        } else if let tabBarController = vc as? UITabBarController,
            let selectedTabController = tabBarController.selectedViewController {
            return UIApplication.getVisibleViewControllerFrom(vc: selectedTabController )
        } else {
            if let presentedViewController = vc.presentedViewController {
                return UIApplication.getVisibleViewControllerFrom(vc: presentedViewController)
            } else {
                return vc
            }
        }
    }
}

并将其变成这样的视图修饰符:

struct DisableModalDismiss: ViewModifier {
    let disabled: Bool
    func body(content: Content) -> some View {
        disableModalDismiss()
        return AnyView(content)
    }

    func disableModalDismiss() {
        guard let visibleController = UIApplication.shared.visibleViewController() else { return }
        visibleController.isModalInPresentation = disabled
    }
}

并像这样使用:

struct ShowSheetView: View {
    @State private var showSheet = true
    var body: some View {
        Text("Hello, World!")
        .sheet(isPresented: $showSheet) {
            TestView()
                .modifier(DisableModalDismiss(disabled: true))
        }
    }
}

更新 iOS 15

根据下面的 pawello2222's ,新 interactiveDismissDisabled(_:) API.

现在支持此功能
struct ContentView: View {
    @State private var showSheet = false

    var body: some View {
        Text("Content View")
            .sheet(isPresented: $showSheet) {
                Text("Sheet View")
                    .interactiveDismissDisabled(true)
            }
    }
}

前iOS-15个答案

我也想这样做,但找不到任何解决方案。劫持拖动手势的答案有点管用,但当它通过滚动滚动视图或表单被取消时就不行了。问题中的方法也不那么hacky,所以我进一步调查了它。

对于我的用例,我有一个 sheet 形式的表单,理想情况下可以在没有内容时将其关闭,但在有内容时必须通过警报进行确认。

我对这个问题的解决方案:

struct ModalSheetTest: View {
    @State private var showModally = false
    @State private var showSheet = false
    
    var body: some View {
        Form {
            Toggle(isOn: self.$showModally) {
                Text("Modal")
            }
            Button(action: { self.showSheet = true}) {
                Text("Show sheet")
            }
        }
        .sheet(isPresented: $showSheet) {
            Form {
                Button(action: { self.showSheet = false }) {
                    Text("Hide me")
                }
            }
            .presentation(isModal: self.showModally) {
                print("Attempted to dismiss")
            }
        }
    }
}

状态值showModally决定是否必须模态显示。如果是这样,将其向下拖动以关闭只会触发示例中仅打印“尝试关闭”的关闭,但可用于显示确认关闭的警报。

struct ModalView<T: View>: UIViewControllerRepresentable {
    let view: T
    let isModal: Bool
    let onDismissalAttempt: (()->())?
    
    func makeUIViewController(context: Context) -> UIHostingController<T> {
        UIHostingController(rootView: view)
    }
    
    func updateUIViewController(_ uiViewController: UIHostingController<T>, context: Context) {
        context.coordinator.modalView = self
        uiViewController.rootView = view
        uiViewController.parent?.presentationController?.delegate = context.coordinator
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
        let modalView: ModalView
        
        init(_ modalView: ModalView) {
            self.modalView = modalView
        }
        
        func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
            !modalView.isModal
        }
        
        func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
            modalView.onDismissalAttempt?()
        }
    }
}

extension View {
    func presentation(isModal: Bool, onDismissalAttempt: (()->())? = nil) -> some View {
        ModalView(view: self, isModal: isModal, onDismissalAttempt: onDismissalAttempt)
    }
}

这非常适合我的用例,希望它也能帮助您或其他人。

我们在 https://gist.github.com/mobilinked/9b6086b3760bcf1e5432932dad0813c0

创建了一个扩展,可以轻松控制模态解除
/// Example:
struct ContentView: View {
    @State private var presenting = false
    
    var body: some View {
        VStack {
            Button {
                presenting = true
            } label: {
                Text("Present")
            }
        }
        .sheet(isPresented: $presenting) {
            ModalContent()
                .allowAutoDismiss { false }
                // or
                // .allowAutoDismiss(false)
        }
    }
}

您可以使用此方法传递模态视图的内容以供重复使用。

将 NavigationView 与 gesture priority 结合使用以禁用 dragging

import SwiftUI

struct ModalView<Content: View>: View
{
    @Environment(\.presentationMode) var presentationMode
    let content: Content
    let title: String
    let dg = DragGesture()
    
    init(title: String, @ViewBuilder content: @escaping () -> Content) {
        self.content = content()
        self.title = title
    }
    
    var body: some View
    {
        NavigationView
        {
            ZStack (alignment: .top)
            {
                self.content
            }
            .navigationBarTitleDisplayMode(.inline)
            .toolbar(content: {
                ToolbarItem(placement: .principal, content: {
                    Text(title)
                })
                
                ToolbarItem(placement: .navigationBarTrailing, content: {
                    Button("Done") {
                        self.presentationMode.wrappedValue.dismiss()
                    }
                })
            })
        }
        .highPriorityGesture(dg)
    }
}

在内容视图中:

struct ContentView: View {

@State var showModal = true

var body: some View {

    Button(action: {
       self.showModal.toggle()
    }) {
       Text("Show Modal")
    }.sheet(isPresented: self.$showModal) {
       ModalView (title: "Title") {
          Text("Prevent dismissal of modal view.")
       }
    }
  }
}

结果!

此解决方案在 iPhone 和 iPad 上对我有效。它使用 isModalInPresentation。来自 the docs:

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.

您的尝试与对我有用的方法很接近。诀窍是在 willMove(toParent:)

中的托管控制器的 parent 上设置 isModalInPresentation
class MyHostingController<Content: View>: UIHostingController<Content> {
    var canDismissSheet = true

    override func willMove(toParent parent: UIViewController?) {
        super.willMove(toParent: parent)
        parent?.isModalInPresentation = !canDismissSheet
    }
}

struct MyViewControllerView<Content: View>: UIViewControllerRepresentable {
    let content: Content
    let canDismissSheet: Bool

    func makeUIViewController(context: Context) -> UIHostingController<Content> {
        let viewController = MyHostingController(rootView: content)
        viewController.canDismissSheet = canDismissSheet
        return viewController
    }

    func updateUIViewController(_ uiViewController: UIHostingController<Content>, context: Context) {
        uiViewController.parent?.isModalInPresentation = !canDismissSheet
    }
}

从 iOS 14 开始,如果您不想要关闭手势,可以使用 .fullScreenCover(isPresented:, content:) (Docs) 而不是 .sheet(isPresented:, content:)

struct FullScreenCoverPresenterView: View {
    @State private var isPresenting = false

    var body: some View {
        Button("Present Full-Screen Cover") {
            isPresenting.toggle()
        }
        .fullScreenCover(isPresented: $isPresenting) {
            Text("Tap to Dismiss")
                .onTapGesture {
                    isPresenting.toggle()
                }
        }
    }
}

注意fullScreenCover 在 macOS 上不可用,但在 iPhone 和 iPad 上运行良好。

注意:此解决方案不允许您在满足特定条件时启用解雇手势。要启用和禁用带条件的解雇手势,请参阅我的

iOS 15

从iOS15开始我们可以使用interactiveDismissDisabled:

func interactiveDismissDisabled(_ isDisabled: Bool = true) -> some View

我们只需要将它附加到 sheet:

struct ContentView: View {
    @State private var showSheet = false

    var body: some View {
        Text("Content View")
            .sheet(isPresented: $showSheet) {
                Text("Sheet View")
                    .interactiveDismissDisabled(true)
            }
    }
}

如果需要,您还可以传递一个变量来控制何时可以禁用 sheet:

.interactiveDismissDisabled(!userAcceptedTermsOfUse)

致所有对@Guido 的解决方案和 NavigationView 有疑问的人。只需结合@Guido 和@SlimeBaron

的解决方案
class ModalHostingController<Content: View>: UIHostingController<Content>, UIAdaptivePresentationControllerDelegate {
    var canDismissSheet = true
    var onDismissalAttempt: (() -> ())?

    override func willMove(toParent parent: UIViewController?) {
        super.willMove(toParent: parent)

        parent?.presentationController?.delegate = self
    }

    func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
        canDismissSheet
    }

    func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
        onDismissalAttempt?()
    }
}

struct ModalView<T: View>: UIViewControllerRepresentable {
    let view: T
    let canDismissSheet: Bool
    let onDismissalAttempt: (() -> ())?

    func makeUIViewController(context: Context) -> ModalHostingController<T> {
        let controller = ModalHostingController(rootView: view)

        controller.canDismissSheet = canDismissSheet
        controller.onDismissalAttempt = onDismissalAttempt

        return controller
    }

    func updateUIViewController(_ uiViewController: ModalHostingController<T>, context: Context) {
        uiViewController.rootView = view

        uiViewController.canDismissSheet = canDismissSheet
        uiViewController.onDismissalAttempt = onDismissalAttempt
    }
}

extension View {
    func interactiveDismiss(canDismissSheet: Bool, onDismissalAttempt: (() -> ())? = nil) -> some View {
        ModalView(
            view: self,
            canDismissSheet: canDismissSheet,
            onDismissalAttempt: onDismissalAttempt
        ).edgesIgnoringSafeArea(.all)
    }
}

用法:

struct ContentView: View {
    @State var isPresented = false
    @State var canDismissSheet = false

    var body: some View {
        Button("Tap me") {
            isPresented = true
        }
        .sheet(
            isPresented: $isPresented,
            content: {
                NavigationView {
                    Text("Hello World")
                }
                .interactiveDismiss(canDismissSheet: canDismissSheet) {
                    print("attemptToDismissHandler")
                }
            }
        )
    }
}