在前面的两篇文章中,我们探讨了如何制作一个可以判断是否进行了修改的表单,以及如何统一管理 app 各个层级 View 的弹出 Sheet。今天我们将他们合并在一起,完成整个项目的最终目的——在 Sheet 中制作一个可以实时响应的表单,并且 sheet 会感觉表单的情况响应取消手势。
由来
在之前 Form 的例子中,虽然我们可以根据表单是否进行了修改来对 cancel、edit 等做出不同的响应,但是我们并没有办法控制用户直接使用手势来取消 sheet,为了不让用户绕过程序的判断检查,不得已使用了 fullScreenCover 来规避手势取消。不过在实际使用中,尽管全屏 sheet 提供了更多的屏幕可用空间,但还是会给使用者带来了操作逻辑不统一的体验。
在去年,我使用的解决方案是,屏蔽 sheet 的拖动手势。
 .highPriorityGesture(DragGesture())这也是没有办法的办法。
后来,SwiftUI-lab 中,Javier 提出了他的解决方案 Dismiss Gesture for SwiftUI Modals。这个方案基本上实现了我想要的全部功能。不过这个方案看起来有些怪异。
- 数据和 sheet 控制混合在一起
- 对于 sheet 的控制过于繁琐,而且不直观
前段时间 mobilinked 编写了一段用于控制 sheet 的代码,结构精巧,使用简单。
本文对于 sheet 的控制采用了 mobilinked 的基础代码,并针对 Form 的响应做出了对应的修改。
在进行下面的代码说明前,如果你还没有阅读前两篇文章的话,请阅读后再继续。
目标
- 表单对输入的内容进行实时检查(是否有错误,是否有空白项)
- 表单将根据当前的状态决定是否允许 sheet 进行手势取消
- 当用户进行手势取消时,如果表单已经进行了修改,需要用户二次确认是否取消
代码简介
由于本文代码中多数部分同 Form 示例代码类似,所以仅简述一下新增及修改的部分。
SheetManager
public class AIOSheetManager:ObservableObject{
    @Published  var action:AllInOneSheetAction?
    var unlock:Bool = false //false 时无法下滑 dismiss, 由 form 程序维护
    var type:AllInOneSheetType = .sheet //sheet or fullScreenCover
    var dismissControl:Bool = true //是否启动 dismiss 阻止开关,true 启动阻止
    
    @Published var showSheet = false
    @Published var showFullCoverScreen = false
    var dismissed = PassthroughSubject<Bool,Never>()
    var dismissAction:(() -> Void)? = nil
    enum AllInOneSheetType{
        case fullScreenCover
        case sheet
    }
}sheet 控制代码
struct MbModalHackView: UIViewControllerRepresentable {
    let manager:AIOSheetManager
    func makeUIViewController(context: UIViewControllerRepresentableContext<MbModalHackView>) -> UIViewController {
        UIViewController()
    }
    func updateUIViewController(_ uiViewController: UIViewController, context: UIViewControllerRepresentableContext<MbModalHackView>) {
        rootViewController(of: uiViewController).presentationController?.delegate = context.coordinator
    }
    private func rootViewController(of uiViewController: UIViewController) -> UIViewController {
        if let parent = uiViewController.parent {
            return rootViewController(of: parent)
        }
        else {
            return uiViewController
        }
    }
    func makeCoordinator() -> Coordinator {
        Coordinator(manager: manager)
    }
    class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
        let manager:AIOSheetManager
        init(manager:AIOSheetManager){
            self.manager = manager
        }
        func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
            guard manager.dismissControl else {return true}
            return manager.unlock
        }
        //当阻止取消时,发送用户要求取消 sheet 命令
        func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController){
            manager.dismissed.send(true)
        }
    }
}
extension View {
    public func allowAutoDismiss(_ manager:AIOSheetManager) -> some View {
        self
            .background(MbModalHackView(manager: manager))
    }
}包装
struct XSheet:ViewModifier{
    @EnvironmentObject var manager:AIOSheetManager
    @EnvironmentObject var store:Store
    @Environment(\.managedObjectContext) var context
    var onDismiss:()->Void{
        return {
            (manager.dismissAction ?? {})()
            manager.dismissAction = nil
            manager.action = nil
            manager.showSheet = false
            manager.showFullCoverScreen = false
        }
    }
    func body(content: Content) -> some View {
        ZStack{
            content
            
            Color.clear
                .sheet(isPresented: $manager.showSheet,onDismiss: onDismiss){
                        if let action = manager.action
                        {
                            reducer(action)
                            .allowAutoDismiss(manager)
                            .environmentObject(manager)
                        }
                    
                }
            
            Color.clear
                .fullScreenCover(isPresented: $manager.showFullCoverScreen,onDismiss: onDismiss){
                        if let action = manager.action
                        {
                            reducer(action)
                                .allowAutoDismiss(manager)
                                .environmentObject(manager)
                        }
                }
        }
        .onChange(of: manager.action){ action in
            guard action != nil else {
                manager.showSheet = false
                manager.showFullCoverScreen = false
                return
            }
            if manager.type == .sheet {
                manager.showSheet = true
            }
            if manager.type == .fullScreenCover{
                manager.showFullCoverScreen = true
            }
        }
    }
}
enum AllInOneSheetAction:Identifiable,Equatable{
    case show(student:Student)
    case edit(student:Student)
    case new
    
    var id:UUID{UUID()}
}
extension XSheet{
    func reducer(_ action:AllInOneSheetAction) -> some View{
        switch action{
        case .show(let student):
            return StudentManager(action:.show, student:student)
        case .new:
            return StudentManager(action: .new, student: nil)
        case .edit(let student):
            return StudentManager(action:.edit,student: student)
        }
    }
}
extension View{
    func xsheet() -> some View{
        self
            .modifier(XSheet())
    }
}调用方式
NavigationView{
    ...
}
.xsheet()
Button("New"){
         sheetManager.type = .sheet  //当前支持两种方式 sheet fullScreenCover
         sheetManager.dismissControl = true //打开控制
         sheetManager.action = .new   //设置统一 sheet 的 action
              }Form 代码的修改
为了让我们的表单代码能够管理 sheet,并且可以响应用户的取消手势,对 Form 代码做了如下的修改:
    @State private var changed = false{
        didSet{
            //控制 sheet 是否允许 dismiss
            if action == .show {
                sheetManager.unlock = true
            }
            else {
                sheetManager.unlock = !changed
            }
        }
    }新增
 .onReceive(sheetManager.dismissed){ value in
                delConfirm.toggle()
            }详细代码请访问我的 github