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.
.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.
- Data and Sheet control were mixed together.
- 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
- The form checks the input content in real-time (for errors, blank fields).
- The form decides whether to allow gesture cancellation of the Sheet based on its current status.
- 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
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
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
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
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:
@State private var changed = false{
didSet{
// Control whether the Sheet can be dismissed
if action == .show {
sheetManager.unlock = true
}
else {
sheetManager.unlock = !changed
}
}
}
New addition
.onReceive(sheetManager.dismissed){ value in
delConfirm.toggle()
}
For detailed code, please visit my github.