Creating a Sheet in SwiftUI with Controllable Cancel Gestures

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In the previous two articles, we explored how to create a form that can determine if modifications were made and how to uniformly manage the pop-up Sheets across different View levels in the app. Today, we combine these concepts to achieve the final goal of the project—creating a form within a Sheet that responds in real time, and the Sheet reacts to the form’s status for cancel gestures.

Pop Up Different Sheets in SwiftUI as Needed

Creating a Real-Time Responsive Form in SwiftUI

Origin

In the previous Form example, although we could react differently to cancel and edit actions based on whether the form was modified, we couldn’t control the user’s direct use of gestures to cancel the Sheet. To prevent users from bypassing programmatic checks, we reluctantly used fullScreenCover to avoid gesture cancellation. However, in practice, although the full-screen Sheet offers more screen space, it still creates an inconsistent user experience.

Last year, my solution was to block the Sheet’s drag gesture.

Swift
 .highPriorityGesture(DragGesture())

This was a workaround.

Later, Javier from SwiftUI-lab proposed his solution Dismiss Gesture for SwiftUI Modals, which essentially achieved all the functions I wanted. However, this solution seemed a bit odd.

  1. Data and Sheet control were mixed together.
  2. Control over the Sheet was too cumbersome and not intuitive.

Recently, mobilinked wrote a piece of code to control the Sheet, which was ingeniously structured and easy to use.

This article adopts mobilinked’s base code for Sheet control and makes corresponding modifications for Form response.

Before proceeding with the code description, please read the previous two articles if you haven’t already.

Goals

  1. The form checks the input content in real-time (for errors, blank fields).
  2. The form decides whether to allow gesture cancellation of the Sheet based on its current status.
  3. When the user attempts to cancel via gesture, if the form has been modified, the user needs to confirm the cancellation again.

Code Overview

Since most parts of the code in this article are similar to the Form example code, only the new and modified parts are briefly described.

SheetManager

Swift
public class AIOSheetManager:ObservableObject{
    @Published  var action:AllInOneSheetAction?
    var unlock:Bool = false // false prevents swipe-to-dismiss, maintained by the form program
    var type:AllInOneSheetType = .sheet // sheet or fullScreenCover
    var dismissControl:Bool = true // activates dismiss prevention switch, true to activate
    
    @Published var showSheet = false
    @Published var showFullCoverScreen = false

    var dismissed = PassthroughSubject<Bool,Never>()
    var dismissAction:(() -> Void)? = nil

    enum AllInOneSheetType{
        case fullScreenCover
        case sheet
    }
}

Sheet control code

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

        // When preventing cancellation, send a command for user-requested Sheet dismissal
        func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController){
            manager.dismissed.send(true)
        }
    }
}

extension View {
    public func allowAutoDismiss(_ manager:AIOSheetManager) -> some View {
        self
            .background(MbModalHackView(manager: manager))

    }
}

Wrapping

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

Usage

Swift
NavigationView{
    ...
}
.xsheet()

Button("New"){
         sheetManager.type = .sheet  // currently supports two types: sheet and fullScreenCover
         sheetManager.dismissControl = true // enable control
         sheetManager.action = .new   // set the unified sheet action
              }

Form Code Modifications

To enable our Form code to manage the Sheet and respond to user cancel gestures, the following modifications were made:

Swift
    @State private var changed = false{
        didSet{
            // Control whether the Sheet can be dismissed
            if action == .show {
                sheetManager.unlock = true
            }
            else {
                sheetManager.unlock = !changed
            }
        }
    }
Swift
New addition
 .onReceive(sheetManager.dismissed){ value in
                delConfirm.toggle()
            }

For detailed code, please visit my github.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed