【问题标题】:Prevent dismissal of modal view controller in SwiftUI防止在 SwiftUI 中关闭模态视图控制器
【发布时间】:2019-10-30 02:36:29
【问题描述】:

在 WWDC 2019 上,Apple 宣布了一种用于模态演示的新“卡片式”外观,它带来了通过向下滑动卡片来关闭模态视图控制器的内置手势。他们还在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 结构来做到这一点?

【问题讨论】:

    标签: ios swift swiftui


    【解决方案1】:

    iOS 15 更新

    根据下面的pawello2222answer,现在新的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)
                }
        }
    }
    

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

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

    【讨论】:

    • 这就是这样做的方法。没有什么hacky和非常优雅的。谢谢吉多。
    • 感谢读者的建议:isModal 不应该是 Binding,因为它是只读的。删除@Binding 会破坏这一点,因为协调器只会存储isModal 的初始值。要解决此问题,您可以在协调器中将modalView 设为var,然后在updateUIViewController 中更新它,例如context.coordinator.modalView = self,这样如果isModal 发生更改,它将正确更新。状态变量不更新的注释可以通过updateUIViewController中的uiViewController.rootView = view修复,否则视图将无法正常更新。
    • @jjatie 我没有使用 \.presentationMode 而是使用绑定到在这种情况下使工作表可见的变量.sheet(isPresented: $showSheet) 所以把@Binding var showSheet: Boolself.showSheet = false 改为关闭self.presentationMode.wrappedValue.dismiss()
    • 出于某种奇怪的原因,这不适用于 NavigationView 甚至简单的文本。不过,它适用于 Form。
    • 除了@Helam评论的变化之外,我还不得不使用UIHostingController的子类来覆盖willMove(to: parent)来设置父级的presentationController,因为updateUIViewController之前没有发生我第一次尝试关闭视图控制器。
    【解决方案2】:

    通过更改您不想被拖动的任何视图的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()
            }
        }
      }
    }
    

    【讨论】:

    • 目前,这是我见过的最好的解决方案。
    • 有一个问题,虽然用两个手指向下拖动工作表会关闭它,如果视图的主体被另一个视图(Loader ie UIActivityIndi​​catorView)顶上,这个解决方案也不起作用。跨度>
    • @Aleyam 您提到的那些可能是新问题(用两根手指向下拖动工作表),我相信有解决方案。当然,这段代码在你粘贴的任何地方都不起作用。这只是为了获得一个想法。
    • 是的,我明白了你的意思(这个答案背后的想法),问题是关于“防止在 SwiftUI 中解除模式视图控制器”所​​以如果用两根手指拖动会解除工作表,看起来像答案是不完整的,而且,实现这种逻辑以防止解雇成为复杂视图的噩梦。
    • 它不仅会通过两指拖动消失,而且如果您拖动工作表内的任何其他视图也会消失。虽然这是公认的答案,但必须有一种更好、更简单的方法来做到这一点
    【解决方案3】:

    注意:为了清晰和简洁,此代码已经过编辑。

    使用从here获取当前窗口场景的方式,可以通过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))
            }
        }
    }
    

    【讨论】:

    • 不幸的是这里没有效果。我不认为我做错了什么,因为它主要是复制粘贴和扩展以及一个 if 语句。
    • @iMaddin 使用它作为视图修饰符的编辑对你有什么影响吗?
    • 复制粘贴您的扩展程序和 ViewModifier,并用它来修改我的工作表的内容。它工作得很好!看起来很棒,而且没有问题。
    【解决方案4】:

    对于所有对@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")
                    }
                }
            )
        }
    }
    

    【讨论】:

    • 谢谢!这也适用于其他 SwiftUI 容器,不仅仅是 Form!
    • 如果您需要完成presentationControllerDidDismiss,它与上述解决方案完美配合。谢谢!
    • 如果canDismissSheet 的变量被另一个视图引用并且它可以动画,那么动画不起作用,而interactiveDismissDisabled(_:) 起作用。可能是因为uiViewController.rootView = view
    【解决方案5】:

    从 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 上运行良好。

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

    【讨论】:

    • 这也使用不同的 UI 呈现——顾名思义,这模仿了 fullScreen 模态呈现样式,而不是 iOS 13 中的“卡片样式”模态。
    • 是的,它会产生不同的外观,一种视觉上向用户指示无法使用拖动手势关闭模式的外观。我认为在大多数情况下这可能是可取的。此外,此解决方案使用文档化的 SwiftUI 构造来完成任务。
    • 当然,只是想确保有人提到这不是问题中发布的确切问题的解决方案,因此使用此答案的任何人在获得不同外观时都不会感到惊讶! :)
    【解决方案6】:

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

    使用 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.")
           }
        }
      }
    }
    

    结果!

    【讨论】:

      【解决方案7】:

      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 不,我在文档中找不到任何内容。不过,这是向前迈出的一步。
      【解决方案8】:

      我们在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)
              }
          }
      }
      

      【讨论】:

        【解决方案9】:

        此解决方案适用于 iPhone 和 iPad。它使用isModalInPresentation。来自the docs

        此属性的默认值为 false。当您将其设置为 true 时,UIKit 会忽略视图控制器范围之外的事件,并阻止视图控制器在屏幕上的交互解除。

        您的尝试与对我有用的方法接近。诀窍是在 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
            }
        }
        

        【讨论】:

        • 感谢您在 iPad 上进行测试。这看起来是一个很好的解决方案,但我不喜欢它似乎依赖于有关视图之上的 UIKit 层次结构的私有细节。仍在寻找完美的答案:(
        • 是的,它与公认的答案相同的假设是托管控制器的父级管理视图的呈现。我同意这并不完美。
        • 哈!你知道,我实际上错过了答案到达uiViewController.parent 的第一次复飞。你是对的,这在这方面并没有更糟。我会在 iPad 上试一试,如果效果更好,我会接受你的回答 :)
        • 接受的答案在 iPad 和 iPhone 上都适用于我。你看到了什么问题?
        • 嗯。我能够在 iPad 上关闭工作表。不过,这可能是侥幸。我将从我的答案中删除该评论。感谢您检查。
        猜你喜欢
        • 2012-05-14
        • 1970-01-01
        • 2013-01-01
        • 2017-09-08
        • 1970-01-01
        相关资源
        最近更新 更多