SwiftUI: Disable Back Swipe Gesture Dynamically
Welcome to part 2 of my series on addressing the issue of the Back Swipe Gesture Missing When Using SwiftUI. If you haven’t had the chance to read the previous installment, I encourage you to do so.
In the preceding article, I presented a solution to a problem in which the swipe back gesture, designed for navigating back within a SwiftUI-based application, was not working as intended. The code introduced a CustomNavigationStack
view that incorporates a custom gesture recognizer to facilitate swipe-back navigation.
In this article, I will provide a solution for updating the status of the custom gesture recognizer, as discussed in part 1.
Usage
- Introduced a toggle to control the state property:
@State private var isSwipeEnabled
- Implemented a
.disabledInteractivePopGesture(Binding<Bool>)
method to bind the value ofisSwipeEnabled
💡 When returning to the previous page, the status will always revert to its default value: true
Solution
Let’s break down the problem into smaller subtasks and address them sequentially.
- Handling Gesture Modification with View Modifier: We’ve created an
InterPopGestureModifier
to manage the behavior of gesture modifications. - Attaching a Listener to CustomNavigationStack: This listener is responsible for handling the events emit by
InterPopGestureModifier
and update its state propertyisSwipeEnabled
. - (Safety check) Verifying Gesture name: Safety checks to ensure the existence of the gesture name before attempting to post notifications. These checks prevent unexpected issues in case the gesture name is not available.
Handling Gesture Modifications
Now, let’s examine the InterPopGestureModifier
:
InterPopGestureModifier
is a view modifier that controls the interactive pop gesture's behavior based on thedisabled
binding.- The
onCha
nge modifier (available on iOS 17.0 and later) listens to changes in thedisabled
state and, when it changes and when this view initially appears, posts a notification to enable or disable the gesture accordingly. This is interchangeable with a combination ofonAppear
andonCha
nge if supported iOS 16.0 version and below. - The
onDisappear
modifier ensures that the gesture is re-enabled when the view disappears. - Safety check: It uses an environment variable
popGestureID
to identify the specific gesture to control.
popGestureID
This identifier is essential for distinguishing the specific gesture. Currently, the method I’ve employed is passing the name to the environment. Please feel free to share any alternative solutions if you have them 😊.
- Create Environment Key
2. Sets the environment value of the specified key path to the given value in CustomNavigationStack
.
3. Use it on InterPopGestureModifier
Attached Listener to CustomNavigationStack
- The
onReceive
modifier listens to a notification with the name corresponding to theinteractivePopGestureRecognizer.name
. When it receives this notification, it checks the status from the user info and updates theinteractivePopGestureRecognizer.isEnabled
accordingly.
Finally, we wrap our View Modifier into View extension
👩🏽💻Code
struct CustomNavigationStack<Content: View>: View {
@ViewBuilder var content: Content
@State private var interactivePopGestureRecognizer: UIScreenEdgePanGestureRecognizer = {
let gesture = UIScreenEdgePanGestureRecognizer()
gesture.name = UUID().uuidString
gesture.edges = UIRectEdge.left
gesture.isEnabled = true
return gesture
}()
var body: some View {
NavigationStack {
content
.background {
AttachPopGestureView(gesture: $interactivePopGestureRecognizer)
}
}
.environment(\.popGestureID, interactivePopGestureRecognizer.name)
.onReceive(NotificationCenter.default.publisher(for: .init(interactivePopGestureRecognizer.name ?? ""))) { info in
if let userInfo = info.userInfo, let status = userInfo["status"] as? Bool {
interactivePopGestureRecognizer.isEnabled = status
}
}
}
}
extension View {
func disabledInteractivePopGesture(
_ disabled: Binding<Bool> = .constant(true)
) -> some View {
self.modifier(InterPopGestureModifier(disabled: disabled))
}
}
//MARK: - View Modifier
fileprivate struct InterPopGestureModifier: ViewModifier {
@Binding var disabled: Bool
@Environment(\.popGestureID) private var gestureID
func body(content: Content) -> some View {
content
.onChange(of: disabled, initial: false, { oldValue, newValue in
guard let gestureID else { return }
NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
"status": !disabled
])
})
.onDisappear {
guard let gestureID else { return }
NotificationCenter.default.post(name: .init(gestureID), object: nil, userInfo: [
"status": true
])
}
}
}
struct AttachPopGestureView: UIViewRepresentable {
@Binding var gesture: UIScreenEdgePanGestureRecognizer
func makeUIView(context: Context) -> some UIView {
return UIView()
}
func updateUIView(_ uiView: UIViewType, context: Context) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) {
if let parentVC = uiView.parentViewController {
if let navigationController = parentVC.navigationController {
// To prevent duplication
guard !(navigationController.view.gestureRecognizers?
.contains(where: {$0.name == gesture.name}) ?? true) else { return }
navigationController.addInteractivePopGesture(gesture)
}
}
}
}
}
👩🏽💻Helper & EnvironmentKey
//MARK: - Helper
fileprivate extension UINavigationController {
func addInteractivePopGesture(_ gesture: UIPanGestureRecognizer) {
guard let gestureSelector = interactivePopGestureRecognizer?.value(forKey: "targets") else { return }
gesture.setValue(gestureSelector, forKey: "targets")
view.addGestureRecognizer(gesture)
}
}
fileprivate extension UIView {
var parentViewController: UIViewController? {
sequence(first: self) { $0.next }.first(where: { $0 is UIViewController }) as? UIViewController
}
}
//MARK: - EnvironmentKey
fileprivate struct PopNotificationID: EnvironmentKey {
static var defaultValue: String?
}
extension EnvironmentValues {
var popGestureID: String? {
get {
self[PopNotificationID.self]
}
set {
self[PopNotificationID.self] = newValue
}
}
}
Usage
@main
struct TestApp: App {
var body: some Scene {
WindowGroup {
CustomNavigationStack {
NavigationLink("Go Page 2") {
PageTwo()
}
}
}
}
}
struct PageTwo: View {
@Environment (\.dismiss) var dismiss
@State private var isSwipeEnabled: Bool = true
var body: some View {
Toggle("Page 2", isOn: $isSwipeEnabled)
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.yellow)
// CUSTOM BACK BUTTON ADDED
.navigationBarBackButtonHidden()
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
self.dismiss()
} label: {
Image(systemName: "chevron.left")
}
}
}
// View extension - that disabled the gesture at this page only
.disabledInteractivePopGesture($isSwipeEnabled)
}
}
Thanks all, happy reading! 😄