在SwiftUI中防止模态视图控制器被解除

61

在WWDC 2019上,苹果宣布了一种新的“卡片式”外观,用于模态展示,并带来了内置手势,通过向下滑动卡片可以关闭模态视图控制器。他们还在UIViewController中引入了新的isModalInPresentation属性,以便您可以选择禁止此关闭行为。

然而,到目前为止,我发现没有办法在SwiftUI中模拟这种行为。使用.presentation(_ modal: Modal?),据我所知,并不允许您以同样的方式禁用关闭手势。我还尝试将模态视图控制器放置在一个UIViewControllerRepresentableView中,但那似乎也没有帮助:

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结构可以实现这一点?

10个回答

91

iOS 15更新

根据pawello2222在下面的答案中所述,这现在受到新的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之前的解决方案

我也想达到同样的效果,但却无法在任何地方找到解决方案。抢夺拖动手势的答案有些可行,但当通过滚动滚动视图或表单来关闭它时,就会失效。问题中的方法也不是很巧妙,因此我进一步研究了它。

对于我的用例,我有一个表单在一个工作表中,当没有内容时最好可以关闭它,但当有内容时必须通过警报进行确认。

我解决这个问题的方法:

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)
    }
}

这非常适合我的使用场景,希望它也能对你或其他人有所帮助。

5
这是正确的做法,没有什么巧妙的技巧,非常优雅。感谢Guido。 - Junyi Wang
9
一位感激的读者建议:isModal不应该是一个绑定 (Binding),因为它是只读的。然而,删除 @Binding 会破坏代码,因为 Coordinator 只会存储 isModal 的初始值。为了解决这个问题,您可以使协调器中的 modalView 成为一个 var,然后在 updateUIViewController 中更新它,如 context.coordinator.modalView = self,这样如果 isModal 发生更改,它将正确更新。关于状态变量不更新的评论可以通过在 updateUIViewController 中执行 uiViewController.rootView = view 来解决,否则视图将无法正确更新。 - Helam
3
@jjatie,我没有使用 .presentationMode,而是使用了一个绑定到变量的方式来使得这个 sheet 可见,即 .sheet(isPresented: $showSheet),所以请加上 @Binding var showSheet: Bool,并且在需要关闭时使用 self.showSheet = false,而不是 self.presentationMode.wrappedValue.dismiss() - LetsGoBrandon
3
因为某些奇怪的原因,这在NavigationView或者甚至是简单的Text上无法实现。不过在Form中可以正常工作。 - Abdalrahman Shatou
3
除了 @Helam 评论中提到的更改外,我还需要使用 UIHostingController 的子类来覆盖 willMove(to: parent) 方法,以设置父视图控制器的 presentationController 属性,因为在我第一次尝试解散视图控制器之前,updateUIViewController 方法没有被调用。 - vedosity
显示剩余11条评论

16

通过更改任何视图的手势优先级,您可以防止在任何视图上拖动手势。例如,对于模态视图,可以按如下方式操作:

也许这不是最佳实践,但它完美地发挥了作用。

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()
        }
    }
  }
}

2
目前来看,这是我见过的最好的解决方案。 - Jumhyn
1
然而,用两个手指向下拖动表格会使其消失,而且如果视图的主体被另一个视图(如UIActivityIndicatorView)覆盖,则此解决方案将无法正常工作。 - Aleyam
@Aleyam,你提到的那些可能是新问题(用两根手指向下拖动表格),我相信有解决方案。当然,这段代码不会在你粘贴到任何地方都起作用。这只是为了得到一些想法。 - FRIDDAY
2
是的,我明白你的观点(这个回答背后的思路)。问题是关于“在SwiftUI中防止模态视图控制器被解雇”,如果用两只手指拖动会关闭该表单,那么答案似乎不完整,而且为了复杂视图实现此逻辑可能会变得非常棘手。 - Aleyam
它不仅可以通过双指拖动来关闭,而且还可以通过拖动表单内的任何其他视图来关闭。虽然这是被接受的答案,但一定有更好、更少hack的方法来实现这个功能。 - krummens

11
注意:此代码已经进行了编辑,以提高清晰度和简洁性。
使用从 这里 获取当前窗口场景的方法,您可以通过此扩展here@Bobj-C获取顶部视图控制器。
extension UIApplication {

    func visibleViewController() -> UIViewController? {
        guard let window = UIApplication.shared.windows.first(where: { $0.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))
        }
    }
}

1
很遗憾这里没有效果。我认为我没有做错什么,因为大部分都是复制粘贴和扩展以及一个if语句。 - iMaddin
1
@iMaddin,您是否使用它作为视图修饰符进行编辑会有所不同? - R. J.
1
复制粘贴了您的扩展和 ViewModifier,并将其用于修改我的 Sheet 内容。效果非常好!看起来很棒,没有问题。 - user15072454

9
对于那些在使用@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")
                }
            }
        )
    }
}

1
谢谢!这同样适用于其他SwiftUI容器,不仅仅是Form! - Bio-Matic
1
如果您需要presentationControllerDidDismiss的完成,使用上面的解决方案可以完美地解决问题。谢谢! - nikolsky
1
如果您的 canDismissSheet 变量被另一个视图引用并且可以进行动画,则在使用 interactiveDismissDisabled(_:) 时动画不起作用。可能是因为 uiViewController.rootView = view 的原因。 - Manabu Nakazawa

6

iOS 15+

从iOS 15开始,我们可以使用interactiveDismissDisabled

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

我们只需要将它附加到该表上:

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

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

如有需要,您还可以传递变量来控制何时可以禁用该表:

.interactiveDismissDisabled(!userAcceptedTermsOfUse)

太棒了,很高兴看到这个功能被包含在新的更新中!当用户尝试关闭时是否有执行操作的 API? - Jumhyn
@Jumhyn 不,我在文档中找不到任何东西。不过,这已经是一个进步了。 - pawello2222

5

从iOS14开始,如果您不想使用取消手势,可以使用 .fullScreenCover(isPresented:, content:) (文档) 代替 .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()
                }
        }
    }
}

注意:在 macOS 上,fullScreenCover 不可用,但在 iPhone 和 iPad 上可以正常使用。

注意:这种解决方案不允许您在满足某些条件时启用解除手势。要使用条件启用和禁用解除手势,请参见我的其他回答


1
这也提供了使用不同的用户界面——正如其名称所示,它模仿了“全屏”模态显示样式,并且不会给你iOS 13中的“卡片样式”模态。 - Jumhyn
1
是的,这会导致不同的外观,视觉上向用户表明模态框不能使用拖动手势关闭,我认为在大多数情况下这可能是理想的。此外,该解决方案使用了文档化的SwiftUI结构来完成任务。 - SlimeBaron
当然,只是想确保提到这不是问题中发布的确切问题的解决方案,这样使用此答案的任何人在获得不同外观时不会感到惊讶! :) - Jumhyn

3
你可以使用此方法传递模态视图的内容以便重复使用。
使用带有“手势优先级”的 NavigationView 来禁用拖动。
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.")
       }
    }
  }
}

结果!

输入图像描述


1
这个解决方案对我在iPhone和iPad上都有效。它使用了isModalInPresentation。来自文档

此属性的默认值为false。将其设置为true时,UIKit会忽略视图控制器边界外的事件,并防止在屏幕上显示视图控制器时进行交互式解除。

您的尝试接近于我的解决方法。关键是在willMove(toParent:)中设置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
    }
}

感谢在iPad上进行测试。这看起来是一个不错的解决方案,但我不喜欢它似乎依赖于有关问题视图上方的UIKit层次结构的私有细节。仍在寻找完美的答案:( - Jumhyn
哈!你知道吗,我第一次看的时候居然错过了那个答案中提到的 uiViewController.parent。你说得对,在这方面它并不比其他方法更糟糕。我会在iPad上试一试这两种方法,如果效果更好的话就接受你的答案 :) - Jumhyn
这个被接受的答案在我的iPad和iPhone上都能正常工作。你遇到了什么问题? - Jumhyn
嗯,我能够在iPad上关闭该表格。不过那可能只是偶然事件。我会从我的答案中删除那条评论。感谢您的检查。 - SlimeBaron
我认为可以通过将任务分派到主队列并修改一些状态来完成。即便如此,我认为 uiViewController 可能还没有其 parent - SlimeBaron
显示剩余3条评论

1
我们创建了一个扩展程序,使得控制模态框的关闭变得轻松无比,可在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)
        }
    }
}

0

它支持大多数iOS版本,无需制作包装器,只需这样做

extension UINavigationController {

open override func viewDidLoad() {
    super.viewDidLoad()
    interactivePopGestureRecognizer?.isEnabled = false
    interactivePopGestureRecognizer?.delegate = nil    
}}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接