diff --git a/Example/Shared/Event/Gesture/DragGestureExample.swift b/Example/Shared/Event/Gesture/DragGestureExample.swift new file mode 100644 index 000000000..ff69d3046 --- /dev/null +++ b/Example/Shared/Event/Gesture/DragGestureExample.swift @@ -0,0 +1,40 @@ +// +// DragGestureExample.swift +// Shared + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif + +struct DragGestureExample: View { + @State private var position: CGSize = .zero + @GestureState private var dragOffset: CGSize = .zero + + var drag: some Gesture { + DragGesture() + .updating($dragOffset) { value, state, _ in + state = value.translation + } + .onEnded { value in + position.width += value.translation.width + position.height += value.translation.height + } + } + + var body: some View { + Circle() + .fill(dragOffset == .zero ? Color.blue : Color.green) + .frame(width: 100, height: 100) + .offset( + x: position.width + dragOffset.width, + y: position.height + dragOffset.height + ) + .gesture(drag) + } +} + +#Preview { + DragGestureExample() +} diff --git a/Example/Tests/OpenSwiftUIInteractionTests/Event/Gesture/DragGestureInteractionTests.swift b/Example/Tests/OpenSwiftUIInteractionTests/Event/Gesture/DragGestureInteractionTests.swift new file mode 100644 index 000000000..38d8583ce --- /dev/null +++ b/Example/Tests/OpenSwiftUIInteractionTests/Event/Gesture/DragGestureInteractionTests.swift @@ -0,0 +1,71 @@ +// +// DragGestureInteractionTests.swift +// OpenSwiftUIInteractionTests + +#if OPENSWIFTUI +import OpenSwiftUI +#else +import SwiftUI +#endif +import OpenSwiftUITestsSupport +import SnapshotTesting +import Testing + +@MainActor +@Suite(.serialized, .tags(.interaction), .snapshots(record: .never, diffTool: diffTool)) +struct DragGestureInteractionTests { + private struct ContentView: View { + var minimumDistance: Double + var onChanged: (DragGesture.Value) -> Void + var onEnded: (DragGesture.Value) -> Void + + var body: some View { + Color.red + .gesture( + DragGesture(minimumDistance: minimumDistance) + .onChanged { onChanged($0) } + .onEnded { onEnded($0) } + ) + } + } + + @Test + func dragReportsTranslation() async throws { + var changedCount = 0 + var endedValue: DragGesture.Value? + let content = ContentView( + minimumDistance: 10.0, + onChanged: { _ in changedCount += 1 }, + onEnded: { endedValue = $0 } + ) + try await withInteractionTestHost(of: content) { host in + try await host.drag(by: CGSize(width: 60, height: 0)) + try await host.waitUntil(endedValue != nil) + let value = try #require(endedValue) + #expect(changedCount > 0) + #expect(abs(value.translation.width - 60) < 1) + #expect(abs(value.translation.height) < 1) + #expect(abs(value.location.x - value.startLocation.x - 60) < 1) + } + } + + @Test + func dragStartsAfterMinimumDistance() async throws { + var changedValues: [DragGesture.Value] = [] + var endedValues: [DragGesture.Value] = [] + let content = ContentView( + minimumDistance: 10.0, + onChanged: { changedValues.append($0) }, + onEnded: { endedValues.append($0) } + ) + try await withInteractionTestHost(of: content) { host in + // A drag shorter than the default minimum distance must not start the gesture. + try await host.drag(by: CGSize(width: 4, height: 0), duration: 0.1) + try await host.drag(by: CGSize(width: 60, height: 0)) + try await host.waitUntil(!endedValues.isEmpty) + #expect(endedValues.count == 1) + let firstChange = try #require(changedValues.first) + #expect(firstChange.translation.width >= 10) + } + } +} diff --git a/Example/Tests/OpenSwiftUIInteractionTests/InteractionTests/InteractionTestHost.swift b/Example/Tests/OpenSwiftUIInteractionTests/InteractionTests/InteractionTestHost.swift index eee2ff3cf..36aedc3fb 100644 --- a/Example/Tests/OpenSwiftUIInteractionTests/InteractionTests/InteractionTestHost.swift +++ b/Example/Tests/OpenSwiftUIInteractionTests/InteractionTests/InteractionTestHost.swift @@ -107,6 +107,22 @@ struct InteractionTestHost { #endif } + /// Drags from the center of the hosted view by `translation`, in view coordinates. + func drag(by translation: CGSize, duration: TimeInterval = 0.3) async throws { + #if os(macOS) + let start = try view.windowHitPoint(for: events) + // AppKit window coordinates use a bottom-left origin. + let end = CGPoint(x: start.x + translation.width, y: start.y - translation.height) + try await events.mouseDrag(from: start, to: end, duration: duration) + #else + try await onMainRunLoop { + let start = try view.windowHitPoint(for: events) + let end = CGPoint(x: start.x + translation.width, y: start.y + translation.height) + try events.fingerDrag(from: start, to: end, duration: duration) + } + #endif + } + func waitUntil( _ condition: @autoclosure @escaping () -> Bool, timeout: TimeInterval = 3 diff --git a/Example/Tests/README.md b/Example/Tests/README.md index e7aeddaea..781bb1b26 100644 --- a/Example/Tests/README.md +++ b/Example/Tests/README.md @@ -59,7 +59,8 @@ The default content size is 200×200 points, shared with UI tests through `defaultSize` in [Shared/SnapshotTesting.swift](Shared/SnapshotTesting.swift). Pass `size` only when a test needs another content size. The hosted view and its snapshots use that size on both macOS and iOS, independently of the screen size. -Call the helper, `tap()`, and `waitUntil(_:timeout:)` with `try await`. +Call the helper, `tap()`, `drag(by:duration:)`, and `waitUntil(_:timeout:)` with +`try await`. `assertSnapshot(named:)` captures the current hosted view and preserves its state for later interactions: diff --git a/Example/Tuist/Package.resolved b/Example/Tuist/Package.resolved index 71431c26f..d8cb7ad75 100644 --- a/Example/Tuist/Package.resolved +++ b/Example/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "521f9fc47b46d81999034f82f9587690ee726b2b51eb63151cad82e6525ca07f", + "originHash" : "a3b2c3bc6c2350a3bd7eae92cb2f423234860a82ec88f06afbc4d32974cc0812", "pins" : [ { "identity" : "equatable", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/OpenSwiftUIProject/Hammer", "state" : { - "revision" : "cdde65d489d3792052e5b386784e9f939588b1b2", - "version" : "0.19.2" + "revision" : "2c0b0075e965b02a9c12339d9a67ed160ef27aad", + "version" : "0.19.3" } }, { diff --git a/Example/Tuist/Package.swift b/Example/Tuist/Package.swift index 4c1935036..073abebc2 100644 --- a/Example/Tuist/Package.swift +++ b/Example/Tuist/Package.swift @@ -35,7 +35,7 @@ var dependencies: [PackageDescription.Package.Dependency] = [ .package(url: "https://github.com/OpenSwiftUIProject/equatable.git", branch: "main"), .package(url: "https://github.com/OpenSwiftUIProject/SymbolLocator.git", from: "0.2.0"), .package(url: "https://github.com/OpenSwiftUIProject/swift-snapshot-testing.git", exact: "1.19.3"), - .package(url: "https://github.com/OpenSwiftUIProject/Hammer.git", exact: "0.19.2"), + .package(url: "https://github.com/OpenSwiftUIProject/Hammer.git", exact: "0.19.3"), ] if openCombineCondition { diff --git a/Sources/OpenSwiftUI/Event/Gesture/DragGesture.swift b/Sources/OpenSwiftUI/Event/Gesture/DragGesture.swift new file mode 100644 index 000000000..320f1f437 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Gesture/DragGesture.swift @@ -0,0 +1,269 @@ +// +// DragGesture.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete +// ID: 686AE3220CDF86066434C733931F13C8 (SwiftUI) + +public import Foundation +@_spi(ForOpenSwiftUIOnly) +public import OpenSwiftUICore + +// MARK: - DragGesture + +/// A gesture that follows a touch or pointer as it moves across a view. +/// +/// A drag gesture starts after the touch or pointer moves at least +/// ``minimumDistance`` points from the location where it went down. After it +/// starts, the gesture produces a new ``Value`` each time the location +/// changes. The gesture ends when the touch lifts or the mouse button is +/// released. +/// +/// To recognize a drag gesture on a view, create and configure the gesture, +/// and then add it to the view with the ``View/gesture(_:including:)`` +/// modifier. Use ``Gesture/onChanged(_:)`` to react to each update and +/// ``Gesture/onEnded(_:)`` to react when the drag finishes. To keep +/// temporary state that resets when the gesture ends, use +/// ``Gesture/updating(_:body:)`` with a ``GestureState`` property. +/// +/// The following code lets people move a ``Circle``. The circle follows the +/// drag while it is in progress, and keeps its new position after the drag +/// ends: +/// +/// struct DraggableCircle: View { +/// @State private var position: CGSize = .zero +/// @GestureState private var dragOffset: CGSize = .zero +/// +/// var drag: some Gesture { +/// DragGesture() +/// .updating($dragOffset) { value, state, _ in +/// state = value.translation +/// } +/// .onEnded { value in +/// position.width += value.translation.width +/// position.height += value.translation.height +/// } +/// } +/// +/// var body: some View { +/// Circle() +/// .fill(dragOffset == .zero ? Color.blue : Color.green) +/// .frame(width: 100, height: 100) +/// .offset( +/// x: position.width + dragOffset.width, +/// y: position.height + dragOffset.height +/// ) +/// .gesture(drag) +/// } +/// } +/// +/// The gesture reports locations in its ``coordinateSpace``. By default, this +/// is the local coordinate space of the view that has the gesture. +@available(OpenSwiftUI_v1_0, *) +@available(tvOS, unavailable) // @_spi_available(tvOS, introduced: 18.0) +public struct DragGesture: PrimitiveGesture, PubliclyPrimitiveGesture, Gesture { + + /// The attributes of a drag gesture. + /// + /// The gesture creates a value for each change of the drag. All locations + /// are in the coordinate space of the gesture. + public struct Value: Equatable, Sendable { + /// The time of the event that created this value. + public var time: Date + + /// The location of the current event. + public var location: CGPoint + + /// The location of the event that started the drag. + public var startLocation: CGPoint + + /// The distance from the start location to the current location. + /// + /// This value is equal to `location - startLocation`. + public var translation: CGSize { + location - startLocation + } + + /// The current speed of the drag, in points per second. + @_alwaysEmitIntoClient + public var velocity: CGSize { + let predicted = predictedEndLocation + return CGSize( + width: 4.0 * (predicted.x - location.x), + height: 4.0 * (predicted.y - location.y) + ) + } + + /// An estimate of where the drag stops if it stops now. + /// + /// The estimate uses the current ``velocity`` of the drag. + public var predictedEndLocation: CGPoint { + location + _velocity.valuePerSecond * 0.25 + } + + /// An estimate of the total ``translation`` if the drag stops now. + /// + /// The estimate uses the current ``velocity`` of the drag. + public var predictedEndTranslation: CGSize { + translation + _velocity.valuePerSecond * 0.25 + } + + var _velocity: _Velocity + + var platform = Platform() + + struct Platform: Equatable {} + } + + /// The minimum distance, in points, that the touch or pointer must move + /// before the gesture starts. + public var minimumDistance: CGFloat + + /// The coordinate space in which the gesture reports locations. + public var coordinateSpace: CoordinateSpace + + var allowedDirections: _EventDirections = .all + + /// Creates a drag gesture with a minimum distance and a coordinate space. + /// + /// - Parameters: + /// - minimumDistance: The minimum distance, in points, that the touch + /// or pointer must move before the gesture starts. The default is + /// `10`. + /// - coordinateSpace: The coordinate space in which the gesture + /// reports locations. The default is ``CoordinateSpace/local``. + @available(*, deprecated, message: "use overload that accepts a CoordinateSpaceProtocol instead") + @_disfavoredOverload + public init(minimumDistance: CGFloat = 10, coordinateSpace: CoordinateSpace = .local) { + self.minimumDistance = minimumDistance + self.coordinateSpace = coordinateSpace + } + + /// Creates a drag gesture with a minimum distance and a coordinate space. + /// + /// - Parameters: + /// - minimumDistance: The minimum distance, in points, that the touch + /// or pointer must move before the gesture starts. The default is + /// `10`. + /// - coordinateSpace: The coordinate space in which the gesture + /// reports locations. The default is ``CoordinateSpaceProtocol/local``. + @available(OpenSwiftUI_v5_0, *) + public init(minimumDistance: CGFloat = 10, coordinateSpace: some CoordinateSpaceProtocol = .local) { + self.minimumDistance = minimumDistance + self.coordinateSpace = coordinateSpace.coordinateSpace + } + + init( + minimumDistance: CGFloat, + coordinateSpace: some CoordinateSpaceProtocol, + allowedDirections: _EventDirections + ) { + self.minimumDistance = minimumDistance + self.coordinateSpace = coordinateSpace.coordinateSpace + self.allowedDirections = allowedDirections + } + + nonisolated public static func _makeGesture( + gesture: _GraphValue, + inputs: _GestureInputs + ) -> _GestureOutputs { + makeGesture(gesture: gesture, inputs: inputs) + } + + package var internalBody: some Gesture { + // FIXME: SwiftUI maps DragGesture3D on visionOS. + SpatialDragGesture( + minimumDistance: minimumDistance, + coordinateSpace: coordinateSpace, + allowedDirections: allowedDirections + ) + .category(.drag, includeChildren: false) + } + + public typealias Body = Never +} + +@available(*, unavailable) +extension DragGesture: Sendable {} + +// MARK: - SpatialDragGesture + +#if os(macOS) +private typealias DragEvent = MouseEvent +#else +private typealias DragEvent = TouchEvent +#endif + +@available(tvOS, unavailable) +private struct SpatialDragGesture: Gesture { + struct StateType: GestureStateProtocol { + var start: DragEvent? + var value: DragGesture.Value? + var sampler: VelocitySampler> + + init() { + start = nil + value = nil + sampler = VelocitySampler() + } + } + + var minimumDistance: CGFloat + var coordinateSpace: CoordinateSpace + var allowedDirections: _EventDirections + + var body: some Gesture { + StateType.gesture( + content: EventListener() + .eventFilter(forType: MouseEvent.self) { event in + event.button == .primary + } + .coordinateSpace(coordinateSpace) + ) { state, phase in + self.phase(state: &state, event: phase) + } + .dependency(.pausedUntilFailed) + } + + func phase( + state: inout StateType, + event: GesturePhase + ) -> GesturePhase { + switch event { + case .possible: + guard !allowedDirections.isEmpty else { + return .failed + } + return .possible(state.value) + case let .active(currentEvent), let .ended(currentEvent): + let start = state.start ?? currentEvent + state.start = start + let location = currentEvent.location + state.sampler.addSample( + AnimatablePair(location.x, location.y), + time: currentEvent.timestamp + ) + let value = DragGesture.Value( + time: Date(timeIntervalSinceReferenceDate: currentEvent.timestamp.seconds), + location: location, + startLocation: start.location, + _velocity: state.sampler.velocity.map { velocity in + CGSize(width: velocity.first, height: velocity.second) + } + ) + if state.value == nil, minimumDistance > 0 { + let translation = start.globalLocation - currentEvent.globalLocation + guard Double(minimumDistance) <= translation.magnitude, + translation.withinRange(axes: allowedDirections, rangeCosine: 0.5) + else { + return event.isEnded ? .failed : .possible(state.value) + } + } + state.value = value + return event.withValue(value) + case .failed: + return .failed + } + } +} diff --git a/Sources/OpenSwiftUI/Event/Gesture/EventDirections.swift b/Sources/OpenSwiftUI/Event/Gesture/EventDirections.swift new file mode 100644 index 000000000..0ba8c0de0 --- /dev/null +++ b/Sources/OpenSwiftUI/Event/Gesture/EventDirections.swift @@ -0,0 +1,77 @@ +// +// EventDirections.swift +// OpenSwiftUI +// +// Audited for 6.5.4 +// Status: Complete + +import Foundation +import OpenSwiftUICore + +// MARK: - _EventDirections + +/// A set of directions in which an event, such as a drag or a scroll, can +/// move. +/// +/// Gestures and scroll views use this set to limit an interaction to some +/// directions. For example, a gesture that uses ``horizontal`` responds only +/// to movement that is mostly left or right. +@available(OpenSwiftUI_v1_0, *) +public struct _EventDirections: OptionSet { + public let rawValue: Int8 + + public init(rawValue: Int8) { + self.rawValue = rawValue + } + + /// The left direction. + public static let left = _EventDirections(rawValue: 1 << 0) + + /// The right direction. + public static let right = _EventDirections(rawValue: 1 << 1) + + /// The up direction. + public static let up = _EventDirections(rawValue: 1 << 2) + + /// The down direction. + public static let down = _EventDirections(rawValue: 1 << 3) + + /// The left and right directions. + public static let horizontal: _EventDirections = [.left, .right] + + /// The up and down directions. + public static let vertical: _EventDirections = [.up, .down] + + /// All directions. + public static let all: _EventDirections = [.horizontal, .vertical] +} + +@available(*, unavailable) +extension _EventDirections: Sendable {} + +// MARK: - CGSize + withinRange + +extension CGSize { + /// Returns a Boolean value that indicates whether this size points in one + /// of the directions in `axes`. + /// + /// The size points in a direction when the cosine of the angle between + /// them is greater than `rangeCosine`. The result is always `true` when + /// `axes` is ``_EventDirections/all``. + func withinRange(axes: _EventDirections, rangeCosine: CGFloat) -> Bool { + guard axes != .all else { + return true + } + let direction = normalized() + if axes.contains(.left), -direction.width > rangeCosine { + return true + } + if axes.contains(.right), direction.width > rangeCosine { + return true + } + if axes.contains(.up), -direction.height > rangeCosine { + return true + } + return axes.contains(.down) && direction.height > rangeCosine + } +} diff --git a/Sources/OpenSwiftUICore/Util/Extension/CGSize+Math.swift b/Sources/OpenSwiftUICore/Util/Extension/CGSize+Math.swift index 95b235db2..584092ba5 100644 --- a/Sources/OpenSwiftUICore/Util/Extension/CGSize+Math.swift +++ b/Sources/OpenSwiftUICore/Util/Extension/CGSize+Math.swift @@ -74,3 +74,25 @@ package func mix(_ lhs: CGSize, _ rhs: CGSize, by t: Double) -> CGSize { height: (rhs.height - lhs.height) * t + lhs.height ) } + +extension CGSize { + package var magnitude: Double { + (width * width + height * height).squareRoot() + } + + package mutating func normalize() { + let magnitudeSquared = width * width + height * height + guard magnitudeSquared != 0 else { + return + } + let scale = 1 / magnitudeSquared.squareRoot() + width *= scale + height *= scale + } + + package func normalized() -> CGSize { + var result = self + result.normalize() + return result + } +}