From cfb350e5a1bf61bb85ede980ddf3e157fd2bde86 Mon Sep 17 00:00:00 2001 From: Muukii Date: Tue, 18 Aug 2026 00:30:11 +0200 Subject: [PATCH] Add directional snap gesture mode --- README.md | 25 ++ .../DirectionalDragGesture.swift | 303 ++++++++++++++++++ .../SnapDraggingModifier.swift | 168 +++++++--- .../swiftui_snap_dragging_modifierTests.swift | 193 ++++++++++- 4 files changed, 639 insertions(+), 50 deletions(-) create mode 100644 Sources/SwiftUISnapDraggingModifier/DirectionalDragGesture.swift diff --git a/README.md b/README.md index ff92130..98e6648 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,31 @@ About Fluid interfaces : https://developer.apple.com/videos/play/wwdc2018/803/ ## Examples +### Directional gesture ownership + +Use `.directional` when a snap gesture should begin only after movement is +dominant on the modifier's existing `axis`. For example, a horizontal action +inside a vertical scrolling surface can reject vertical and equal-axis pans +before the row gesture begins: + +```swift +RoundedRectangle(cornerRadius: 16, style: .continuous) + .modifier( + SnapDraggingModifier( + gestureMode: .directional, + offset: $offset, + axis: [.horizontal], + horizontalBoundary: .init(min: -50, max: 0, bandLength: 50) + ) + ) +``` + +`.directional` is available on iOS 18 and later. It is separate from +`.scrollViewInteroperable`, which coordinates edge handoff with a scroll view. +UIKit owns the pan-recognition threshold; `activation.minimumDistance` delays +offset and callback delivery after recognition rather than replacing that +system threshold. + **Throwing a ball** diff --git a/Sources/SwiftUISnapDraggingModifier/DirectionalDragGesture.swift b/Sources/SwiftUISnapDraggingModifier/DirectionalDragGesture.swift new file mode 100644 index 0000000..97daeca --- /dev/null +++ b/Sources/SwiftUISnapDraggingModifier/DirectionalDragGesture.swift @@ -0,0 +1,303 @@ +import SwiftUI +import UIKit + +/// A UIKit-backed pan gesture that begins only when movement is compatible +/// with its configured axes. +/// +/// Axis admission happens before the recognizer enters `.began`, allowing an +/// enclosing scroll view to keep a cross-axis pan without requiring explicit +/// knowledge of that scroll view. +@available(iOS 18.0, *) +struct DirectionalDragGesture: UIGestureRecognizerRepresentable { + + struct Value { + let translation: CGSize + let velocity: CGVector + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + + var axis: Axis.Set + var activation: SnapDraggingModifier.Activation + var contentSize: CGSize + var layoutDirection: LayoutDirection + + private let converter: CoordinateSpaceConverter + private var session = DirectionalDragGestureSession() + + init( + axis: Axis.Set, + activation: SnapDraggingModifier.Activation, + contentSize: CGSize, + layoutDirection: LayoutDirection, + converter: CoordinateSpaceConverter + ) { + self.axis = axis + self.activation = activation + self.contentSize = contentSize + self.layoutDirection = layoutDirection + self.converter = converter + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else { + return false + } + + let translation = + converter.localTranslation + ?? { + panGestureRecognizer.translation(in: panGestureRecognizer.view) + }() + let velocity = + converter.localVelocity + ?? { + panGestureRecognizer.velocity(in: panGestureRecognizer.view) + }() + + guard + DirectionalDragGestureAdmission.shouldBegin( + axis: axis, + translation: translation, + velocity: velocity + ) + else { + return false + } + + let location = converter.localLocation + let startLocation = CGPoint( + x: location.x - translation.x, + y: location.y - translation.y + ) + + return DirectionalDragGestureAdmission.shouldBegin( + at: startLocation, + contentSize: contentSize, + region: activation.regionToActivate, + layoutDirection: layoutDirection + ) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + otherGestureRecognizer is UIScreenEdgePanGestureRecognizer + } + + func shouldDeliverChange(translation: CGPoint) -> Bool { + guard + DirectionalDragGestureAdmission.hasReachedMinimumDistance( + translation: translation, + minimumDistance: activation.minimumDistance + ) + else { + return false + } + + session.recordDeliveredChange() + return true + } + + func consumeTerminalAction( + for state: UIGestureRecognizer.State + ) -> DirectionalDragGestureSession.TerminalAction? { + session.consumeTerminalAction(for: state) + } + } + + let axis: Axis.Set + let activation: SnapDraggingModifier.Activation + let contentSize: CGSize + let layoutDirection: LayoutDirection + let onChange: (Value) -> Void + let onEnd: (Value) -> Void + let onCancel: () -> Void + + func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { + Coordinator( + axis: axis, + activation: activation, + contentSize: contentSize, + layoutDirection: layoutDirection, + converter: converter + ) + } + + func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer { + let gestureRecognizer = UIPanGestureRecognizer() + gestureRecognizer.maximumNumberOfTouches = 1 + // Once a directional drag begins, descendant controls must not also commit + // their tap action. Vertical-dominant pans fail before this takes effect. + gestureRecognizer.cancelsTouchesInView = true + gestureRecognizer.delaysTouchesBegan = false + gestureRecognizer.delaysTouchesEnded = false + gestureRecognizer.delegate = context.coordinator + return gestureRecognizer + } + + func updateUIGestureRecognizer(_ gestureRecognizer: UIPanGestureRecognizer, context: Context) { + context.coordinator.axis = axis + context.coordinator.activation = activation + context.coordinator.contentSize = contentSize + context.coordinator.layoutDirection = layoutDirection + } + + func handleUIGestureRecognizerAction( + _ gestureRecognizer: UIPanGestureRecognizer, + context: Context + ) { + let translation = + context.converter.localTranslation + ?? { + gestureRecognizer.translation(in: gestureRecognizer.view) + }() + let velocity = + context.converter.localVelocity + ?? { + gestureRecognizer.velocity(in: gestureRecognizer.view) + }() + let value = Value( + translation: CGSize(width: translation.x, height: translation.y), + velocity: CGVector(dx: velocity.x, dy: velocity.y) + ) + + let state = gestureRecognizer.state + + switch state { + case .began, .changed: + if context.coordinator.shouldDeliverChange(translation: translation) { + onChange(value) + } + case .ended, .cancelled, .failed: + switch context.coordinator.consumeTerminalAction(for: state) { + case .end: + onEnd(value) + case .cancel: + onCancel() + case nil: + break + } + case .possible: + break + @unknown default: + if context.coordinator.consumeTerminalAction(for: state) == .cancel { + onCancel() + } + } + } +} + +/// Tracks whether a directional drag has produced an observable change and +/// consumes at most one terminal action for that drag. +struct DirectionalDragGestureSession { + + enum TerminalAction: Equatable { + case end + case cancel + } + + private var hasDeliveredChange = false + + mutating func recordDeliveredChange() { + hasDeliveredChange = true + } + + mutating func consumeTerminalAction( + for state: UIGestureRecognizer.State + ) -> TerminalAction? { + let action: TerminalAction? + + switch state { + case .ended: + action = .end + case .cancelled, .failed: + action = .cancel + case .possible, .began, .changed: + action = nil + @unknown default: + action = .cancel + } + + guard hasDeliveredChange, let action else { + return nil + } + + // Consume before invoking client code so re-entrant teardown cannot emit a + // second terminal callback for the same gesture. + hasDeliveredChange = false + return action + } +} + +/// Pure dominant-axis admission used by the UIKit recognizer and unit tests. +enum DirectionalDragGestureAdmission { + + private static let edgeActivationWidth: CGFloat = 20 + + static func shouldBegin( + axis: Axis.Set, + translation: CGPoint, + velocity: CGPoint + ) -> Bool { + // Translation expresses the complete movement that led UIKit to ask + // whether this pan should begin. Instantaneous velocity can contain small + // sampling asymmetry even when the authored path is an equal diagonal. + let movement = translation == .zero ? velocity : translation + let horizontalMagnitude = abs(movement.x) + let verticalMagnitude = abs(movement.y) + + switch (axis.contains(.horizontal), axis.contains(.vertical)) { + case (true, true): + return horizontalMagnitude > 0 || verticalMagnitude > 0 + case (true, false): + return horizontalMagnitude > verticalMagnitude + case (false, true): + return verticalMagnitude > horizontalMagnitude + case (false, false): + return false + } + } + + static func hasReachedMinimumDistance( + translation: CGPoint, + minimumDistance: Double + ) -> Bool { + hypot(translation.x, translation.y) >= max(0, minimumDistance) + } + + static func shouldBegin( + at startLocation: CGPoint, + contentSize: CGSize, + region: SnapDraggingModifier.Activation.Region, + layoutDirection: LayoutDirection + ) -> Bool { + switch region { + case .screen: + return true + case .edge(let edges): + if edges.contains(.top), startLocation.y <= edgeActivationWidth { + return true + } + + if edges.contains(.bottom), startLocation.y >= contentSize.height - edgeActivationWidth { + return true + } + + let isNearLeftEdge = startLocation.x <= edgeActivationWidth + let isNearRightEdge = startLocation.x >= contentSize.width - edgeActivationWidth + + switch layoutDirection { + case .leftToRight: + return (edges.contains(.leading) && isNearLeftEdge) + || (edges.contains(.trailing) && isNearRightEdge) + case .rightToLeft: + return (edges.contains(.leading) && isNearRightEdge) + || (edges.contains(.trailing) && isNearLeftEdge) + @unknown default: + return false + } + } + } +} diff --git a/Sources/SwiftUISnapDraggingModifier/SnapDraggingModifier.swift b/Sources/SwiftUISnapDraggingModifier/SnapDraggingModifier.swift index 9af8be4..0b855dc 100644 --- a/Sources/SwiftUISnapDraggingModifier/SnapDraggingModifier.swift +++ b/Sources/SwiftUISnapDraggingModifier/SnapDraggingModifier.swift @@ -16,6 +16,16 @@ public struct GestureModeHighPriority: GestureMode {} /// inside the modified view. public struct GestureModeSimultaneous: GestureMode {} +/// A UIKit-backed gesture mode that begins only when the dominant movement +/// direction is compatible with ``SnapDraggingModifier/axis``. +/// +/// UIKit decides when its pan recognizer begins. The modifier's +/// ``SnapDraggingModifier/Activation/minimumDistance`` delays offset and +/// callback delivery after that recognition boundary; it does not replace +/// UIKit's intrinsic pan threshold. +@available(iOS 18, *) +public struct GestureModeDirectional: GestureMode {} + @available(iOS 18, *) public struct GestureModeScrollViewInteroperable: GestureMode { @@ -47,6 +57,16 @@ extension GestureMode where Self == GestureModeSimultaneous { } } +@available(iOS 18, *) +extension GestureMode where Self == GestureModeDirectional { + + /// Uses the modifier's existing `axis` for both gesture admission and offset + /// updates, keeping axis configuration in one place. + public static var directional: Self { + .init() + } +} + @available(iOS 18, *) extension GestureMode where Self == GestureModeScrollViewInteroperable { @@ -93,7 +113,8 @@ public struct SnapDraggingModifier: ViewModifier { public init( onStartDragging: @escaping () -> Void = {}, - onEndDragging: @escaping (_ velocity: inout CGVector, _ offset: CGSize, _ contentSize: CGSize) + onEndDragging: + @escaping (_ velocity: inout CGVector, _ offset: CGSize, _ contentSize: CGSize) -> CGSize = { _, _, _ in .zero } ) { self.onStartDragging = onStartDragging @@ -104,7 +125,8 @@ public struct SnapDraggingModifier: ViewModifier { @available(iOS 17.0, *) public init( onStartDragging: @escaping () -> Void = {}, - onEndDragging: @escaping (_ velocity: inout CGVector, _ offset: CGSize, _ contentSize: CGSize) + onEndDragging: + @escaping (_ velocity: inout CGVector, _ offset: CGSize, _ contentSize: CGSize) -> CGSize = { _, _, _ in .zero }, onCompleteAnimation: @escaping () -> Void ) { @@ -166,6 +188,7 @@ public struct SnapDraggingModifier: ViewModifier { @GestureState private var pointInView: CGPoint = .zero @State private var isActive = false + @State private var directionalInitialOffset: CGSize? @State private var scrollViewInteroperableInitialOffset: CGSize? @State private var contentSize: CGSize = .zero @@ -232,6 +255,9 @@ public struct SnapDraggingModifier: ViewModifier { case _ as GestureModeSimultaneous: base .simultaneousGesture(dragGesture.simultaneously(with: gesture), including: .all) + case _ as GestureModeDirectional: + base + .gesture(directionalGesture) case let scrollViewInteroperable as GestureModeScrollViewInteroperable: base .gesture(_gesture(configuration: scrollViewInteroperable.configuration)) @@ -283,6 +309,44 @@ public struct SnapDraggingModifier: ViewModifier { } + @available(iOS 18.0, *) + @available(macOS, unavailable) + @available(tvOS, unavailable) + @available(watchOS, unavailable) + @available(visionOS, unavailable) + private var directionalGesture: DirectionalDragGesture { + DirectionalDragGesture( + axis: axis, + activation: activation, + contentSize: contentSize, + layoutDirection: layoutDirection, + onChange: { value in + if directionalInitialOffset == nil { + directionalInitialOffset = presentingOffset + handler.onStartDragging() + } + + let baseOffset = directionalInitialOffset ?? presentingOffset + updateOffset( + baseOffset: baseOffset, + translation: value.translation + ) + }, + onEnd: { value in + if directionalInitialOffset != nil { + onEnded(velocity: value.velocity) + } + directionalInitialOffset = nil + }, + onCancel: { + if directionalInitialOffset != nil { + resetToTargetOffset() + } + directionalInitialOffset = nil + } + ) + } + private func isInActivation(startLocation: CGPoint) -> Bool { switch activation.regionToActivate { @@ -390,30 +454,10 @@ public struct SnapDraggingModifier: ViewModifier { let baseOffset = scrollViewInteroperableInitialOffset ?? presentingOffset - let proposedOffset = CGSize( - width: baseOffset.width + value.translation.width, - height: baseOffset.height + value.translation.height + updateOffset( + baseOffset: baseOffset, + translation: value.translation ) - - // TODO: stop the current animation when dragging restarted. - withAnimation(.interactiveSpring()) { - if axis.contains(.horizontal) { - currentOffset.width = rubberBand( - value: proposedOffset.width, - min: horizontalBoundary.min, - max: horizontalBoundary.max, - bandLength: horizontalBoundary.bandLength - ) - } - if axis.contains(.vertical) { - currentOffset.height = rubberBand( - value: proposedOffset.height, - min: verticalBoundary.min, - max: verticalBoundary.max, - bandLength: verticalBoundary.bandLength - ) - } - } // } }, onEnd: { value in @@ -460,30 +504,10 @@ public struct SnapDraggingModifier: ViewModifier { // Because of GestureState, this value is set always. let baseOffset = initialOffset! - let proposedOffset = CGSize( - width: baseOffset.width + value.translation.width, - height: baseOffset.height + value.translation.height + updateOffset( + baseOffset: baseOffset, + translation: value.translation ) - - // TODO: stop the current animation when dragging restarted. - withAnimation(.interactiveSpring()) { - if axis.contains(.horizontal) { - currentOffset.width = rubberBand( - value: proposedOffset.width, - min: horizontalBoundary.min, - max: horizontalBoundary.max, - bandLength: horizontalBoundary.bandLength - ) - } - if axis.contains(.vertical) { - currentOffset.height = rubberBand( - value: proposedOffset.height, - min: verticalBoundary.min, - max: verticalBoundary.max, - bandLength: verticalBoundary.bandLength - ) - } - } } }) .onEnded({ value in @@ -504,6 +528,54 @@ public struct SnapDraggingModifier: ViewModifier { } + private func updateOffset(baseOffset: CGSize, translation: CGSize) { + let proposedOffset = CGSize( + width: baseOffset.width + translation.width, + height: baseOffset.height + translation.height + ) + + // Stop visually following an older target animation as soon as a new + // interactive drag supplies its own presentation value. + withAnimation(.interactiveSpring()) { + if axis.contains(.horizontal) { + currentOffset.width = rubberBand( + value: proposedOffset.width, + min: horizontalBoundary.min, + max: horizontalBoundary.max, + bandLength: horizontalBoundary.bandLength + ) + } + if axis.contains(.vertical) { + currentOffset.height = rubberBand( + value: proposedOffset.height, + min: verticalBoundary.min, + max: verticalBoundary.max, + bandLength: verticalBoundary.bandLength + ) + } + } + } + + private func resetToTargetOffset() { + let targetOffset = self.targetOffset + + let animation: Animation = { + switch springParameter { + case .interpolation(let mass, let stiffness, let damping): + return .interpolatingSpring( + mass: mass, + stiffness: stiffness, + damping: damping, + initialVelocity: 0 + ) + } + }() + + withAnimation(animation) { + currentOffset = targetOffset + } + } + private func onEnded(velocity: CGVector) { var usingVelocity = velocity diff --git a/Tests/SnapDraggingModifierTests/swiftui_snap_dragging_modifierTests.swift b/Tests/SnapDraggingModifierTests/swiftui_snap_dragging_modifierTests.swift index 2566375..5b7e6ac 100644 --- a/Tests/SnapDraggingModifierTests/swiftui_snap_dragging_modifierTests.swift +++ b/Tests/SnapDraggingModifierTests/swiftui_snap_dragging_modifierTests.swift @@ -1,8 +1,197 @@ +import SwiftUI import XCTest @testable import SwiftUISnapDraggingModifier -final class swiftui_snap_dragging_modifierTests: XCTestCase { - func testExample() throws { +final class SwiftUISnapDraggingModifierTests: XCTestCase { + + func testHorizontalAxisAcceptsHorizontalDominantVelocity() { + XCTAssertTrue( + DirectionalDragGestureAdmission.shouldBegin( + axis: .horizontal, + translation: .zero, + velocity: .init(x: -100, y: 40) + ) + ) + } + + func testHorizontalAxisRejectsVerticalDominantVelocity() { + XCTAssertFalse( + DirectionalDragGestureAdmission.shouldBegin( + axis: .horizontal, + translation: .zero, + velocity: .init(x: -40, y: 100) + ) + ) + } + + func testVerticalAxisAcceptsVerticalDominantVelocity() { + XCTAssertTrue( + DirectionalDragGestureAdmission.shouldBegin( + axis: .vertical, + translation: .zero, + velocity: .init(x: 40, y: -100) + ) + ) + } + + func testVerticalAxisRejectsHorizontalDominantVelocity() { + XCTAssertFalse( + DirectionalDragGestureAdmission.shouldBegin( + axis: .vertical, + translation: .zero, + velocity: .init(x: 100, y: -40) + ) + ) + } + + func testSingleAxisRejectsEqualDiagonalVelocity() { + XCTAssertFalse( + DirectionalDragGestureAdmission.shouldBegin( + axis: .horizontal, + translation: .zero, + velocity: .init(x: -100, y: 100) + ) + ) + XCTAssertFalse( + DirectionalDragGestureAdmission.shouldBegin( + axis: .vertical, + translation: .zero, + velocity: .init(x: -100, y: 100) + ) + ) + } + + func testBothAxesAcceptMovementInEitherDirection() { + let bothAxes: Axis.Set = [.horizontal, .vertical] + + XCTAssertTrue( + DirectionalDragGestureAdmission.shouldBegin( + axis: bothAxes, + translation: .zero, + velocity: .init(x: 100, y: 0) + ) + ) + XCTAssertTrue( + DirectionalDragGestureAdmission.shouldBegin( + axis: bothAxes, + translation: .zero, + velocity: .init(x: 0, y: -100) + ) + ) + } + + func testNoAxisAndNoMovementDoNotBegin() { + XCTAssertFalse( + DirectionalDragGestureAdmission.shouldBegin( + axis: [], + translation: .init(x: 100, y: 0), + velocity: .init(x: 100, y: 0) + ) + ) + XCTAssertFalse( + DirectionalDragGestureAdmission.shouldBegin( + axis: [.horizontal, .vertical], + translation: .zero, + velocity: .zero + ) + ) + } + + func testTranslationDeterminesDirectionWhenAvailable() { + XCTAssertTrue( + DirectionalDragGestureAdmission.shouldBegin( + axis: .horizontal, + translation: .init(x: -20, y: 5), + velocity: .init(x: -5, y: 100) + ) + ) + + XCTAssertFalse( + DirectionalDragGestureAdmission.shouldBegin( + axis: .horizontal, + translation: .init(x: -20, y: 20), + velocity: .init(x: -100, y: 5) + ) + ) + } + + func testVelocityProvidesFallbackBeforeTranslationIsAvailable() { + XCTAssertTrue( + DirectionalDragGestureAdmission.shouldBegin( + axis: .horizontal, + translation: .zero, + velocity: .init(x: -100, y: 20) + ) + ) + } + + func testMinimumDistanceUsesTotalTranslation() { + XCTAssertFalse( + DirectionalDragGestureAdmission.hasReachedMinimumDistance( + translation: .init(x: 3, y: 4), + minimumDistance: 6 + ) + ) + XCTAssertTrue( + DirectionalDragGestureAdmission.hasReachedMinimumDistance( + translation: .init(x: 3, y: 4), + minimumDistance: 5 + ) + ) + } + + func testActivationRegionUsesSemanticHorizontalEdges() { + let size = CGSize(width: 100, height: 200) + + XCTAssertTrue( + DirectionalDragGestureAdmission.shouldBegin( + at: .init(x: 10, y: 100), + contentSize: size, + region: .edge(.leading), + layoutDirection: .leftToRight + ) + ) + XCTAssertFalse( + DirectionalDragGestureAdmission.shouldBegin( + at: .init(x: 10, y: 100), + contentSize: size, + region: .edge(.leading), + layoutDirection: .rightToLeft + ) + ) + XCTAssertTrue( + DirectionalDragGestureAdmission.shouldBegin( + at: .init(x: 90, y: 100), + contentSize: size, + region: .edge(.leading), + layoutDirection: .rightToLeft + ) + ) + } + + func testEndedConsumesExactlyOneEndActionAfterAChange() { + var session = DirectionalDragGestureSession() + + XCTAssertNil(session.consumeTerminalAction(for: .ended)) + + session.recordDeliveredChange() + + XCTAssertEqual(session.consumeTerminalAction(for: .ended), .end) + XCTAssertNil(session.consumeTerminalAction(for: .ended)) + } + + func testCancellationAndFailureConsumeExactlyOneCancelAction() { + var cancelledSession = DirectionalDragGestureSession() + cancelledSession.recordDeliveredChange() + + XCTAssertEqual(cancelledSession.consumeTerminalAction(for: .cancelled), .cancel) + XCTAssertNil(cancelledSession.consumeTerminalAction(for: .failed)) + + var failedSession = DirectionalDragGestureSession() + failedSession.recordDeliveredChange() + + XCTAssertEqual(failedSession.consumeTerminalAction(for: .failed), .cancel) + XCTAssertNil(failedSession.consumeTerminalAction(for: .cancelled)) } }