From 19a9d8cb642e90c065f0ab9aed40bac08e298c7b Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Mon, 31 Jul 2023 18:55:25 +1000 Subject: [PATCH 01/33] Add gestures Add tap, lonPress and Drag gestures. --- .../Gestures/Composing/ExclusiveGesture.swift | 47 +++++ .../Gestures/Composing/SequenceGesture.swift | 38 +++++ .../Composing/SimultaneousGesture.swift | 37 ++++ Sources/TokamakCore/Gestures/Gesture.swift | 125 ++++++++++++++ .../TokamakCore/Gestures/GestureMask.swift | 94 ++++++++++ .../TokamakCore/Gestures/GesturePhase.swift | 25 +++ .../TokamakCore/Gestures/GestureState.swift | 47 +++++ .../Performing/GestureStateGesture.swift | 63 +++++++ .../Gestures/Performing/_ChangedGesture.swift | 59 +++++++ .../Gestures/Performing/_EndedGesture.swift | 58 +++++++ .../Gestures/Recognizers/DragGesture.swift | 133 +++++++++++++++ .../Recognizers/LongPressGesture.swift | 160 ++++++++++++++++++ .../Gestures/Recognizers/TapGesture.swift | 93 ++++++++++ .../Views/Gestures/GestureView.swift | 57 +++++++ Sources/TokamakDOM/Core.swift | 7 + .../Views/Gestures/GestureView.swift | 41 +++++ .../Views/Gestures/_DragGestureView.swift | 75 ++++++++ .../Gestures/_LongPressGestureView.swift | 91 ++++++++++ .../Views/Gestures/_TapGestureView.swift | 41 +++++ Sources/TokamakDemo/DOM/URLHashDemo.swift | 8 +- 20 files changed, 1295 insertions(+), 4 deletions(-) create mode 100644 Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift create mode 100644 Sources/TokamakCore/Gestures/Composing/SequenceGesture.swift create mode 100644 Sources/TokamakCore/Gestures/Composing/SimultaneousGesture.swift create mode 100644 Sources/TokamakCore/Gestures/Gesture.swift create mode 100644 Sources/TokamakCore/Gestures/GestureMask.swift create mode 100644 Sources/TokamakCore/Gestures/GesturePhase.swift create mode 100644 Sources/TokamakCore/Gestures/GestureState.swift create mode 100644 Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift create mode 100644 Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift create mode 100644 Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift create mode 100644 Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift create mode 100644 Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift create mode 100644 Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift create mode 100644 Sources/TokamakCore/Views/Gestures/GestureView.swift create mode 100644 Sources/TokamakDOM/Views/Gestures/GestureView.swift create mode 100644 Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift create mode 100644 Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift create mode 100644 Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift diff --git a/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift b/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift new file mode 100644 index 000000000..4603a4230 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift @@ -0,0 +1,47 @@ +// +// File.swift +// +// +// Created by Szymon on 17/7/2023. +// + +import Foundation +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +@frozen +/// The ExclusiveGesture gives precedence to its first gesture. +public struct ExclusiveGesture where First : Gesture, Second : Gesture { + /// The value of an exclusive gesture that indicates which of two gestures succeeded. + public typealias Value = ExclusiveGesture.ExclusiveValue + + public struct ExclusiveValue { + public var first: First.Value + public var second: First.Value + } + + /// The first of two gestures. + public var first: First + /// The second of two gestures. + public var second: Second + + /// Creates a gesture from two gestures where only one of them succeeds. + init(first: First, second: Second) { + self.first = first + self.second = second + } +} diff --git a/Sources/TokamakCore/Gestures/Composing/SequenceGesture.swift b/Sources/TokamakCore/Gestures/Composing/SequenceGesture.swift new file mode 100644 index 000000000..698b49666 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Composing/SequenceGesture.swift @@ -0,0 +1,38 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +@frozen +public struct SequenceGesture where First : Gesture, Second : Gesture { + /// The value of a sequence gesture that helps to detect whether the first gesture succeeded, so the second gesture can start. + public typealias Value = SequenceGesture.SequenceValue + + public struct SequenceValue { + public var first: First.Value + public var second: First.Value + } + + /// The first gesture in a sequence of two gestures. + public var first: First + /// The second gesture in a sequence of two gestures. + public var second: Second + + /// Creates a sequence gesture with two gestures. + init(first: First, second: Second) { + self.first = first + self.second = second + } +} diff --git a/Sources/TokamakCore/Gestures/Composing/SimultaneousGesture.swift b/Sources/TokamakCore/Gestures/Composing/SimultaneousGesture.swift new file mode 100644 index 000000000..0aea6c3e4 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Composing/SimultaneousGesture.swift @@ -0,0 +1,37 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +@frozen +public struct SimultaneousGesture where First : Gesture, Second : Gesture { + public typealias Value = SimultaneousGesture.SimultaneousValue + + public struct SimultaneousValue { + public let first: First.Value? + public let second: First.Value? + } + + /// The first of two gestures that can happen simultaneously. + public let first: First + /// The second of two gestures that can happen simultaneously. + public let second: Second + + /// Creates a gesture with two gestures that can receive updates or succeed independently of each other. + init(first: First, second: Second) { + self.first = first + self.second = second + } +} diff --git a/Sources/TokamakCore/Gestures/Gesture.swift b/Sources/TokamakCore/Gestures/Gesture.swift new file mode 100644 index 000000000..7e12a7e42 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Gesture.swift @@ -0,0 +1,125 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +import Foundation + +public protocol Gesture { + // MARK: Required + + /// The type representing the gesture’s value. + associatedtype Value + + /// The type of gesture representing the body of Self. + associatedtype Body: Gesture + + /// The content and behavior of the gesture. + var body: Self.Body { get } + + // MARK: Internal + var phase: GesturePhase { get set } + func _onEnded(perform action: @escaping (Value) -> Void) -> Self + func _onChanged(perform action: @escaping (Value) -> Void) -> Self +} + +// MARK: Performing the gesture + +extension Gesture { + /// Adds an action to perform when the gesture ends. + public func onEnded(_ action: @escaping (Self.Value) -> Void) -> _EndedGesture { + _EndedGesture(self, onEnded: action) + } + + /// Updates the provided gesture value property as the gesture’s value changes. + public func updating( + _ state: GestureState, + body: @escaping (Self.Value, inout State, inout Transaction) -> Void + ) -> GestureStateGesture { + GestureStateGesture(base: self, state: state, updatingBody: body) + } +} + +// MARK: Performing the gesture + +extension Gesture where Value: Equatable { + /// Adds an action to perform when the gesture’s value changes. + /// Available when Value conforms to Equatable. + public func onChanged(_ action: @escaping (Self.Value) -> Void) -> _ChangedGesture { + _ChangedGesture(self, onChanged: action) + } +} + +// MARK: Composing gestures + +extension Gesture { + /// Combines a gesture with another gesture to create a new gesture that recognizes both gestures at the same time. + public func simultaneously(with gesture: Other) -> SimultaneousGesture { + SimultaneousGesture(first: self, second: gesture) + } + + /// Sequences a gesture with another one to create a new gesture, which results in the second gesture only receiving events after the first gesture succeeds. + public func sequenced(before gesture: Other) -> SequenceGesture { + SequenceGesture(first: self, second: gesture) + } + + /// Combines two gestures exclusively to create a new gesture where only one gesture succeeds, giving precedence to the first gesture. + public func exclusively(before gesture: Other) -> ExclusiveGesture { + ExclusiveGesture(first: self, second: gesture) + } +} + +// MARK: Transforming a gesture + +extension Gesture {} + +// MARK: Private Helpers + +extension Gesture { + func calculateDistance(xOffset: Double, yOffset: Double) -> Double { + let xSquared = pow(xOffset, 2) + let ySquared = pow(yOffset, 2) + let sumOfSquares = xSquared + ySquared + let distance = sqrt(sumOfSquares) + return distance + } + + func calculateTranslation(from pointA: CGPoint, to pointB: CGPoint) -> CGSize { + let dx = pointB.x - pointA.x + let dy = pointB.y - pointA.y + return CGSize(width: dx, height: dy) + } + + func calculateVelocity(from translation: CGSize, timeElapsed: Double) -> CGSize { + let velocityX = translation.width / timeElapsed + let velocityY = translation.height / timeElapsed + + return CGSize(width: velocityX, height: velocityY) + } + + func calculatePredictedEndLocation(from location: CGPoint, velocity: CGSize) -> CGPoint { + let predictedX = location.x + velocity.width + let predictedY = location.y + velocity.height + + return CGPoint(x: predictedX, y: predictedY) + } + + func calculatePredictedEndTranslation(from translation: CGSize, velocity: CGSize) -> CGSize { + let predictedWidth = translation.width + velocity.width + let predictedHeight = translation.height + velocity.height + + return CGSize(width: predictedWidth, height: predictedHeight) + } +} diff --git a/Sources/TokamakCore/Gestures/GestureMask.swift b/Sources/TokamakCore/Gestures/GestureMask.swift new file mode 100644 index 000000000..8cb2ced0b --- /dev/null +++ b/Sources/TokamakCore/Gestures/GestureMask.swift @@ -0,0 +1,94 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 30/7/2023. +// + +import Foundation + +/// Options that control how adding a gesture to a view affects other gestures recognized by the view and its subviews. +@frozen public struct GestureMask: Equatable, ExpressibleByArrayLiteral, OptionSet, RawRepresentable, Sendable, SetAlgebra { + public typealias RawValue = Int + public var rawValue: Int + + // MARK: - OptionSet + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + // MARK: - Equatable + + public static func == (lhs: GestureMask, rhs: GestureMask) -> Bool { + return lhs.rawValue == rhs.rawValue + } + + // MARK: - ExpressibleByArrayLiteral + + /// Creates a gesture mask from an array of gesture options. + /// + /// - Parameter elements: An array of `GestureMask` elements. + public init(arrayLiteral elements: GestureMask...) { + self.rawValue = elements.reduce(0) { $0 | $1.rawValue } + } + + // MARK: - SetAlgebra + + static var allZeros: GestureMask { + return GestureMask(rawValue: 0) + } + + static func | (lhs: GestureMask, rhs: GestureMask) -> GestureMask { + return GestureMask(rawValue: lhs.rawValue | rhs.rawValue) + } + + static func & (lhs: GestureMask, rhs: GestureMask) -> GestureMask { + return GestureMask(rawValue: lhs.rawValue & rhs.rawValue) + } + + static prefix func ~ (x: GestureMask) -> GestureMask { + return GestureMask(rawValue: ~x.rawValue) + } + + // MARK: - Gesture Options + + /// Enable both the added gesture as well as all other gestures on the view and its subviews. + public static let all: GestureMask = .gesture | .subviews + + /// Enable the added gesture but disable all gestures in the subview hierarchy. + public static let gesture: GestureMask = GestureMask(rawValue: 1 << 0) + + /// Enable all gestures in the subview hierarchy but disable the added gesture. + public static let subviews: GestureMask = GestureMask(rawValue: 1 << 1) + + /// Disable all gestures in the subview hierarchy, including the added gesture. + public static let none: GestureMask = [] + + // MARK: - Helper Methods + + /// Enables a specific gesture option in the mask. + /// + /// - Parameter option: The `GestureMask` representing the gesture option to enable. + mutating func enableGesture(_ option: GestureMask) { + self.insert(option) + } + + /// Disables a specific gesture option in the mask. + /// + /// - Parameter option: The `GestureMask` representing the gesture option to disable. + mutating func disableGesture(_ option: GestureMask) { + self.remove(option) + } +} + diff --git a/Sources/TokamakCore/Gestures/GesturePhase.swift b/Sources/TokamakCore/Gestures/GesturePhase.swift new file mode 100644 index 000000000..d901e3890 --- /dev/null +++ b/Sources/TokamakCore/Gestures/GesturePhase.swift @@ -0,0 +1,25 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 23/7/2023. +// + +import Foundation + +public enum GesturePhase { + case began(location: CGPoint) + case changed(location: CGPoint) + case ended(location: CGPoint) + case cancelled +} diff --git a/Sources/TokamakCore/Gestures/GestureState.swift b/Sources/TokamakCore/Gestures/GestureState.swift new file mode 100644 index 000000000..e8f9dde4a --- /dev/null +++ b/Sources/TokamakCore/Gestures/GestureState.swift @@ -0,0 +1,47 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + + +@propertyWrapper +public struct GestureState: DynamicProperty { + private let initialValue: Value + + var anyInitialValue: Any { initialValue } + + var getter: (() -> Any)? + var setter: ((Any, Transaction) -> ())? + + public init(wrappedValue value: Value) { + initialValue = value + } + + public var wrappedValue: Value { + get { getter?() as? Value ?? initialValue } + nonmutating set { setter?(newValue, Transaction._active ?? .init(animation: nil)) } + } + + public var projectedValue: GestureState { + self + } +} + +extension GestureState: WritableValueStorage {} + +public extension GestureState where Value: ExpressibleByNilLiteral { + @inlinable + init() { self.init(wrappedValue: nil) } +} diff --git a/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift b/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift new file mode 100644 index 000000000..d21d6f716 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift @@ -0,0 +1,63 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +public struct GestureStateGesture: Gesture { + public typealias Value = Base.Value + + @GestureState private var gestureState: State + private var gesture: Base + + private let updatingBody: (Base.Value, inout State, inout Transaction) -> Void + private var onEnded: ((Value) -> Void)? + + public var phase: GesturePhase { + get { + gesture.phase + } + set { + gesture.phase = newValue + } + } + + public var body: Base.Body { + var gesture = gesture._onChanged(perform: { value in + // TODO: Is this transaction working? + var transaction = Transaction._active ?? .init(animation: nil) + updatingBody(value, &gestureState, &transaction) + }) + if let onEnded { + gesture = gesture._onEnded(perform: onEnded) + } + return gesture.body + } + + init(base: Base, state: GestureState, updatingBody: @escaping (Base.Value, inout State, inout Transaction) -> Void) { + self.gesture = base + self._gestureState = state + self.updatingBody = updatingBody + } + + public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onEnded = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { + self + } +} diff --git a/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift b/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift new file mode 100644 index 000000000..8b8d9041f --- /dev/null +++ b/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift @@ -0,0 +1,59 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +public struct _ChangedGesture: Gesture { + public typealias Value = Base.Value + + private var gesture: Base + private var onChanged: (Base.Value) -> Void + private var onEnded: ((Value) -> Void)? + + + public var phase: GesturePhase { + get { + gesture.phase + } + set { + gesture.phase = newValue + } + } + + public var body: Base.Body { + var gesture = gesture._onChanged(perform: onChanged) + if let onEnded { + gesture = gesture._onEnded(perform: onEnded) + } + return gesture.body + } + + init(_ gesture: Base, onChanged: @escaping (Base.Value) -> Void) { + self.gesture = gesture + self.onChanged = onChanged + } + + public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onEnded = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onChanged = action + return gesture + } +} diff --git a/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift b/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift new file mode 100644 index 000000000..0ac7f42b1 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift @@ -0,0 +1,58 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +public struct _EndedGesture: Gesture { + public typealias Value = Base.Value + + private var gesture: Base + private var onEnded: (Value) -> Void + private var onChanged: ((Value) -> Void)? + + public var phase: GesturePhase { + get { + gesture.phase + } + set { + gesture.phase = newValue + } + } + + public var body: Base.Body { + var gesture = gesture._onEnded(perform: onEnded) + if let onChanged { + gesture = gesture._onChanged(perform: onChanged) + } + return gesture.body + } + + init(_ gesture: Base, onEnded: @escaping (Value) -> Void) { + self.gesture = gesture + self.onEnded = onEnded + } + + public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onEnded = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onChanged = action + return gesture + } +} diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift new file mode 100644 index 000000000..8848d1c5a --- /dev/null +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -0,0 +1,133 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +import Foundation + +public struct DragGesture: Gesture { + private var startLocation: CGPoint? = nil + private var previousTimestamp: Date? + private var velocity: CGSize = .zero + private var onEndedAction: ((Value) -> Void)? = nil + private var onChangedAction: ((Value) -> Void)? = nil + private var didMeetMininumDistanceRequirement: Bool = false + + public var minimumDistance: Double + public var phase: GesturePhase = .cancelled { + didSet { + switch phase { + case .began(let location): + startLocation = location + didMeetMininumDistanceRequirement = false + previousTimestamp = nil + velocity = .zero + case .changed(let location) where startLocation != nil: + guard let startLocation else { return } + let translation = calculateTranslation(from: startLocation, to: location) + let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) + + if minimumDistance < distance { + didMeetMininumDistanceRequirement = true + } + + // Do nothing if gesture has not met the criteria + guard didMeetMininumDistanceRequirement else { return } + let currentTimestamp = Date() + let timeElapsed = Double(currentTimestamp.timeIntervalSince(previousTimestamp ?? currentTimestamp)) + let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) + self.velocity = velocity + + // Predict end location based on velocity + let predictedEndLocation = calculatePredictedEndLocation(from: location, velocity: velocity) + + // Predict end translation based on velocity + let predictedEndTranslation = calculatePredictedEndTranslation(from: translation, velocity: velocity) + + onChangedAction?( + Value( + startLocation: startLocation, + location: location, + predictedEndLocation: predictedEndLocation, + translation: translation, + predictedEndTranslation: predictedEndTranslation + ) + ) + case .changed: + break + case .ended(let location): + if let startLocation { + let translation = calculateTranslation(from: startLocation, to: location) + let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) + onEndedAction?( + Value( + startLocation: startLocation, + location: location, + predictedEndLocation: location, + translation: translation, + predictedEndTranslation: translation + ) + ) + } + startLocation = nil + case .cancelled: + startLocation = nil + } + } + } + public var body: DragGesture { + self + } + + /// Creates a dragging gesture with the minimum dragging distance before the gesture succeeds and the coordinate space of the gesture’s location. + /// By default, the minimum distance needed to recognize a gesture is 10. + /// - Parameters: + /// - minimumDistance: The minimum dragging distance before the gesture succeeds. + /// - coordinateSpace: The coordinate space in which to receive location values. + public init(minimumDistance: Double = 10) { + self.minimumDistance = minimumDistance + } + + public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onEndedAction = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onChangedAction = action + return gesture + } + + // MARK: Types + + public struct Value: Equatable { + /// The location of the drag gesture’s first event. + public var startLocation: CGPoint = .zero + + /// The location of the drag gesture’s current event. + public var location: CGPoint = .zero + + /// A prediction, based on the current drag velocity, of where the final location will be if dragging stopped now. + public var predictedEndLocation: CGPoint = .zero + + /// The total translation from the start of the drag gesture to the current event of the drag gesture. + public var translation: CGSize = .zero + + /// A prediction, based on the current drag velocity, of what the final translation will be if dragging stopped now. + public var predictedEndTranslation: CGSize = .zero + } +} diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift new file mode 100644 index 000000000..e4c64a3a3 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -0,0 +1,160 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +import Foundation + +public struct LongPressGesture: Gesture { + public typealias Value = Bool + private var startLocation: CGPoint? = nil + private var touchStartTime = Date() + public private(set) var minimumDuration: Double + private var maximumDistance: Double = 0 + private var onEndedAction: ((Value) -> Void)? = nil + private var onChangedAction: ((Value) -> Void)? = nil + + public var phase: GesturePhase = .cancelled { + didSet { + switch phase { + case .began(let location): + startLocation = location + touchStartTime = Date() + onChangedAction?(startLocation != nil) + case .changed(let location) where startLocation != nil: + guard let startLocation else { return } + let translation = calculateTranslation(from: startLocation, to: location) + let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) + + guard maximumDistance >= distance else { + // Fail longpress if distance is to big. + self.startLocation = nil + return + } + + let touch = Date() + let delayInSeconds = touch.timeIntervalSince(touchStartTime) + + if delayInSeconds >= minimumDuration { + // Reset state, so behaviour matches SwiftUI Altough, SwiftUI doesn not triggers it, but we have to. + onChangedAction?(false) + // The LongPress gesture ends when the required duration is met. + onEndedAction?(true) + self.startLocation = nil + } + case .changed: + break + case .cancelled, .ended: + startLocation = nil + } + } + } + + public var body: LongPressGesture { + self + } + + /// Creates a long-press gesture with a minimum duration + /// - Parameter minimumDuration: The minimum duration of the long press that must elapse before the gesture succeeds. + public init(minimumDuration: Double = 0.5) { + self.minimumDuration = minimumDuration + } + + /// Creates a long-press gesture with a minimum duration and a maximum distance that the interaction can move before the gesture fails. + /// - Parameters: + /// - minimumDuration: The minimum duration of the long press that must elapse before the gesture succeeds. + /// - maximumDistance: The maximum distance that the long press can move before the gesture fails. + public init(minimumDuration: Double, maximumDistance: Double) { + self.minimumDuration = minimumDuration + self.maximumDistance = maximumDistance + } + + public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onEndedAction = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onChangedAction = action + return gesture + } +} + +// MARK: View Modifiers + +extension View { + /// Adds an action to perform when this view recognizes a remote long touch gesture. + /// A long touch gesture is when the finger is on the remote touch surface without actually pressing. + public func onLongPressGesture(perform action: @escaping () -> Void) -> some View { + self.modifier(LongPressGestureModifier(action: action)) + } + + /// Adds an action to perform when this view recognizes a long press gesture. + public func onLongPressGesture( + minimumDuration: Double, + maximumDistance: Double, + perform action: @escaping () -> Void, + onPressingChanged: ((Bool) -> Void)? + ) -> some View { + self.modifier( + LongPressGestureModifier( + minimumDuration: minimumDuration, + maximumDistance: maximumDistance, + onPressingChanged: onPressingChanged, + action: action + ) + ) + } + + /// Adds an action to perform when this view recognizes a long press gesture. + public func onLongPressGesture( + minimumDuration: Double = 0.5, + maximumDistance: Double = 10.0, + pressing: ((Bool) -> Void)? = nil, + perform action: @escaping () -> Void + ) -> some View { + self.modifier( + LongPressGestureModifier(minimumDuration: minimumDuration, + maximumDistance: maximumDistance, + onPressingChanged: pressing, + action: action + ) + ) + } +} + +struct LongPressGestureModifier: ViewModifier { + var minimumDuration: Double = 0.5 + var maximumDistance: Double = 10.0 + var onPressingChanged: ((Bool) -> Void)? = nil + let action: () -> Void + + @GestureState private var isPressing = false + + func body(content: Content) -> some View { + content.gesture( + LongPressGesture(minimumDuration: minimumDuration, maximumDistance: maximumDistance) + .updating($isPressing) { currentState, gestureState, _ in + gestureState = currentState + onPressingChanged?(isPressing) + } + .onEnded { _ in + action() + } + ) + } +} diff --git a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift new file mode 100644 index 000000000..73bedf603 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift @@ -0,0 +1,93 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +import Foundation + +public struct TapGesture: Gesture { + public typealias Value = () + + /// The required number of taps to complete the tap gesture. + private var count: Int + /// The maximum duration between the taps + private var delay: Double = 0.3 + private var touchEndTime = Date() + private var numberOfTapsSinceGestureBegan: Int = 0 + private var onEndedAction: ((Value) -> Void)? = nil + + public var phase: GesturePhase = .cancelled { + didSet { + switch phase { + case .cancelled: + numberOfTapsSinceGestureBegan = 0 + case .ended: + if case .began = oldValue { + let touch = Date() + let delayInSeconds = touch.timeIntervalSince(touchEndTime) + touchEndTime = touch + + // If we have multi count tap gesture, handle it if the taps are with in desired delays + if numberOfTapsSinceGestureBegan > 0, delayInSeconds > delay { + numberOfTapsSinceGestureBegan = 0 + } else { + numberOfTapsSinceGestureBegan += 1 + } + } + + // If we ended touch and have desired count we complete gesture + if count == numberOfTapsSinceGestureBegan { + onEndedAction?(()) + numberOfTapsSinceGestureBegan = 0 + } + default: + // TapGesture in SwiftUI have no change update nor events + break + } + } + } + + public var body: TapGesture { + self + } + + /// Creates a tap gesture with the number of required taps. + /// - Parameter count: The required number of taps to complete the tap gesture. + public init(count: Int = 1) { + self.count = count + } + + public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onEndedAction = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { + // TapGesture in SwiftUI have no change update nor events + self + } +} + +// MARK: View Modifiers + +extension View { + /// Adds an action to perform when this view recognizes a tap gesture. + public func onTapGesture(count: Int = 1, perform action: @escaping () -> Void) -> some View { + self.gesture( + TapGesture(count: count).onEnded(action) + ) + } +} diff --git a/Sources/TokamakCore/Views/Gestures/GestureView.swift b/Sources/TokamakCore/Views/Gestures/GestureView.swift new file mode 100644 index 000000000..7a066f655 --- /dev/null +++ b/Sources/TokamakCore/Views/Gestures/GestureView.swift @@ -0,0 +1,57 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + + +public struct GestureView: _PrimitiveView { + @Environment(\.isEnabled) var isEnabled + // TODO: Allow for array of gestures with priority + // TODO: Add AnyGesture, for type erease + // TODO: Add GestureReader? + @State public var gesture: G + public let content: Content + + public init(_ content: Content, gesture: G) { + self.content = content + self._gesture = State(wrappedValue: gesture) + } +} + +extension View { + /// Attaches a single gesture to the view. + /// + /// - Parameter gesture: The gesture to attach. + /// - Returns: A modified version of the view with the gesture attached. + public func gesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T: Gesture { + GestureView(self, gesture: gesture.body) + } + + /// Attaches a gesture to the view to process simultaneously with gestures defined by the view. + /// - Parameter gesture: The gesture to attach. + /// - Returns: A modified version of the view with the gesture attached. + public func simultaneousGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { + GestureView(self, gesture: gesture.body) + } + + /// Attaches a gesture to the view with a higher precedence than gestures defined by the view. + /// - Parameters: + /// - gesture: A gesture to attach to the view. + /// - mask: A value that controls how adding this gesture to the view affects other gestures recognized by the view and its subviews. Defaults to all. + /// - Returns: A modified version of the view with the gesture attached. + func highPriorityGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { + GestureView(self, gesture: gesture.body) + } +} diff --git a/Sources/TokamakDOM/Core.swift b/Sources/TokamakDOM/Core.swift index 30abdc7e1..f89ccd7e7 100644 --- a/Sources/TokamakDOM/Core.swift +++ b/Sources/TokamakDOM/Core.swift @@ -176,6 +176,13 @@ public typealias Toggle = TokamakCore.Toggle public typealias VStack = TokamakCore.VStack public typealias ZStack = TokamakCore.ZStack +// MARK: Gestures + +public typealias GestureState = TokamakCore.GestureState +public typealias TapGesture = TokamakCore.TapGesture +public typealias DragGesture = TokamakCore.DragGesture +public typealias LongPressGesture = TokamakCore.LongPressGesture + // MARK: Special Views public typealias View = TokamakCore.View diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift new file mode 100644 index 000000000..4b9217b5d --- /dev/null +++ b/Sources/TokamakDOM/Views/Gestures/GestureView.swift @@ -0,0 +1,41 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +import JavaScriptKit +import TokamakCore +import TokamakStaticHTML +import Foundation + +extension TokamakCore.GestureView: DOMPrimitive { + var renderedBody: AnyView { + switch G.Body.self { + case is TapGesture.Type: + return AnyView(_TapGestureView(gesture: $gesture, content: content)) + case is LongPressGesture.Type: + return AnyView(_LongPressGestureView(gesture: $gesture, content: content)) + case is DragGesture.Type: + return AnyView(_DragGestureView(gesture: $gesture, content: content)) + default: + return AnyView( + content.onAppear { + print("🛑", G.Body.self) + print("🟡", gesture.self) + } + ) + } + } +} diff --git a/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift new file mode 100644 index 000000000..1bcb0bb66 --- /dev/null +++ b/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift @@ -0,0 +1,75 @@ +// +// File.swift +// +// +// Created by Szymon on 30/7/2023. +// + +import Foundation +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 30/7/2023. +// + +import JavaScriptKit +import TokamakCore +import TokamakStaticHTML +import Foundation + +struct _DragGestureView: View { + @Binding var gesture: G + + let content: Content + + init(gesture: Binding, content: Content) { + self._gesture = gesture + self.content = content + } + + var body: some View { + DynamicHTML("div", [:], listeners: [ + "pointerdown": { event in + guard + let x = event.x.jsValue.number, + let y = event.y.jsValue.number + else { return } + let location = CGPoint(x: x, y: y) + gesture.phase = .began(location: location) + }, + "pointerup": { event in + guard + let x = event.x.jsValue.number, + let y = event.y.jsValue.number + else { return } + let location = CGPoint(x: x, y: y) + gesture.phase = .ended(location: location) + }, + "pointercancel": { _ in + gesture.phase = .cancelled + }, + "pointermove": { event in + guard + let x = event.x.jsValue.number, + let y = event.y.jsValue.number + else { return } + let point = CGPoint(x: x, y: y) + gesture.phase = .changed(location: point) + }, + ]) { + content + } + } +} + diff --git a/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift new file mode 100644 index 000000000..4294ab60b --- /dev/null +++ b/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift @@ -0,0 +1,91 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 30/7/2023. +// + +import JavaScriptKit +import TokamakCore +import TokamakStaticHTML +import Foundation + +struct _LongPressGestureView: View { + @State var isPressing = false + @State var location: CGPoint = .zero + @Binding var gesture: G + + var minimumDuration: Double { + if let longPressGesture = gesture as? LongPressGesture { + return longPressGesture.minimumDuration + } + return 0.5 + } + + let content: Content + + init(gesture: Binding, content: Content) { + self._gesture = gesture + self.content = content + } + + var body: some View { + DynamicHTML("div", [:], listeners: [ + "pointerdown": { event in + startDelay() + isPressing = true + guard + let x = event.x.jsValue.number, + let y = event.y.jsValue.number + else { return } + let location = CGPoint(x: x, y: y) + gesture.phase = .began(location: location) + }, + "pointerup": { _ in + gesture.phase = .ended(location: .zero) + isPressing = false + }, + "pointercancel": { _ in + gesture.phase = .cancelled + isPressing = false + }, + "pointermove": { event in + guard + let x = event.x.jsValue.number, + let y = event.y.jsValue.number + else { return } + let point = CGPoint(x: x, y: y) + location = point + gesture.phase = .changed(location: point) + }, + ]) { + content + } + } + + func startDelay() { + Task { + do { + try await Task.sleep(for: .seconds(minimumDuration)) + if isPressing { + await MainActor.run { + gesture.phase = .changed(location: location) + } + } + } catch { + //TODO: What do we do with this error? + print(error) + } + } + } +} diff --git a/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift new file mode 100644 index 000000000..5823a97cb --- /dev/null +++ b/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift @@ -0,0 +1,41 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 30/7/2023. +// + +import JavaScriptKit +import TokamakCore +import TokamakStaticHTML + +struct _TapGestureView: View { + @Binding var gesture: G + + let content: Content + + init(gesture: Binding, content: Content) { + self._gesture = gesture + self.content = content + } + + var body: some View { + DynamicHTML("div", [:], listeners: [ + "pointerdown": { _ in gesture.phase = .began(location: .zero) }, + "pointerup": { _ in gesture.phase = .ended(location: .zero) }, + "pointercancel": { _ in gesture.phase = .cancelled }, + ]) { + content + } + } +} diff --git a/Sources/TokamakDemo/DOM/URLHashDemo.swift b/Sources/TokamakDemo/DOM/URLHashDemo.swift index ae117e184..ff523a6b4 100644 --- a/Sources/TokamakDemo/DOM/URLHashDemo.swift +++ b/Sources/TokamakDemo/DOM/URLHashDemo.swift @@ -16,18 +16,18 @@ import JavaScriptKit import TokamakDOM -private let location = JSObject.global.location.object! +private let startLocation = JSObject.global.startLocation.object! private let window = JSObject.global.window.object! private final class HashState: ObservableObject { var onHashChange: JSClosure! @Published - var currentHash = location["hash"].string! + var currentHash = startLocation["hash"].string! init() { let onHashChange = JSClosure { [weak self] _ in - self?.currentHash = location["hash"].string! + self?.currentHash = startLocation["hash"].string! return .undefined } @@ -50,7 +50,7 @@ struct URLHashDemo: View { var body: some View { VStack { Button("Assign random location.hash") { - location["hash"] = .string("\(Int.random(in: 0...1000))") + startLocation["hash"] = .string("\(Int.random(in: 0...1000))") } Text("Current location.hash is \(hashState.currentHash)") } From 927fded8e9f024177a554108e8c3574a6d1b9cd9 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Mon, 31 Jul 2023 19:04:01 +1000 Subject: [PATCH 02/33] fix style --- Sources/TokamakCore/Views/Gestures/GestureView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TokamakCore/Views/Gestures/GestureView.swift b/Sources/TokamakCore/Views/Gestures/GestureView.swift index 7a066f655..f135659d9 100644 --- a/Sources/TokamakCore/Views/Gestures/GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/GestureView.swift @@ -20,7 +20,7 @@ public struct GestureView: _PrimitiveView { @Environment(\.isEnabled) var isEnabled // TODO: Allow for array of gestures with priority // TODO: Add AnyGesture, for type erease - // TODO: Add GestureReader? + // TODO: Add GestureReader? @State public var gesture: G public let content: Content From be15819d262f7cb1c49d41f87f4246a52ada465c Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Mon, 31 Jul 2023 19:18:13 +1000 Subject: [PATCH 03/33] Add missing type --- Sources/TokamakDOM/Core.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/TokamakDOM/Core.swift b/Sources/TokamakDOM/Core.swift index f89ccd7e7..998393901 100644 --- a/Sources/TokamakDOM/Core.swift +++ b/Sources/TokamakDOM/Core.swift @@ -178,6 +178,7 @@ public typealias ZStack = TokamakCore.ZStack // MARK: Gestures +public typealias GestureMask = TokamakCore.GestureMask public typealias GestureState = TokamakCore.GestureState public typealias TapGesture = TokamakCore.TapGesture public typealias DragGesture = TokamakCore.DragGesture From fddad3c022fdeb6f7a7e30528eccceffaec4990b Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Tue, 1 Aug 2023 18:10:20 +1000 Subject: [PATCH 04/33] fix header comments --- .../TokamakCore/Gestures/Composing/ExclusiveGesture.swift | 8 -------- Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift | 8 -------- 2 files changed, 16 deletions(-) diff --git a/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift b/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift index 4603a4230..be3d1d03b 100644 --- a/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift +++ b/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift @@ -1,11 +1,3 @@ -// -// File.swift -// -// -// Created by Szymon on 17/7/2023. -// - -import Foundation // Copyright 2020 Tokamak contributors // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift index 1bcb0bb66..d57e30268 100644 --- a/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift @@ -1,11 +1,3 @@ -// -// File.swift -// -// -// Created by Szymon on 30/7/2023. -// - -import Foundation // Copyright 2020 Tokamak contributors // // Licensed under the Apache License, Version 2.0 (the "License"); From d31f7f373ddd2dd9ac4184e504ae3bd3c974df0a Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Wed, 9 Aug 2023 19:32:10 +1000 Subject: [PATCH 05/33] Address PR feedback + add view data changes reaction modifiers --- Sources/TokamakCore/Gestures/Gesture.swift | 3 +- .../TokamakCore/Gestures/GestureMask.swift | 16 +-- .../Performing/GestureStateGesture.swift | 11 +- .../Gestures/Performing/_ChangedGesture.swift | 12 +- .../Gestures/Performing/_EndedGesture.swift | 11 +- .../Gestures/Recognizers/DragGesture.swift | 118 ++++++++---------- .../Recognizers/LongPressGesture.swift | 86 +++++++------ .../Gestures/Recognizers/TapGesture.swift | 49 ++++---- ...GesturePhase.swift => _GesturePhase.swift} | 2 +- .../Views/Gestures/GestureView.swift | 18 ++- .../TokamakCore/Views/View+DataChanges.swift | 101 +++++++++++++++ .../Views/Gestures/_DragGestureView.swift | 8 +- .../Gestures/_LongPressGestureView.swift | 10 +- .../Views/Gestures/_TapGestureView.swift | 6 +- .../ViewReactToDataChangesTests.swift | 107 ++++++++++++++++ 15 files changed, 365 insertions(+), 193 deletions(-) rename Sources/TokamakCore/Gestures/{GesturePhase.swift => _GesturePhase.swift} (96%) create mode 100644 Sources/TokamakCore/Views/View+DataChanges.swift create mode 100644 Tests/TokamakTests/ViewReactToDataChangesTests.swift diff --git a/Sources/TokamakCore/Gestures/Gesture.swift b/Sources/TokamakCore/Gestures/Gesture.swift index 7e12a7e42..8ce3a04f7 100644 --- a/Sources/TokamakCore/Gestures/Gesture.swift +++ b/Sources/TokamakCore/Gestures/Gesture.swift @@ -29,8 +29,7 @@ public protocol Gesture { /// The content and behavior of the gesture. var body: Self.Body { get } - // MARK: Internal - var phase: GesturePhase { get set } + mutating func _onPhaseChange(_ phase: _GesturePhase) func _onEnded(perform action: @escaping (Value) -> Void) -> Self func _onChanged(perform action: @escaping (Value) -> Void) -> Self } diff --git a/Sources/TokamakCore/Gestures/GestureMask.swift b/Sources/TokamakCore/Gestures/GestureMask.swift index 8cb2ced0b..50974c67a 100644 --- a/Sources/TokamakCore/Gestures/GestureMask.swift +++ b/Sources/TokamakCore/Gestures/GestureMask.swift @@ -18,13 +18,13 @@ import Foundation /// Options that control how adding a gesture to a view affects other gestures recognized by the view and its subviews. -@frozen public struct GestureMask: Equatable, ExpressibleByArrayLiteral, OptionSet, RawRepresentable, Sendable, SetAlgebra { - public typealias RawValue = Int - public var rawValue: Int +@frozen public struct GestureMask: Equatable, ExpressibleByArrayLiteral, OptionSet, Sendable { + public typealias RawValue = Int8 + public var rawValue: Int8 // MARK: - OptionSet - public init(rawValue: Int) { + public init(rawValue: Int8) { self.rawValue = rawValue } @@ -64,16 +64,16 @@ import Foundation // MARK: - Gesture Options /// Enable both the added gesture as well as all other gestures on the view and its subviews. - public static let all: GestureMask = .gesture | .subviews + public static let all: Self = .gesture | .subviews /// Enable the added gesture but disable all gestures in the subview hierarchy. - public static let gesture: GestureMask = GestureMask(rawValue: 1 << 0) + public static let gesture: Self = GestureMask(rawValue: 1 << 0) /// Enable all gestures in the subview hierarchy but disable the added gesture. - public static let subviews: GestureMask = GestureMask(rawValue: 1 << 1) + public static let subviews: Self = GestureMask(rawValue: 1 << 1) /// Disable all gestures in the subview hierarchy, including the added gesture. - public static let none: GestureMask = [] + public static let none: Self = [] // MARK: - Helper Methods diff --git a/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift b/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift index d21d6f716..81563862e 100644 --- a/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift +++ b/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift @@ -24,15 +24,6 @@ public struct GestureStateGesture: Gesture { private let updatingBody: (Base.Value, inout State, inout Transaction) -> Void private var onEnded: ((Value) -> Void)? - public var phase: GesturePhase { - get { - gesture.phase - } - set { - gesture.phase = newValue - } - } - public var body: Base.Body { var gesture = gesture._onChanged(perform: { value in // TODO: Is this transaction working? @@ -50,6 +41,8 @@ public struct GestureStateGesture: Gesture { self._gestureState = state self.updatingBody = updatingBody } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) {} public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { var gesture = self diff --git a/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift b/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift index 8b8d9041f..f8aba3d62 100644 --- a/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift +++ b/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift @@ -22,16 +22,6 @@ public struct _ChangedGesture: Gesture { private var onChanged: (Base.Value) -> Void private var onEnded: ((Value) -> Void)? - - public var phase: GesturePhase { - get { - gesture.phase - } - set { - gesture.phase = newValue - } - } - public var body: Base.Body { var gesture = gesture._onChanged(perform: onChanged) if let onEnded { @@ -44,6 +34,8 @@ public struct _ChangedGesture: Gesture { self.gesture = gesture self.onChanged = onChanged } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) {} public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { var gesture = self diff --git a/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift b/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift index 0ac7f42b1..30f27ea08 100644 --- a/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift +++ b/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift @@ -22,15 +22,6 @@ public struct _EndedGesture: Gesture { private var onEnded: (Value) -> Void private var onChanged: ((Value) -> Void)? - public var phase: GesturePhase { - get { - gesture.phase - } - set { - gesture.phase = newValue - } - } - public var body: Base.Body { var gesture = gesture._onEnded(perform: onEnded) if let onChanged { @@ -43,6 +34,8 @@ public struct _EndedGesture: Gesture { self.gesture = gesture self.onEnded = onEnded } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) {} public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { var gesture = self diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 8848d1c5a..1e8c66721 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -23,70 +23,7 @@ public struct DragGesture: Gesture { private var velocity: CGSize = .zero private var onEndedAction: ((Value) -> Void)? = nil private var onChangedAction: ((Value) -> Void)? = nil - private var didMeetMininumDistanceRequirement: Bool = false - public var minimumDistance: Double - public var phase: GesturePhase = .cancelled { - didSet { - switch phase { - case .began(let location): - startLocation = location - didMeetMininumDistanceRequirement = false - previousTimestamp = nil - velocity = .zero - case .changed(let location) where startLocation != nil: - guard let startLocation else { return } - let translation = calculateTranslation(from: startLocation, to: location) - let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) - - if minimumDistance < distance { - didMeetMininumDistanceRequirement = true - } - - // Do nothing if gesture has not met the criteria - guard didMeetMininumDistanceRequirement else { return } - let currentTimestamp = Date() - let timeElapsed = Double(currentTimestamp.timeIntervalSince(previousTimestamp ?? currentTimestamp)) - let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) - self.velocity = velocity - - // Predict end location based on velocity - let predictedEndLocation = calculatePredictedEndLocation(from: location, velocity: velocity) - - // Predict end translation based on velocity - let predictedEndTranslation = calculatePredictedEndTranslation(from: translation, velocity: velocity) - - onChangedAction?( - Value( - startLocation: startLocation, - location: location, - predictedEndLocation: predictedEndLocation, - translation: translation, - predictedEndTranslation: predictedEndTranslation - ) - ) - case .changed: - break - case .ended(let location): - if let startLocation { - let translation = calculateTranslation(from: startLocation, to: location) - let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) - onEndedAction?( - Value( - startLocation: startLocation, - location: location, - predictedEndLocation: location, - translation: translation, - predictedEndTranslation: translation - ) - ) - } - startLocation = nil - case .cancelled: - startLocation = nil - } - } - } public var body: DragGesture { self } @@ -99,6 +36,61 @@ public struct DragGesture: Gesture { public init(minimumDistance: Double = 10) { self.minimumDistance = minimumDistance } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) { + switch phase { + case .began(let location): + startLocation = location + previousTimestamp = nil + velocity = .zero + case .changed(let location) where startLocation != nil: + guard let startLocation else { return } + let translation = calculateTranslation(from: startLocation, to: location) + let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) + + // Do nothing if gesture has not met the criteria + guard minimumDistance < distance else { return } + let currentTimestamp = Date() + let timeElapsed = Double(currentTimestamp.timeIntervalSince(previousTimestamp ?? currentTimestamp)) + let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) + self.velocity = velocity + + // Predict end location based on velocity + let predictedEndLocation = calculatePredictedEndLocation(from: location, velocity: velocity) + + // Predict end translation based on velocity + let predictedEndTranslation = calculatePredictedEndTranslation(from: translation, velocity: velocity) + + onChangedAction?( + Value( + startLocation: startLocation, + location: location, + predictedEndLocation: predictedEndLocation, + translation: translation, + predictedEndTranslation: predictedEndTranslation + ) + ) + case .changed: + break + case .ended(let location): + if let startLocation { + let translation = calculateTranslation(from: startLocation, to: location) + let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) + onEndedAction?( + Value( + startLocation: startLocation, + location: location, + predictedEndLocation: location, + translation: translation, + predictedEndTranslation: translation + ) + ) + } + startLocation = nil + case .cancelled: + startLocation = nil + } + } public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { var gesture = self diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift index e4c64a3a3..81ba5e4bd 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -20,48 +20,11 @@ import Foundation public struct LongPressGesture: Gesture { public typealias Value = Bool private var startLocation: CGPoint? = nil - private var touchStartTime = Date() - public private(set) var minimumDuration: Double + private var touchStartTime = Date(timeIntervalSince1970: 0) private var maximumDistance: Double = 0 private var onEndedAction: ((Value) -> Void)? = nil private var onChangedAction: ((Value) -> Void)? = nil - - public var phase: GesturePhase = .cancelled { - didSet { - switch phase { - case .began(let location): - startLocation = location - touchStartTime = Date() - onChangedAction?(startLocation != nil) - case .changed(let location) where startLocation != nil: - guard let startLocation else { return } - let translation = calculateTranslation(from: startLocation, to: location) - let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) - - guard maximumDistance >= distance else { - // Fail longpress if distance is to big. - self.startLocation = nil - return - } - - let touch = Date() - let delayInSeconds = touch.timeIntervalSince(touchStartTime) - - if delayInSeconds >= minimumDuration { - // Reset state, so behaviour matches SwiftUI Altough, SwiftUI doesn not triggers it, but we have to. - onChangedAction?(false) - // The LongPress gesture ends when the required duration is met. - onEndedAction?(true) - self.startLocation = nil - } - case .changed: - break - case .cancelled, .ended: - startLocation = nil - } - } - } - + public private(set) var minimumDuration: Double public var body: LongPressGesture { self } @@ -81,6 +44,40 @@ public struct LongPressGesture: Gesture { self.maximumDistance = maximumDistance } + public mutating func _onPhaseChange(_ phase: _GesturePhase) { + switch phase { + case .began(let location): + startLocation = location + touchStartTime = Date() + onChangedAction?(startLocation != nil) + case .changed(let location) where startLocation != nil: + guard let startLocation else { return } + let translation = calculateTranslation(from: startLocation, to: location) + let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) + + guard maximumDistance >= distance else { + // Fail longpress if distance is to big. + self.startLocation = nil + return + } + + let touch = Date() + let delayInSeconds = touch.timeIntervalSince(touchStartTime) + + if delayInSeconds >= minimumDuration { + // Reset state, so behaviour matches SwiftUI. Although, SwiftUI doesn't trigger it, but we have to. + onChangedAction?(false) + // The LongPress gesture ends when the required duration is met. + onEndedAction?(true) + self.startLocation = nil + } + case .changed: + break + case .cancelled, .ended: + startLocation = nil + } + } + public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { var gesture = self gesture.onEndedAction = action @@ -128,11 +125,12 @@ extension View { perform action: @escaping () -> Void ) -> some View { self.modifier( - LongPressGestureModifier(minimumDuration: minimumDuration, - maximumDistance: maximumDistance, - onPressingChanged: pressing, - action: action - ) + LongPressGestureModifier( + minimumDuration: minimumDuration, + maximumDistance: maximumDistance, + onPressingChanged: pressing, + action: action + ) ) } } diff --git a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift index 73bedf603..8bd345a92 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift @@ -19,44 +19,43 @@ import Foundation public struct TapGesture: Gesture { public typealias Value = () - /// The required number of taps to complete the tap gesture. private var count: Int /// The maximum duration between the taps private var delay: Double = 0.3 private var touchEndTime = Date() private var numberOfTapsSinceGestureBegan: Int = 0 + private var phase: _GesturePhase = .cancelled private var onEndedAction: ((Value) -> Void)? = nil - public var phase: GesturePhase = .cancelled { - didSet { - switch phase { - case .cancelled: - numberOfTapsSinceGestureBegan = 0 - case .ended: - if case .began = oldValue { - let touch = Date() - let delayInSeconds = touch.timeIntervalSince(touchEndTime) - touchEndTime = touch - - // If we have multi count tap gesture, handle it if the taps are with in desired delays - if numberOfTapsSinceGestureBegan > 0, delayInSeconds > delay { - numberOfTapsSinceGestureBegan = 0 - } else { - numberOfTapsSinceGestureBegan += 1 - } - } + public mutating func _onPhaseChange(_ phase: _GesturePhase) { + switch phase { + case .cancelled: + numberOfTapsSinceGestureBegan = 0 + case .ended: + if case .began = self.phase { + let touch = Date() + let delayInSeconds = touch.timeIntervalSince(touchEndTime) + touchEndTime = touch - // If we ended touch and have desired count we complete gesture - if count == numberOfTapsSinceGestureBegan { - onEndedAction?(()) + // If we have multi count tap gesture, handle it if the taps are with in desired delays + if numberOfTapsSinceGestureBegan > 0, delayInSeconds > delay { numberOfTapsSinceGestureBegan = 0 + } else { + numberOfTapsSinceGestureBegan += 1 } - default: - // TapGesture in SwiftUI have no change update nor events - break } + + // If we ended touch and have desired count we complete gesture + if count == numberOfTapsSinceGestureBegan { + onEndedAction?(()) + numberOfTapsSinceGestureBegan = 0 + } + default: + // TapGesture in SwiftUI have no change update nor events + break } + self.phase = phase } public var body: TapGesture { diff --git a/Sources/TokamakCore/Gestures/GesturePhase.swift b/Sources/TokamakCore/Gestures/_GesturePhase.swift similarity index 96% rename from Sources/TokamakCore/Gestures/GesturePhase.swift rename to Sources/TokamakCore/Gestures/_GesturePhase.swift index d901e3890..53fac54db 100644 --- a/Sources/TokamakCore/Gestures/GesturePhase.swift +++ b/Sources/TokamakCore/Gestures/_GesturePhase.swift @@ -17,7 +17,7 @@ import Foundation -public enum GesturePhase { +public enum _GesturePhase { case began(location: CGPoint) case changed(location: CGPoint) case ended(location: CGPoint) diff --git a/Sources/TokamakCore/Views/Gestures/GestureView.swift b/Sources/TokamakCore/Views/Gestures/GestureView.swift index f135659d9..51fc4e543 100644 --- a/Sources/TokamakCore/Views/Gestures/GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/GestureView.swift @@ -17,33 +17,31 @@ public struct GestureView: _PrimitiveView { - @Environment(\.isEnabled) var isEnabled - // TODO: Allow for array of gestures with priority - // TODO: Add AnyGesture, for type erease - // TODO: Add GestureReader? @State public var gesture: G public let content: Content - - public init(_ content: Content, gesture: G) { - self.content = content + + public init(gesture: G, content: Content) { self._gesture = State(wrappedValue: gesture) + self.content = content } } +// MARK: View Extension + extension View { /// Attaches a single gesture to the view. /// /// - Parameter gesture: The gesture to attach. /// - Returns: A modified version of the view with the gesture attached. public func gesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T: Gesture { - GestureView(self, gesture: gesture.body) + GestureView(gesture: gesture, content: self) } /// Attaches a gesture to the view to process simultaneously with gestures defined by the view. /// - Parameter gesture: The gesture to attach. /// - Returns: A modified version of the view with the gesture attached. public func simultaneousGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { - GestureView(self, gesture: gesture.body) + GestureView(gesture: gesture, content: self) } /// Attaches a gesture to the view with a higher precedence than gestures defined by the view. @@ -52,6 +50,6 @@ extension View { /// - mask: A value that controls how adding this gesture to the view affects other gestures recognized by the view and its subviews. Defaults to all. /// - Returns: A modified version of the view with the gesture attached. func highPriorityGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { - GestureView(self, gesture: gesture.body) + GestureView(gesture: gesture, content: self) } } diff --git a/Sources/TokamakCore/Views/View+DataChanges.swift b/Sources/TokamakCore/Views/View+DataChanges.swift new file mode 100644 index 000000000..5dfb5cf57 --- /dev/null +++ b/Sources/TokamakCore/Views/View+DataChanges.swift @@ -0,0 +1,101 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 6/8/2023. +// + +import Foundation +import OpenCombineShim + +extension View { + /// Adds an action to perform when this view detects data emitted by the given publisher. + /// - Parameters: + /// - publisher: The publisher to subscribe to. + /// - action: The action to perform when an event is emitted by publisher. The event emitted by publisher is passed as a parameter to action. + /// - Returns: A view that triggers action when publisher emits an event. + func onReceive

( + _ publisher: P, + perform action: @escaping (P.Output) -> Void + ) -> some View where P : Publisher, P.Failure == Never { + return self.modifier(OnReceiveModifier(publisher: publisher, action: action)) + } + + /// Adds a modifier for this view that fires an action when a specific value changes. + /// - Parameters: + /// - value: The value to check against when determining whether to run the closure. + /// - initial: Whether the action should be run when this view initially appears. + /// - action: A closure to run when the value changes. + /// - oldValue: The old value that failed the comparison check (or the initial value when requested). + /// - newValue: The new value that failed the comparison check. + /// - Returns: A view that fires an action when the specified value changes. + func onChange( + of value: V, + initial: Bool = false, + _ action: @escaping (V, V) -> Void + ) -> some View where V : Equatable { + return self.modifier(OnChangeModifier(value: value, initial: initial, action: action)) + } + + /// Adds a modifier for this view that fires an action when a specific value changes. + /// - Parameters: + /// - value: The value to check against when determining whether to run the closure. + /// - initial: Whether the action should be run when this view initially appears. + /// - action: A closure to run when the value changes. + /// - Returns: A view that fires an action when the specified value changes. + func onChange( + of value: V, + initial: Bool = false, + _ action: @escaping () -> Void + ) -> some View where V : Equatable { + return self.modifier(OnChangeModifier(value: value, initial: initial) { _, _ in action() }) + } +} + +private struct OnReceiveModifier: ViewModifier where P.Failure == Never { + let publisher: P + let action: (P.Output) -> Void + + func body(content: Content) -> some View { + content.onAppear() { + let _ = publisher.sink { value in + self.action(value) + } + } + } +} + +private struct OnChangeModifier: ViewModifier { + let value: V + let initial: Bool + let action: (V, V) -> Void + + init(value: V, initial: Bool, action: @escaping (V, V) -> Void) { + self.value = value + self.initial = initial + self.action = action + } + + func body(content: Content) -> some View { + content.onAppear() { + if initial { + action(value, value) + } + } + .onReceive(Just(value)) { newValue in + if newValue != value { + action(value, newValue) + } + } + } +} diff --git a/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift index d57e30268..156d8f2f8 100644 --- a/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift @@ -38,7 +38,7 @@ struct _DragGestureView: View { let y = event.y.jsValue.number else { return } let location = CGPoint(x: x, y: y) - gesture.phase = .began(location: location) + gesture._onPhaseChange(.began(location: location)) }, "pointerup": { event in guard @@ -46,10 +46,10 @@ struct _DragGestureView: View { let y = event.y.jsValue.number else { return } let location = CGPoint(x: x, y: y) - gesture.phase = .ended(location: location) + gesture._onPhaseChange(.ended(location: location)) }, "pointercancel": { _ in - gesture.phase = .cancelled + gesture._onPhaseChange(.cancelled) }, "pointermove": { event in guard @@ -57,7 +57,7 @@ struct _DragGestureView: View { let y = event.y.jsValue.number else { return } let point = CGPoint(x: x, y: y) - gesture.phase = .changed(location: point) + gesture._onPhaseChange(.changed(location: point)) }, ]) { content diff --git a/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift index 4294ab60b..0288583a5 100644 --- a/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift @@ -49,14 +49,14 @@ struct _LongPressGestureView: View { let y = event.y.jsValue.number else { return } let location = CGPoint(x: x, y: y) - gesture.phase = .began(location: location) + gesture._onPhaseChange(.began(location: location)) }, "pointerup": { _ in - gesture.phase = .ended(location: .zero) + gesture._onPhaseChange(.ended(location: .zero)) isPressing = false }, "pointercancel": { _ in - gesture.phase = .cancelled + gesture._onPhaseChange(.cancelled) isPressing = false }, "pointermove": { event in @@ -66,7 +66,7 @@ struct _LongPressGestureView: View { else { return } let point = CGPoint(x: x, y: y) location = point - gesture.phase = .changed(location: point) + gesture._onPhaseChange(.changed(location: point)) }, ]) { content @@ -79,7 +79,7 @@ struct _LongPressGestureView: View { try await Task.sleep(for: .seconds(minimumDuration)) if isPressing { await MainActor.run { - gesture.phase = .changed(location: location) + gesture._onPhaseChange(.changed(location: location)) } } } catch { diff --git a/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift index 5823a97cb..74182d3e2 100644 --- a/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift @@ -31,9 +31,9 @@ struct _TapGestureView: View { var body: some View { DynamicHTML("div", [:], listeners: [ - "pointerdown": { _ in gesture.phase = .began(location: .zero) }, - "pointerup": { _ in gesture.phase = .ended(location: .zero) }, - "pointercancel": { _ in gesture.phase = .cancelled }, + "pointerdown": { _ in gesture._onPhaseChange(.began(location: .zero)) }, + "pointerup": { _ in gesture._onPhaseChange(.ended(location: .zero)) }, + "pointercancel": { _ in gesture._onPhaseChange(.cancelled) }, ]) { content } diff --git a/Tests/TokamakTests/ViewReactToDataChangesTests.swift b/Tests/TokamakTests/ViewReactToDataChangesTests.swift new file mode 100644 index 000000000..77d49f2d8 --- /dev/null +++ b/Tests/TokamakTests/ViewReactToDataChangesTests.swift @@ -0,0 +1,107 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import TokamakCore +import XCTest +import OpenCombineShim + +class ViewModifierTests: XCTestCase { + func testOnReceive() { + let publisher = PassthroughSubject() + var receivedValue = "" + + let contentView = Text("Hello, world!") + .onReceive(publisher) { value in + receivedValue = value + } + + XCTAssertEqual(receivedValue, "") + + // Simulate publisher emitting a value + publisher.send("Testing onReceive") + + // Re-evaluate the view + _ = contentView.body + + XCTAssertEqual(receivedValue, "Testing onReceive") + } + + func testOnChangeWithValue() { + var count = 0 + var oldCount = 0 + + let contentView = Text("Count: \(count)") + .onChange(of: count) { newValue, newOldValue in + count = newValue + oldCount = newOldValue + } + + XCTAssertEqual(count, 0) + XCTAssertEqual(oldCount, 0) + + // Simulate a change in value + count = 5 + + // Re-evaluate the view + _ = contentView.body + + XCTAssertEqual(count, 5) + XCTAssertEqual(oldCount, 0) + } + + func testOnChangeWithoutValue() { + var count = 0 + var actionFired = false + + let contentView = Text("Hello, world!") + .onChange(of: count) { + actionFired = true + } + + XCTAssertFalse(actionFired) + + // Re-evaluate the view + _ = contentView.body + + XCTAssertTrue(actionFired) + } + + func testModifierComposition() { + let publisher = PassthroughSubject() + var receivedValue = 0 + var count = 0 + + let contentView = Text("Count: \(count)") + .onChange(of: count) { newValue, newOldValue in + count = newValue + } + .onReceive(publisher) { value in + receivedValue = value + } + + XCTAssertEqual(count, 0) + XCTAssertEqual(receivedValue, 0) + + // Simulate publisher emitting a value + publisher.send(10) + // Simulate a change in value + count = 5 + + // Re-evaluate the view + _ = contentView.body + + XCTAssertEqual(count, 5) + XCTAssertEqual(receivedValue, 10) + } +} From 1b52c51f5497a12ef362b67ede077cf72f38c14f Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Wed, 9 Aug 2023 19:34:39 +1000 Subject: [PATCH 06/33] quick fix --- Sources/TokamakCore/Views/Gestures/GestureView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/TokamakCore/Views/Gestures/GestureView.swift b/Sources/TokamakCore/Views/Gestures/GestureView.swift index 51fc4e543..3554105b2 100644 --- a/Sources/TokamakCore/Views/Gestures/GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/GestureView.swift @@ -34,14 +34,14 @@ extension View { /// - Parameter gesture: The gesture to attach. /// - Returns: A modified version of the view with the gesture attached. public func gesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T: Gesture { - GestureView(gesture: gesture, content: self) + GestureView(gesture: gesture.body, content: self) } /// Attaches a gesture to the view to process simultaneously with gestures defined by the view. /// - Parameter gesture: The gesture to attach. /// - Returns: A modified version of the view with the gesture attached. public func simultaneousGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { - GestureView(gesture: gesture, content: self) + GestureView(gesture: gesture.body, content: self) } /// Attaches a gesture to the view with a higher precedence than gestures defined by the view. @@ -50,6 +50,6 @@ extension View { /// - mask: A value that controls how adding this gesture to the view affects other gestures recognized by the view and its subviews. Defaults to all. /// - Returns: A modified version of the view with the gesture attached. func highPriorityGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { - GestureView(gesture: gesture, content: self) + GestureView(gesture: gesture.body, content: self) } } From 6ace747de8ef2ae12c3b12f48bf7bb685b23a13a Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Wed, 9 Aug 2023 19:48:46 +1000 Subject: [PATCH 07/33] Add missing onChangedAction for long press --- Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift index 81ba5e4bd..438a2731d 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -74,6 +74,7 @@ public struct LongPressGesture: Gesture { case .changed: break case .cancelled, .ended: + onChangedAction?(false) startLocation = nil } } From 779c6d327ea3f5746432f74253372c5df630b948 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Wed, 9 Aug 2023 19:59:10 +1000 Subject: [PATCH 08/33] remove prints --- Sources/TokamakDOM/Views/Gestures/GestureView.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift index 4b9217b5d..1d7aa437a 100644 --- a/Sources/TokamakDOM/Views/Gestures/GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/GestureView.swift @@ -30,12 +30,7 @@ extension TokamakCore.GestureView: DOMPrimitive { case is DragGesture.Type: return AnyView(_DragGestureView(gesture: $gesture, content: content)) default: - return AnyView( - content.onAppear { - print("🛑", G.Body.self) - print("🟡", gesture.self) - } - ) + return AnyView(content) } } } From 4ec2d79dd514e47f68fbeb5606197f1e816c3807 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 13 Aug 2023 20:35:59 +1000 Subject: [PATCH 09/33] gestures standard, simultaneous and highPriority handling --- Sources/TokamakCore/Gestures/AnyGesture.swift | 77 ++++++++++ Sources/TokamakCore/Gestures/Gesture.swift | 9 +- .../Gestures/GestureEnvironmentKey.swift | 82 +++++++++++ .../Performing/GestureStateGesture.swift | 4 +- .../Gestures/Performing/_ChangedGesture.swift | 4 +- .../Gestures/Performing/_EndedGesture.swift | 4 +- .../Gestures/Recognizers/DragGesture.swift | 10 +- .../Recognizers/LongPressGesture.swift | 23 ++- .../Gestures/Recognizers/TapGesture.swift | 5 +- .../TokamakCore/Gestures/_GesturePhase.swift | 2 +- .../Gestures/_GesturePriority.swift | 22 +++ .../Views/Gestures/GestureView.swift | 55 ------- .../Views/Gestures/_GestureView.swift | 134 ++++++++++++++++++ .../Views/Gestures/GestureView.swift | 36 +++-- .../Views/Gestures/_DragGestureView.swift | 67 --------- .../Gestures/_LongPressGestureView.swift | 91 ------------ .../Views/Gestures/_TapGestureView.swift | 41 ------ 17 files changed, 378 insertions(+), 288 deletions(-) create mode 100644 Sources/TokamakCore/Gestures/AnyGesture.swift create mode 100644 Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift create mode 100644 Sources/TokamakCore/Gestures/_GesturePriority.swift delete mode 100644 Sources/TokamakCore/Views/Gestures/GestureView.swift create mode 100644 Sources/TokamakCore/Views/Gestures/_GestureView.swift delete mode 100644 Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift delete mode 100644 Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift delete mode 100644 Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift diff --git a/Sources/TokamakCore/Gestures/AnyGesture.swift b/Sources/TokamakCore/Gestures/AnyGesture.swift new file mode 100644 index 000000000..bc039dbad --- /dev/null +++ b/Sources/TokamakCore/Gestures/AnyGesture.swift @@ -0,0 +1,77 @@ +// Copyright 2020-2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 11/8/2023. +// + +import Foundation + +/// A type-erased gesture. +public struct AnyGesture: Gesture { + typealias ActionClosure = (Value) -> Void + /// The type of the underlying `Gesture`. + let gestureType: Any.Type + /// The type of the `body` of the underlying `gesture`. Used to cast the result of the applied `bodyClosure` property. + let bodyType: Any.Type + /// The actual `Gesture` value wrapped within this `gesture`. + var gesture: Any + + let bodyClosure: (Any, ActionClosure?, ActionClosure?) -> any Gesture + var onEnded: ActionClosure? + var onChanged: ActionClosure? + + // `AnyGesture`, just to make it compile for now + public var body: AnyGesture { + fatalError("\(String(reflecting: Self.self)) should return underlaying gesture") + // TODO: Type 'any Gesture' cannot conform to 'Gesture' + // return bodyClosure(gesture, onChanged, onEnded).body + } + + public init(_ gesture: G) where Value == G.Value { + if let anyGesture = gesture as? AnyGesture { + self = anyGesture + } else { + self.gestureType = G.self + self.bodyType = G.Body.self + self.gesture = gesture + self.bodyClosure = { gesture, onChanged, onEnded in + // swiftlint:disable:next force_cast + var gesture = (gesture as! G) + if let onChanged { + gesture = gesture._onChanged(perform: onChanged) + } + if let onEnded { + gesture = gesture._onEnded(perform: onEnded) + } + return gesture + } + } + } + + mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + fatalError("\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called.") + } + + public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onEnded = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { + var gesture = self + gesture.onChanged = action + return gesture + } +} diff --git a/Sources/TokamakCore/Gestures/Gesture.swift b/Sources/TokamakCore/Gestures/Gesture.swift index 8ce3a04f7..4d2c4b2ac 100644 --- a/Sources/TokamakCore/Gestures/Gesture.swift +++ b/Sources/TokamakCore/Gestures/Gesture.swift @@ -29,9 +29,14 @@ public protocol Gesture { /// The content and behavior of the gesture. var body: Self.Body { get } - mutating func _onPhaseChange(_ phase: _GesturePhase) - func _onEnded(perform action: @escaping (Value) -> Void) -> Self + /// Adds an action to perform when the gesture’s phase changes. + /// - Parameter phase: Gesture new phase + /// - Returns: Returns `true` if the gesture is recognized, false otherwise. + mutating func _onPhaseChange(_ phase: _GesturePhase) -> Bool + /// Adds an action to perform when the gesture’s value changes. func _onChanged(perform action: @escaping (Value) -> Void) -> Self + /// Adds an action to perform when the gesture ends. + func _onEnded(perform action: @escaping (Value) -> Void) -> Self } // MARK: Performing the gesture diff --git a/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift new file mode 100644 index 000000000..47b1a36ae --- /dev/null +++ b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift @@ -0,0 +1,82 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 13/8/2023. +// + +private struct GestureEnvironmentKey: EnvironmentKey { + static let defaultValue: GestureEnvironmentValue = GestureEnvironmentValue() +} + +extension EnvironmentValues { + var gestureListener: GestureEnvironmentValue { + get { self[GestureEnvironmentKey.self] } + set { self[GestureEnvironmentKey.self] = newValue } + } +} + +class GestureEnvironmentValue { + var activeGestures: [String: Set] = [:] + + func registerStart(_ gesture: GestureValue, for event: String) { + if activeGestures[event] == nil { + activeGestures[event] = [gesture] + } else if case .highPriority = gesture.priority { + activeGestures[event] = [gesture] + } else { + activeGestures[event]?.insert(gesture) + } + } + + func recognizeGesture(_ gesture: GestureValue, for event: String) { + guard activeGestures[event]?.contains(gesture) == true else { + return + } + var gestures: Set = activeGestures[event]?.removeLowerPriorities(than: gesture.priority) ?? [] + gestures.insert(gesture) + activeGestures[event] = gestures + } + + func canProcessGesture(_ gesture: GestureValue, for event: String) -> Bool { + guard activeGestures[event]?.contains(gesture) == true else { + return false + } + return true + } +} + +struct GestureValue: Hashable { + let gestureId: String + let mask: GestureMask + let priority: _GesturePriority + + func hash(into hasher: inout Hasher) { + hasher.combine(gestureId) + } +} + +// MARK: Helpers + +extension Set where Element == GestureValue { + func removeLowerPriorities(than priority: _GesturePriority) -> Self { + return self.filter { + switch priority { + case .standard, .simultaneous: + return $0.priority != .standard + case .highPriority: + return $0.priority == .highPriority + } + } + } +} diff --git a/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift b/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift index 81563862e..13e0fb89f 100644 --- a/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift +++ b/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift @@ -42,7 +42,9 @@ public struct GestureStateGesture: Gesture { self.updatingBody = updatingBody } - public mutating func _onPhaseChange(_ phase: _GesturePhase) {} + mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + fatalError("\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called.") + } public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { var gesture = self diff --git a/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift b/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift index f8aba3d62..613704c56 100644 --- a/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift +++ b/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift @@ -35,7 +35,9 @@ public struct _ChangedGesture: Gesture { self.onChanged = onChanged } - public mutating func _onPhaseChange(_ phase: _GesturePhase) {} + mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + fatalError("\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called.") + } public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { var gesture = self diff --git a/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift b/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift index 30f27ea08..207b752ab 100644 --- a/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift +++ b/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift @@ -35,7 +35,9 @@ public struct _EndedGesture: Gesture { self.onEnded = onEnded } - public mutating func _onPhaseChange(_ phase: _GesturePhase) {} + mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + fatalError("\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called.") + } public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { var gesture = self diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 1e8c66721..51ac1f1aa 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -37,19 +37,19 @@ public struct DragGesture: Gesture { self.minimumDistance = minimumDistance } - public mutating func _onPhaseChange(_ phase: _GesturePhase) { + mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { case .began(let location): startLocation = location previousTimestamp = nil velocity = .zero case .changed(let location) where startLocation != nil: - guard let startLocation else { return } + guard let startLocation, let location else { return false } let translation = calculateTranslation(from: startLocation, to: location) let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) // Do nothing if gesture has not met the criteria - guard minimumDistance < distance else { return } + guard minimumDistance < distance else { return false } let currentTimestamp = Date() let timeElapsed = Double(currentTimestamp.timeIntervalSince(previousTimestamp ?? currentTimestamp)) let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) @@ -70,12 +70,12 @@ public struct DragGesture: Gesture { predictedEndTranslation: predictedEndTranslation ) ) + return true case .changed: break case .ended(let location): if let startLocation { let translation = calculateTranslation(from: startLocation, to: location) - let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) onEndedAction?( Value( startLocation: startLocation, @@ -87,9 +87,11 @@ public struct DragGesture: Gesture { ) } startLocation = nil + return true case .cancelled: startLocation = nil } + return false } public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift index 438a2731d..8b77b9eaf 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -19,9 +19,10 @@ import Foundation public struct LongPressGesture: Gesture { public typealias Value = Bool + private var startLocation: CGPoint? = nil private var touchStartTime = Date(timeIntervalSince1970: 0) - private var maximumDistance: Double = 0 + private var maximumDistance: Double private var onEndedAction: ((Value) -> Void)? = nil private var onChangedAction: ((Value) -> Void)? = nil public private(set) var minimumDuration: Double @@ -29,36 +30,31 @@ public struct LongPressGesture: Gesture { self } - /// Creates a long-press gesture with a minimum duration - /// - Parameter minimumDuration: The minimum duration of the long press that must elapse before the gesture succeeds. - public init(minimumDuration: Double = 0.5) { - self.minimumDuration = minimumDuration - } - /// Creates a long-press gesture with a minimum duration and a maximum distance that the interaction can move before the gesture fails. /// - Parameters: /// - minimumDuration: The minimum duration of the long press that must elapse before the gesture succeeds. /// - maximumDistance: The maximum distance that the long press can move before the gesture fails. - public init(minimumDuration: Double, maximumDistance: Double) { + public init(minimumDuration: Double = 0.5, maximumDistance: Double = 10) { self.minimumDuration = minimumDuration self.maximumDistance = maximumDistance } - public mutating func _onPhaseChange(_ phase: _GesturePhase) { + mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { case .began(let location): startLocation = location touchStartTime = Date() onChangedAction?(startLocation != nil) case .changed(let location) where startLocation != nil: - guard let startLocation else { return } - let translation = calculateTranslation(from: startLocation, to: location) + guard let startLocation else { return false } + let translation = calculateTranslation(from: startLocation, to: location ?? startLocation) let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) guard maximumDistance >= distance else { + print("Failed", distance, maximumDistance, startLocation, location ?? startLocation) // Fail longpress if distance is to big. self.startLocation = nil - return + return false } let touch = Date() @@ -70,6 +66,7 @@ public struct LongPressGesture: Gesture { // The LongPress gesture ends when the required duration is met. onEndedAction?(true) self.startLocation = nil + return true } case .changed: break @@ -77,6 +74,8 @@ public struct LongPressGesture: Gesture { onChangedAction?(false) startLocation = nil } + // The long press gesture is recognized only when both the maximum distance and minimum time conditions are met. + return false } public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { diff --git a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift index 8bd345a92..07df43fd4 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift @@ -28,7 +28,7 @@ public struct TapGesture: Gesture { private var phase: _GesturePhase = .cancelled private var onEndedAction: ((Value) -> Void)? = nil - public mutating func _onPhaseChange(_ phase: _GesturePhase) { + mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { case .cancelled: numberOfTapsSinceGestureBegan = 0 @@ -50,12 +50,15 @@ public struct TapGesture: Gesture { if count == numberOfTapsSinceGestureBegan { onEndedAction?(()) numberOfTapsSinceGestureBegan = 0 + return true } default: // TapGesture in SwiftUI have no change update nor events break } self.phase = phase + // Tap gesture is recognized on touch up + return false } public var body: TapGesture { diff --git a/Sources/TokamakCore/Gestures/_GesturePhase.swift b/Sources/TokamakCore/Gestures/_GesturePhase.swift index 53fac54db..0b31846bc 100644 --- a/Sources/TokamakCore/Gestures/_GesturePhase.swift +++ b/Sources/TokamakCore/Gestures/_GesturePhase.swift @@ -19,7 +19,7 @@ import Foundation public enum _GesturePhase { case began(location: CGPoint) - case changed(location: CGPoint) + case changed(location: CGPoint?) case ended(location: CGPoint) case cancelled } diff --git a/Sources/TokamakCore/Gestures/_GesturePriority.swift b/Sources/TokamakCore/Gestures/_GesturePriority.swift new file mode 100644 index 000000000..42c1dbb37 --- /dev/null +++ b/Sources/TokamakCore/Gestures/_GesturePriority.swift @@ -0,0 +1,22 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 13/8/2023. +// + +public enum _GesturePriority { + case standard + case simultaneous + case highPriority +} diff --git a/Sources/TokamakCore/Views/Gestures/GestureView.swift b/Sources/TokamakCore/Views/Gestures/GestureView.swift deleted file mode 100644 index 3554105b2..000000000 --- a/Sources/TokamakCore/Views/Gestures/GestureView.swift +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2020 Tokamak contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// Created by Szymon on 16/7/2023. -// - - -public struct GestureView: _PrimitiveView { - @State public var gesture: G - public let content: Content - - public init(gesture: G, content: Content) { - self._gesture = State(wrappedValue: gesture) - self.content = content - } -} - -// MARK: View Extension - -extension View { - /// Attaches a single gesture to the view. - /// - /// - Parameter gesture: The gesture to attach. - /// - Returns: A modified version of the view with the gesture attached. - public func gesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T: Gesture { - GestureView(gesture: gesture.body, content: self) - } - - /// Attaches a gesture to the view to process simultaneously with gestures defined by the view. - /// - Parameter gesture: The gesture to attach. - /// - Returns: A modified version of the view with the gesture attached. - public func simultaneousGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { - GestureView(gesture: gesture.body, content: self) - } - - /// Attaches a gesture to the view with a higher precedence than gestures defined by the view. - /// - Parameters: - /// - gesture: A gesture to attach to the view. - /// - mask: A value that controls how adding this gesture to the view affects other gestures recognized by the view and its subviews. Defaults to all. - /// - Returns: A modified version of the view with the gesture attached. - func highPriorityGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { - GestureView(gesture: gesture.body, content: self) - } -} diff --git a/Sources/TokamakCore/Views/Gestures/_GestureView.swift b/Sources/TokamakCore/Views/Gestures/_GestureView.swift new file mode 100644 index 000000000..7ef9f013e --- /dev/null +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -0,0 +1,134 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +import Foundation + +public struct _GestureView: _PrimitiveView { + @Environment(\.isEnabled) var isEnabled + @Environment(\.gestureListener) var gestureListener + @State public var gestureId: String = UUID().uuidString + @State public var gesture: G + @State var eventId: String? = nil + + let mask: GestureMask + let priority: _GesturePriority + public let content: Content + + var minimumDuration: Double? { + guard let longPressGesture = gesture as? LongPressGesture else { + return nil + } + return longPressGesture.minimumDuration + } + + public init( + gesture: G, + mask: GestureMask, + priority: _GesturePriority = .standard, + content: Content + ) { + self._gesture = State(wrappedValue: gesture) + self.mask = mask + self.priority = priority + self.content = content + } + + public func onPhaseChange(_ phase: _GesturePhase, eventId id: String? = nil) { + guard isEnabled, let currentEventId = eventId ?? id else { return } + + let value = GestureValue(gestureId: gestureId, mask: mask, priority: priority) + + switch phase { + case .began: + startDelay() + eventId = id + gestureListener.registerStart(value, for: currentEventId) + case .cancelled, .ended: + eventId = nil + default: + break + } + + guard gestureListener.canProcessGesture(value, for: currentEventId) else { + // Event being processed by another gestures + return + } + + if gesture._onPhaseChange(phase) { + gestureListener.recognizeGesture(value, for: currentEventId) + } + } + + private func startDelay() { + guard let minimumDuration else { return } + Task { + do { + try await Task.sleep(for: .seconds(minimumDuration)) + if let eventId { + await MainActor.run { + onPhaseChange(.changed(location: nil), eventId: eventId) + } + } + } catch { + //TODO: What do we do with this error? + print(error) + } + } + } +} + +// MARK: View Extension + +extension View { + /// Attaches a single gesture to the view. + /// + /// - Parameter gesture: The gesture to attach. + /// - Returns: A modified version of the view with the gesture attached. + public func gesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T: Gesture { + _GestureView( + gesture: gesture.body, + mask: mask, + content: self + ) + } + + /// Attaches a gesture to the view to process simultaneously with gestures defined by the view. + /// - Parameter gesture: The gesture to attach. + /// - Returns: A modified version of the view with the gesture attached. + public func simultaneousGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { + _GestureView( + gesture: gesture.body, + mask: mask, + priority: .simultaneous, + content: self + ) + } + + /// Attaches a gesture to the view with a higher precedence than gestures defined by the view. + /// - Parameters: + /// - gesture: A gesture to attach to the view. + /// - mask: A value that controls how adding this gesture to the view affects other gestures recognized by the view and its subviews. Defaults to all. + /// - Returns: A modified version of the view with the gesture attached. + public func highPriorityGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { + _GestureView( + gesture: gesture.body, + mask: mask, + priority: .highPriority, + content: self + ) + } +} diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift index 1d7aa437a..ed432e2d9 100644 --- a/Sources/TokamakDOM/Views/Gestures/GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/GestureView.swift @@ -20,17 +20,31 @@ import TokamakCore import TokamakStaticHTML import Foundation -extension TokamakCore.GestureView: DOMPrimitive { +extension TokamakCore._GestureView: DOMPrimitive { var renderedBody: AnyView { - switch G.Body.self { - case is TapGesture.Type: - return AnyView(_TapGestureView(gesture: $gesture, content: content)) - case is LongPressGesture.Type: - return AnyView(_LongPressGestureView(gesture: $gesture, content: content)) - case is DragGesture.Type: - return AnyView(_DragGestureView(gesture: $gesture, content: content)) - default: - return AnyView(content) - } + AnyView( + DynamicHTML("div", ["id": gestureId], listeners: [ + "pointerdown": { event in + guard let target = event.target.object, + let x = event.x.jsValue.number, + let y = event.y.jsValue.number else { return } + onPhaseChange(.began(location: CGPoint(x: x, y: y)), eventId: String(describing: target.hashValue)) + }, + "pointermove": { event in + guard let x = event.x.jsValue.number, let y = event.y.jsValue.number else { return } + let point = CGPoint(x: x, y: y) + onPhaseChange(.changed(location: point)) + }, + "pointerup": { event in + guard let x = event.x.jsValue.number, let y = event.y.jsValue.number else { return } + onPhaseChange(.ended(location: CGPoint(x: x, y: y))) + }, + "pointercancel": { event in + onPhaseChange(.cancelled) + } + ]) { + content + } + ) } } diff --git a/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift deleted file mode 100644 index 156d8f2f8..000000000 --- a/Sources/TokamakDOM/Views/Gestures/_DragGestureView.swift +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2020 Tokamak contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// Created by Szymon on 30/7/2023. -// - -import JavaScriptKit -import TokamakCore -import TokamakStaticHTML -import Foundation - -struct _DragGestureView: View { - @Binding var gesture: G - - let content: Content - - init(gesture: Binding, content: Content) { - self._gesture = gesture - self.content = content - } - - var body: some View { - DynamicHTML("div", [:], listeners: [ - "pointerdown": { event in - guard - let x = event.x.jsValue.number, - let y = event.y.jsValue.number - else { return } - let location = CGPoint(x: x, y: y) - gesture._onPhaseChange(.began(location: location)) - }, - "pointerup": { event in - guard - let x = event.x.jsValue.number, - let y = event.y.jsValue.number - else { return } - let location = CGPoint(x: x, y: y) - gesture._onPhaseChange(.ended(location: location)) - }, - "pointercancel": { _ in - gesture._onPhaseChange(.cancelled) - }, - "pointermove": { event in - guard - let x = event.x.jsValue.number, - let y = event.y.jsValue.number - else { return } - let point = CGPoint(x: x, y: y) - gesture._onPhaseChange(.changed(location: point)) - }, - ]) { - content - } - } -} - diff --git a/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift deleted file mode 100644 index 0288583a5..000000000 --- a/Sources/TokamakDOM/Views/Gestures/_LongPressGestureView.swift +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright 2020 Tokamak contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// Created by Szymon on 30/7/2023. -// - -import JavaScriptKit -import TokamakCore -import TokamakStaticHTML -import Foundation - -struct _LongPressGestureView: View { - @State var isPressing = false - @State var location: CGPoint = .zero - @Binding var gesture: G - - var minimumDuration: Double { - if let longPressGesture = gesture as? LongPressGesture { - return longPressGesture.minimumDuration - } - return 0.5 - } - - let content: Content - - init(gesture: Binding, content: Content) { - self._gesture = gesture - self.content = content - } - - var body: some View { - DynamicHTML("div", [:], listeners: [ - "pointerdown": { event in - startDelay() - isPressing = true - guard - let x = event.x.jsValue.number, - let y = event.y.jsValue.number - else { return } - let location = CGPoint(x: x, y: y) - gesture._onPhaseChange(.began(location: location)) - }, - "pointerup": { _ in - gesture._onPhaseChange(.ended(location: .zero)) - isPressing = false - }, - "pointercancel": { _ in - gesture._onPhaseChange(.cancelled) - isPressing = false - }, - "pointermove": { event in - guard - let x = event.x.jsValue.number, - let y = event.y.jsValue.number - else { return } - let point = CGPoint(x: x, y: y) - location = point - gesture._onPhaseChange(.changed(location: point)) - }, - ]) { - content - } - } - - func startDelay() { - Task { - do { - try await Task.sleep(for: .seconds(minimumDuration)) - if isPressing { - await MainActor.run { - gesture._onPhaseChange(.changed(location: location)) - } - } - } catch { - //TODO: What do we do with this error? - print(error) - } - } - } -} diff --git a/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift b/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift deleted file mode 100644 index 74182d3e2..000000000 --- a/Sources/TokamakDOM/Views/Gestures/_TapGestureView.swift +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2020 Tokamak contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// Created by Szymon on 30/7/2023. -// - -import JavaScriptKit -import TokamakCore -import TokamakStaticHTML - -struct _TapGestureView: View { - @Binding var gesture: G - - let content: Content - - init(gesture: Binding, content: Content) { - self._gesture = gesture - self.content = content - } - - var body: some View { - DynamicHTML("div", [:], listeners: [ - "pointerdown": { _ in gesture._onPhaseChange(.began(location: .zero)) }, - "pointerup": { _ in gesture._onPhaseChange(.ended(location: .zero)) }, - "pointercancel": { _ in gesture._onPhaseChange(.cancelled) }, - ]) { - content - } - } -} From 72269c145ba86fa6eb21897c4cda62c03f82b704 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Tue, 15 Aug 2023 18:23:08 +1000 Subject: [PATCH 10/33] Fix minor tap gesture issue --- .../Gestures/Recognizers/TapGesture.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift index 07df43fd4..75708844a 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift @@ -28,12 +28,21 @@ public struct TapGesture: Gesture { private var phase: _GesturePhase = .cancelled private var onEndedAction: ((Value) -> Void)? = nil + private var isActive: Bool { + switch phase { + case .began, .changed: + return true + default: + return false + } + } + mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { case .cancelled: numberOfTapsSinceGestureBegan = 0 case .ended: - if case .began = self.phase { + if isActive { let touch = Date() let delayInSeconds = touch.timeIntervalSince(touchEndTime) touchEndTime = touch @@ -47,7 +56,7 @@ public struct TapGesture: Gesture { } // If we ended touch and have desired count we complete gesture - if count == numberOfTapsSinceGestureBegan { + if numberOfTapsSinceGestureBegan >= count { onEndedAction?(()) numberOfTapsSinceGestureBegan = 0 return true From 578150006a886071597afa3386e633fcc8eabd45 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Tue, 15 Aug 2023 20:47:28 +1000 Subject: [PATCH 11/33] Global listeners to track continuous pointer movement/up events --- .../Views/Gestures/GestureView.swift | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift index ed432e2d9..25866c4aa 100644 --- a/Sources/TokamakDOM/Views/Gestures/GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/GestureView.swift @@ -30,21 +30,38 @@ extension TokamakCore._GestureView: DOMPrimitive { let y = event.y.jsValue.number else { return } onPhaseChange(.began(location: CGPoint(x: x, y: y)), eventId: String(describing: target.hashValue)) }, - "pointermove": { event in - guard let x = event.x.jsValue.number, let y = event.y.jsValue.number else { return } - let point = CGPoint(x: x, y: y) - onPhaseChange(.changed(location: point)) - }, - "pointerup": { event in - guard let x = event.x.jsValue.number, let y = event.y.jsValue.number else { return } - onPhaseChange(.ended(location: CGPoint(x: x, y: y))) - }, "pointercancel": { event in onPhaseChange(.cancelled) } ]) { - content + content.onAppear { + setupPointerListener() + } } ) } + + private func setupPointerListener() { + /// Global listeners to track continuous pointer movement, providing real-time position updates even outside the initial target bounds. + let pointermoveClosure = JSClosure { args -> JSValue in + if let event = args[0].object, + let x = event.x.jsValue.number, + let y = event.y.jsValue.number { + onPhaseChange(.changed(location: CGPoint(x: x, y: y))) + } + return .undefined + } + _ = JSObject.global.window.object?.addEventListener?("pointermove", pointermoveClosure) + + /// Global listeners to track continuous pointer up, providing real-time updates even outside the initial target bounds. + let pointerupClosure = JSClosure { args -> JSValue in + if let event = args[0].object, + let x = event.x.jsValue.number, + let y = event.y.jsValue.number { + onPhaseChange(.ended(location: CGPoint(x: x, y: y))) + } + return .undefined + } + _ = JSObject.global.window.object?.addEventListener?("pointerup", pointerupClosure) + } } From b73ae7473fa9c5d7d9ef657a90df30f3239507ea Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sat, 19 Aug 2023 22:51:23 +1000 Subject: [PATCH 12/33] Squashed commit of the following: 1. **Gesture Coordinate Space**: A new gesture coordinate space has been added to provide better support for gesture-based interactions. 2. **Geometry Reader Update**: The geometry reader has been updated to offer improved functionality and performance. 3. **Geometry Proxy Preparation**: A geometry proxy has been prepared to streamline and optimize geometric operations. 4. **Coordinate Space Enhancement**: The coordinate space has been enhanced to support new use cases and scenarios. --- .../CoordinateSpace/CoordinateSpace.swift | 102 ++++++++++++++++ .../CoordinateSpaceEnviroment.swift | 19 +++ .../Gestures/GestureEnvironmentKey.swift | 2 +- .../Gestures/Recognizers/DragGesture.swift | 44 +++++-- .../Recognizers/LongPressGesture.swift | 2 +- .../Gestures/View+HitTesting.swift | 33 +++++ .../TokamakCore/Gestures/_GesturePhase.swift | 2 +- .../Shapes/ContainerRelativeShape.swift | 2 +- Sources/TokamakCore/State/State.swift | 10 ++ .../Views/Gestures/_GestureView.swift | 53 ++++---- .../Views/Layout/GeometryReader.swift | 41 +++++-- .../TokamakCore/Views/View+DataChanges.swift | 54 ++++---- Sources/TokamakDOM/Core.swift | 2 + .../Views/Gestures/GestureView.swift | 17 ++- .../Views/Layout/GeometryReader.swift | 115 ++++++++++-------- 15 files changed, 371 insertions(+), 127 deletions(-) create mode 100644 Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift create mode 100644 Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift create mode 100644 Sources/TokamakCore/Gestures/View+HitTesting.swift diff --git a/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift b/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift new file mode 100644 index 000000000..4bea950a5 --- /dev/null +++ b/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift @@ -0,0 +1,102 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 18/8/2023. +// + +import Foundation + +public enum CoordinateSpace { + case global + case local + case named(AnyHashable) +} + +extension CoordinateSpace: Equatable, Hashable { + // Equatable and Hashable conformance +} + +extension CoordinateSpace { + public var isGlobal: Bool { + switch self { + case .global: + return true + default: + return false + } + } + + public var isLocal: Bool { + switch self { + case .local: + return true + default: + return false + } + } +} + +extension CoordinateSpace { + static func convertGlobalSpaceCoordinates(rect: CGRect, toNamedOrigin namedOrigin: CGPoint) -> CGRect { + let translatedOrigin = convert(rect.origin, toNamedOrigin: namedOrigin) + return CGRect(origin: translatedOrigin, size: rect.size) + } + + static func convert(_ point: CGPoint, toNamedOrigin namedOrigin: CGPoint) -> CGPoint { + return CGPoint(x: point.x - namedOrigin.x, y: point.y - namedOrigin.y) + } +} + + +extension View { + /// Assigns a name to the view’s coordinate space, so other code can operate on dimensions like points and sizes relative to the named space. + /// - Parameter name: A name used to identify this coordinate space. + public func coordinateSpace(name: T) -> some View where T : Hashable { + self.modifier(_CoordinateSpaceModifier(name: name)) + } +} + +private struct CoordinateSpaceEnvironmentKey: EnvironmentKey { + static let defaultValue: CoordinateSpaceEnvironmentValue = CoordinateSpaceEnvironmentValue() +} + +extension EnvironmentValues { + var _coordinateSpace: CoordinateSpaceEnvironmentValue { + get { self[CoordinateSpaceEnvironmentKey.self] } + set { self[CoordinateSpaceEnvironmentKey.self] = newValue } + } +} + +class CoordinateSpaceEnvironmentValue { + var activeCoordinateSpace: [CoordinateSpace: CGRect] = [:] +} + +struct _CoordinateSpaceModifier: ViewModifier { + @Environment(\._coordinateSpace) var coordinateSpace + let name: T + + public func body(content: Content) -> some View { + content.background { + GeometryReader { proxy in + Color.clear + .onChange(of: proxy.size, initial: true) { + coordinateSpace.activeCoordinateSpace[.named(name)] = proxy.frame(in: .global) + } + .onDisappear { + coordinateSpace.activeCoordinateSpace.removeValue(forKey: .named(name)) + } + } + } + } +} diff --git a/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift b/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift new file mode 100644 index 000000000..15e21eb0f --- /dev/null +++ b/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift @@ -0,0 +1,19 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 19/8/2023. +// + +import Foundation + diff --git a/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift index 47b1a36ae..3d57599be 100644 --- a/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift +++ b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift @@ -20,7 +20,7 @@ private struct GestureEnvironmentKey: EnvironmentKey { } extension EnvironmentValues { - var gestureListener: GestureEnvironmentValue { + var _gestureListener: GestureEnvironmentValue { get { self[GestureEnvironmentKey.self] } set { self[GestureEnvironmentKey.self] = newValue } } diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 51ac1f1aa..47f2cd60d 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -18,12 +18,16 @@ import Foundation public struct DragGesture: Gesture { + @Environment(\._coordinateSpace) private var coordinates + private var globalOrigin: CGPoint? = nil private var startLocation: CGPoint? = nil private var previousTimestamp: Date? private var velocity: CGSize = .zero private var onEndedAction: ((Value) -> Void)? = nil private var onChangedAction: ((Value) -> Void)? = nil - public var minimumDistance: Double + private var minimumDistance: Double + private var coordinateSpace: CoordinateSpace + public var body: DragGesture { self } @@ -33,13 +37,18 @@ public struct DragGesture: Gesture { /// - Parameters: /// - minimumDistance: The minimum dragging distance before the gesture succeeds. /// - coordinateSpace: The coordinate space in which to receive location values. - public init(minimumDistance: Double = 10) { + public init( + minimumDistance: CGFloat = 10, + coordinateSpace: CoordinateSpace = .local + ) { self.minimumDistance = minimumDistance + self.coordinateSpace = coordinateSpace } mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { - case .began(let location): + case .began(let origin, let location): + globalOrigin = origin startLocation = location previousTimestamp = nil velocity = .zero @@ -63,9 +72,9 @@ public struct DragGesture: Gesture { onChangedAction?( Value( - startLocation: startLocation, - location: location, - predictedEndLocation: predictedEndLocation, + startLocation: converLocation(startLocation), + location: converLocation(location), + predictedEndLocation: converLocation(predictedEndLocation), translation: translation, predictedEndTranslation: predictedEndTranslation ) @@ -78,9 +87,9 @@ public struct DragGesture: Gesture { let translation = calculateTranslation(from: startLocation, to: location) onEndedAction?( Value( - startLocation: startLocation, - location: location, - predictedEndLocation: location, + startLocation: converLocation(startLocation), + location: converLocation(location), + predictedEndLocation: converLocation(location), translation: translation, predictedEndTranslation: translation ) @@ -106,6 +115,23 @@ public struct DragGesture: Gesture { return gesture } + private func converLocation(_ location: CGPoint) -> CGPoint { + switch coordinateSpace { + case .global: + return location + case .local: + if let origin = globalOrigin { + return CoordinateSpace.convert(location, toNamedOrigin: origin) + } + return location + case .named(let name): + if let rect = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { + return CoordinateSpace.convert(location, toNamedOrigin: rect.origin) + } + return location + } + } + // MARK: Types public struct Value: Equatable { diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift index 8b77b9eaf..4016cb4af 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -41,7 +41,7 @@ public struct LongPressGesture: Gesture { mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { - case .began(let location): + case .began(_, let location): startLocation = location touchStartTime = Date() onChangedAction?(startLocation != nil) diff --git a/Sources/TokamakCore/Gestures/View+HitTesting.swift b/Sources/TokamakCore/Gestures/View+HitTesting.swift new file mode 100644 index 000000000..f86035216 --- /dev/null +++ b/Sources/TokamakCore/Gestures/View+HitTesting.swift @@ -0,0 +1,33 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 18/8/2023. +// + +import Foundation + +extension View { + /// Defines the content shape for hit testing. + /// - Parameters: + /// - shape: The hit testing shape for the view. + /// - eoFill: A Boolean that indicates whether the shape is interpreted with the even-odd winding number rule. + /// - Returns: A view that uses the given shape for hit testing. + @inlinable public func contentShape( + _ shape: S, + eoFill: Bool = false + ) -> some View { + // TODO: Add content shape modifier. Verify gesture start against the shape fill area. + self + } +} diff --git a/Sources/TokamakCore/Gestures/_GesturePhase.swift b/Sources/TokamakCore/Gestures/_GesturePhase.swift index 0b31846bc..55491426f 100644 --- a/Sources/TokamakCore/Gestures/_GesturePhase.swift +++ b/Sources/TokamakCore/Gestures/_GesturePhase.swift @@ -18,7 +18,7 @@ import Foundation public enum _GesturePhase { - case began(location: CGPoint) + case began(boundsOrigin: CGPoint, location: CGPoint) case changed(location: CGPoint?) case ended(location: CGPoint) case cancelled diff --git a/Sources/TokamakCore/Shapes/ContainerRelativeShape.swift b/Sources/TokamakCore/Shapes/ContainerRelativeShape.swift index 8d851b0ae..f894b770f 100644 --- a/Sources/TokamakCore/Shapes/ContainerRelativeShape.swift +++ b/Sources/TokamakCore/Shapes/ContainerRelativeShape.swift @@ -21,7 +21,7 @@ public struct ContainerRelativeShape: Shape, EnvironmentReader { var containerShape: (CGRect, GeometryProxy) -> Path? = { _, _ in nil } public func path(in rect: CGRect) -> Path { - containerShape(rect, GeometryProxy(size: rect.size)) ?? Rectangle().path(in: rect) + containerShape(rect, GeometryProxy(globalRect: rect)) ?? Rectangle().path(in: rect) } public init() {} diff --git a/Sources/TokamakCore/State/State.swift b/Sources/TokamakCore/State/State.swift index ea033cf9b..4fd2f2e3c 100644 --- a/Sources/TokamakCore/State/State.swift +++ b/Sources/TokamakCore/State/State.swift @@ -32,9 +32,19 @@ public struct State: DynamicProperty { var getter: (() -> Any)? var setter: ((Any, Transaction) -> ())? + /// Creates a state property that stores an initial value. + /// - Parameter value: An initial value to store in the state property. + /// - Discussion: You don’t call this initializer directly. Instead, Tokamak calls it for you when you declare a property with the @State attribute and provide an initial value: public init(wrappedValue value: Value) { initialValue = value } + + /// Creates a state property that stores an initial value. + /// - Parameter value: An initial value to store in the state property. + /// - Discussion: This initializer has the same behavior as the init(wrappedValue:) initializer. See that initializer for more information. + public init(initialValue value: Value) { + initialValue = value + } public var wrappedValue: Value { get { getter?() as? Value ?? initialValue } diff --git a/Sources/TokamakCore/Views/Gestures/_GestureView.swift b/Sources/TokamakCore/Views/Gestures/_GestureView.swift index 7ef9f013e..a3b1c19fb 100644 --- a/Sources/TokamakCore/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -19,7 +19,7 @@ import Foundation public struct _GestureView: _PrimitiveView { @Environment(\.isEnabled) var isEnabled - @Environment(\.gestureListener) var gestureListener + @Environment(\._gestureListener) var gestureListener @State public var gestureId: String = UUID().uuidString @State public var gesture: G @State var eventId: String? = nil @@ -98,24 +98,30 @@ extension View { /// /// - Parameter gesture: The gesture to attach. /// - Returns: A modified version of the view with the gesture attached. - public func gesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T: Gesture { - _GestureView( - gesture: gesture.body, - mask: mask, - content: self - ) + @ViewBuilder + public func gesture(_ gesture: T?, including mask: GestureMask = .all) -> some View where T: Gesture { + if let gesture { + _GestureView(gesture: gesture.body, mask: mask, content: self) + } else { + self + } } /// Attaches a gesture to the view to process simultaneously with gestures defined by the view. /// - Parameter gesture: The gesture to attach. /// - Returns: A modified version of the view with the gesture attached. - public func simultaneousGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { - _GestureView( - gesture: gesture.body, - mask: mask, - priority: .simultaneous, - content: self - ) + @ViewBuilder + public func simultaneousGesture(_ gesture: T?, including mask: GestureMask = .all) -> some View where T : Gesture { + if let gesture { + _GestureView( + gesture: gesture.body, + mask: mask, + priority: .simultaneous, + content: self + ) + } else { + self + } } /// Attaches a gesture to the view with a higher precedence than gestures defined by the view. @@ -123,12 +129,17 @@ extension View { /// - gesture: A gesture to attach to the view. /// - mask: A value that controls how adding this gesture to the view affects other gestures recognized by the view and its subviews. Defaults to all. /// - Returns: A modified version of the view with the gesture attached. - public func highPriorityGesture(_ gesture: T, including mask: GestureMask = .all) -> some View where T : Gesture { - _GestureView( - gesture: gesture.body, - mask: mask, - priority: .highPriority, - content: self - ) + @ViewBuilder + public func highPriorityGesture(_ gesture: T?, including mask: GestureMask = .all) -> some View where T : Gesture { + if let gesture { + _GestureView( + gesture: gesture.body, + mask: mask, + priority: .highPriority, + content: self + ) + } else { + self + } } } diff --git a/Sources/TokamakCore/Views/Layout/GeometryReader.swift b/Sources/TokamakCore/Views/Layout/GeometryReader.swift index 35091bf38..ae7117eb0 100644 --- a/Sources/TokamakCore/Views/Layout/GeometryReader.swift +++ b/Sources/TokamakCore/Views/Layout/GeometryReader.swift @@ -15,20 +15,40 @@ import Foundation public struct GeometryProxy { - public let size: CGSize + @Environment(\._coordinateSpace) var coordinates + let globalRect: CGRect + + public var size: CGSize { + globalRect.size + } + + public init(globalRect: CGRect) { + self.globalRect = globalRect + } + + public func frame(in coordinateSpace: CoordinateSpace) -> CGRect { + switch coordinateSpace { + case .global: + return globalRect + case .local: + return CGRect(origin: .zero, size: size) + case .named(let name): + if let rect = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { + return CoordinateSpace.convertGlobalSpaceCoordinates( + rect: globalRect, + toNamedOrigin: rect.origin + ) + } + // Return local if no space with given name + return CGRect(origin: .zero, size: size) + } + } } -public func makeProxy(from size: CGSize) -> GeometryProxy { - .init(size: size) +public func makeProxy(from rect: CGRect) -> GeometryProxy { + .init(globalRect: rect) } -// FIXME: to be implemented -// public enum CoordinateSpace { -// case global -// case local -// case named(AnyHashable) -// } - // public struct Anchor { // let box: AnchorValueBoxBase // public struct Source { @@ -38,7 +58,6 @@ public func makeProxy(from size: CGSize) -> GeometryProxy { // extension GeometryProxy { // public let safeAreaInsets: EdgeInsets -// public func frame(in coordinateSpace: CoordinateSpace) -> CGRect // public subscript(anchor: Anchor) -> T {} // } diff --git a/Sources/TokamakCore/Views/View+DataChanges.swift b/Sources/TokamakCore/Views/View+DataChanges.swift index 5dfb5cf57..95b2dc642 100644 --- a/Sources/TokamakCore/Views/View+DataChanges.swift +++ b/Sources/TokamakCore/Views/View+DataChanges.swift @@ -24,7 +24,7 @@ extension View { /// - publisher: The publisher to subscribe to. /// - action: The action to perform when an event is emitted by publisher. The event emitted by publisher is passed as a parameter to action. /// - Returns: A view that triggers action when publisher emits an event. - func onReceive

( + public func onReceive

( _ publisher: P, perform action: @escaping (P.Output) -> Void ) -> some View where P : Publisher, P.Failure == Never { @@ -39,7 +39,7 @@ extension View { /// - oldValue: The old value that failed the comparison check (or the initial value when requested). /// - newValue: The new value that failed the comparison check. /// - Returns: A view that fires an action when the specified value changes. - func onChange( + public func onChange( of value: V, initial: Bool = false, _ action: @escaping (V, V) -> Void @@ -53,7 +53,7 @@ extension View { /// - initial: Whether the action should be run when this view initially appears. /// - action: A closure to run when the value changes. /// - Returns: A view that fires an action when the specified value changes. - func onChange( + public func onChange( of value: V, initial: Bool = false, _ action: @escaping () -> Void @@ -62,40 +62,44 @@ extension View { } } -private struct OnReceiveModifier: ViewModifier where P.Failure == Never { +struct OnReceiveModifier: ViewModifier where P.Failure == Never { + @State var cancellable: AnyCancellable? = nil + let publisher: P let action: (P.Output) -> Void - + func body(content: Content) -> some View { - content.onAppear() { - let _ = publisher.sink { value in - self.action(value) + content + .onAppear { + cancellable = publisher.sink(receiveValue: action) + } + .onDisappear { + cancellable?.cancel() + cancellable = nil } - } } } -private struct OnChangeModifier: ViewModifier { +struct OnChangeModifier: ViewModifier { + @State var oldValue: V? = nil + let value: V let initial: Bool let action: (V, V) -> Void - - init(value: V, initial: Bool, action: @escaping (V, V) -> Void) { - self.value = value - self.initial = initial - self.action = action - } - + func body(content: Content) -> some View { - content.onAppear() { - if initial { - action(value, value) + content + .onAppear { + if self.initial { + action(value, value) + } + oldValue = value } - } - .onReceive(Just(value)) { newValue in - if newValue != value { - action(value, newValue) + ._onUpdate { + if value != oldValue { + action(oldValue ?? value, value) + } + oldValue = value } - } } } diff --git a/Sources/TokamakDOM/Core.swift b/Sources/TokamakDOM/Core.swift index 998393901..63b7d07c3 100644 --- a/Sources/TokamakDOM/Core.swift +++ b/Sources/TokamakDOM/Core.swift @@ -27,6 +27,7 @@ public typealias EnvironmentObject = TokamakCore.EnvironmentObject public typealias EnvironmentValues = TokamakCore.EnvironmentValues public typealias PreferenceKey = TokamakCore.PreferenceKey +public typealias CoordinateSpace = TokamakCore.CoordinateSpace public typealias Binding = TokamakCore.Binding public typealias ObservableObject = TokamakCore.ObservableObject @@ -178,6 +179,7 @@ public typealias ZStack = TokamakCore.ZStack // MARK: Gestures +public typealias Gesture = TokamakCore.Gesture public typealias GestureMask = TokamakCore.GestureMask public typealias GestureState = TokamakCore.GestureState public typealias TapGesture = TokamakCore.TapGesture diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift index 25866c4aa..fff73e987 100644 --- a/Sources/TokamakDOM/Views/Gestures/GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/GestureView.swift @@ -23,12 +23,23 @@ import Foundation extension TokamakCore._GestureView: DOMPrimitive { var renderedBody: AnyView { AnyView( - DynamicHTML("div", ["id": gestureId], listeners: [ + DynamicHTML("div", [ + "id": gestureId, + ], listeners: [ "pointerdown": { event in guard let target = event.target.object, let x = event.x.jsValue.number, - let y = event.y.jsValue.number else { return } - onPhaseChange(.began(location: CGPoint(x: x, y: y)), eventId: String(describing: target.hashValue)) + let y = event.y.jsValue.number, + let rect = target.getBoundingClientRect?(), + let originX = rect.x.number, + let originY = rect.y.number else { return } + onPhaseChange( + .began( + boundsOrigin: CGPoint(x: originX, y: originY), + location: CGPoint(x: x, y: y) + ), + eventId: String(describing: target.hashValue) + ) }, "pointercancel": { event in onPhaseChange(.cancelled) diff --git a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift index 68eb0e281..b3574751c 100644 --- a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift +++ b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift @@ -20,63 +20,70 @@ import TokamakStaticHTML private let ResizeObserver = JSObject.global.ResizeObserver.function! extension GeometryReader: DOMPrimitive { - var renderedBody: AnyView { - AnyView(_GeometryReader(content: content)) - } + var renderedBody: AnyView { + AnyView(_GeometryReader(content: content)) + } } struct _GeometryReader: View { - final class State: ObservableObject { - /** Holds a strong reference to a `JSClosure` instance that has to stay alive as long as - the `_GeometryReader` owner is alive. - */ - var closure: JSClosure? - - /// A reference to a DOM node being observed for size updates. - var observedNodeRef: JSObject? - - /// A reference to a `ResizeObserver` instance. - var observerRef: JSObject? - - /// The last known size of the `observedNodeRef` DOM node. - @Published - var size: CGSize? - } - - let content: (GeometryProxy) -> Content - - @StateObject - private var state = State() - - var body: some View { - HTML("div", ["class": "_tokamak-geometryreader"]) { - if let size = state.size { - content(makeProxy(from: size)) - } else { - EmptyView() - } + final class State: ObservableObject { + /** Holds a strong reference to a `JSClosure` instance that has to stay alive as long as + the `_GeometryReader` owner is alive. + */ + var closure: JSClosure? + + /// A reference to a DOM node being observed for size updates. + var observedNodeRef: JSObject? + + /// A reference to a `ResizeObserver` instance. + var observerRef: JSObject? + + /// The last known size of the `observedNodeRef` DOM node. + @Published + var rect: CGRect? } - ._domRef($state.observedNodeRef) - ._onMount { - let closure = JSClosure { [weak state] args -> JSValue in - // FIXME: `JSArrayRef` is not a `RandomAccessCollection` for some reason, which forces - // us to use a string subscript - guard - let rect = args[0].object?[dynamicMember: "0"].object?.contentRect.object, - let width = rect.width.number, - let height = rect.height.number - else { return .undefined } - - state?.size = .init(width: width, height: height) - return .undefined - } - state.closure = closure - - let observerRef = ResizeObserver.new(closure) - - _ = observerRef.observe!(state.observedNodeRef!) - - state.observerRef = observerRef + + let content: (GeometryProxy) -> Content + + @StateObject + private var state = State() + + var body: some View { + HTML("div", ["class": "_tokamak-geometryreader"]) { + if let rect = state.rect { + content(makeProxy(from: rect)) + } else { + EmptyView() + } + } + ._domRef($state.observedNodeRef) + ._onMount { + let closure = JSClosure { [weak state] args -> JSValue in + // FIXME: `JSArrayRef` is not a `RandomAccessCollection` for some reason, which forces + // us to use a string subscript + guard + let target = args[0].object?[dynamicMember: "0"].object?.target.object, + let rect = target.getBoundingClientRect?(), + let x = rect.x.number, + let y = rect.y.number, + let width = rect.width.number, + let height = rect.height.number + else { return .undefined } + + state?.rect = CGRect( + origin: CGPoint(x: x, y: y), + size: CGSize(width: width, height: height) + ) + + return .undefined + } + state.closure = closure + + let observerRef = ResizeObserver.new(closure) + + _ = observerRef.observe!(state.observedNodeRef!) + + state.observerRef = observerRef + } } - } } From eba439ff8e00fdb9b04953e70dd0e2ca5adeab6e Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 20 Aug 2023 09:12:06 +1000 Subject: [PATCH 13/33] Address PR feedback --- Sources/TokamakCore/Gestures/AnyGesture.swift | 77 ------------------ .../Gestures/GestureEnvironmentKey.swift | 41 ++++++++-- .../TokamakCore/Gestures/GestureMask.swift | 16 ---- .../OnChangeModifier.swift} | 81 +++++++------------ .../Modifiers/OnReceiveModifier.swift | 51 ++++++++++++ .../Views/Gestures/_GestureView.swift | 7 +- .../ViewReactToDataChangesTests.swift | 12 +-- 7 files changed, 123 insertions(+), 162 deletions(-) delete mode 100644 Sources/TokamakCore/Gestures/AnyGesture.swift rename Sources/TokamakCore/{Views/View+DataChanges.swift => Modifiers/OnChangeModifier.swift} (60%) create mode 100644 Sources/TokamakCore/Modifiers/OnReceiveModifier.swift diff --git a/Sources/TokamakCore/Gestures/AnyGesture.swift b/Sources/TokamakCore/Gestures/AnyGesture.swift deleted file mode 100644 index bc039dbad..000000000 --- a/Sources/TokamakCore/Gestures/AnyGesture.swift +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2020-2021 Tokamak contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// Created by Szymon on 11/8/2023. -// - -import Foundation - -/// A type-erased gesture. -public struct AnyGesture: Gesture { - typealias ActionClosure = (Value) -> Void - /// The type of the underlying `Gesture`. - let gestureType: Any.Type - /// The type of the `body` of the underlying `gesture`. Used to cast the result of the applied `bodyClosure` property. - let bodyType: Any.Type - /// The actual `Gesture` value wrapped within this `gesture`. - var gesture: Any - - let bodyClosure: (Any, ActionClosure?, ActionClosure?) -> any Gesture - var onEnded: ActionClosure? - var onChanged: ActionClosure? - - // `AnyGesture`, just to make it compile for now - public var body: AnyGesture { - fatalError("\(String(reflecting: Self.self)) should return underlaying gesture") - // TODO: Type 'any Gesture' cannot conform to 'Gesture' - // return bodyClosure(gesture, onChanged, onEnded).body - } - - public init(_ gesture: G) where Value == G.Value { - if let anyGesture = gesture as? AnyGesture { - self = anyGesture - } else { - self.gestureType = G.self - self.bodyType = G.Body.self - self.gesture = gesture - self.bodyClosure = { gesture, onChanged, onEnded in - // swiftlint:disable:next force_cast - var gesture = (gesture as! G) - if let onChanged { - gesture = gesture._onChanged(perform: onChanged) - } - if let onEnded { - gesture = gesture._onEnded(perform: onEnded) - } - return gesture - } - } - } - - mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { - fatalError("\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called.") - } - - public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onEnded = action - return gesture - } - - public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onChanged = action - return gesture - } -} diff --git a/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift index 47b1a36ae..2b884b9d8 100644 --- a/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift +++ b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift @@ -16,19 +16,28 @@ // private struct GestureEnvironmentKey: EnvironmentKey { - static let defaultValue: GestureEnvironmentValue = GestureEnvironmentValue() + static let defaultValue: GestureContext = GestureContext() } extension EnvironmentValues { - var gestureListener: GestureEnvironmentValue { + /// An environment value that provides a central hub for managing gesture recognition and priority handling. + var _gestureListener: GestureContext { get { self[GestureEnvironmentKey.self] } set { self[GestureEnvironmentKey.self] = newValue } } } -class GestureEnvironmentValue { +final class GestureContext { + // MARK: Gesture Management + + /// A dictionary that tracks active gestures for different events, organized by event name. var activeGestures: [String: Set] = [:] - + + /// Registers the start of a gesture for a specific event, respecting priority levels. + /// + /// - Parameters: + /// - gesture: The gesture to be registered. + /// - event: The name of the event associated with the gesture. func registerStart(_ gesture: GestureValue, for event: String) { if activeGestures[event] == nil { activeGestures[event] = [gesture] @@ -39,6 +48,11 @@ class GestureEnvironmentValue { } } + /// Recognizes a gesture for a specific event, considering its priority and adjusting active gestures accordingly. + /// + /// - Parameters: + /// - gesture: The gesture to be recognized. + /// - event: The name of the event associated with the gesture. func recognizeGesture(_ gesture: GestureValue, for event: String) { guard activeGestures[event]?.contains(gesture) == true else { return @@ -48,6 +62,12 @@ class GestureEnvironmentValue { activeGestures[event] = gestures } + /// Checks if a gesture can be processed for a specific event, considering its recognition status and priority. + /// + /// - Parameters: + /// - gesture: The gesture to be checked. + /// - event: The name of the event associated with the gesture. + /// - Returns: `true` if the gesture can be processed, `false` otherwise. func canProcessGesture(_ gesture: GestureValue, for event: String) -> Bool { guard activeGestures[event]?.contains(gesture) == true else { return false @@ -57,8 +77,15 @@ class GestureEnvironmentValue { } struct GestureValue: Hashable { + // MARK: Gesture Metadata + + /// A unique identifier for the gesture. let gestureId: String + + /// A mask that defines the type of gesture. let mask: GestureMask + + /// The priority level of the gesture. let priority: _GesturePriority func hash(into hasher: inout Hasher) { @@ -68,7 +95,11 @@ struct GestureValue: Hashable { // MARK: Helpers -extension Set where Element == GestureValue { +private extension Set where Element == GestureValue { + /// Removes gestures with lower priorities than the given priority. + /// + /// - Parameter priority: The priority to compare against. + /// - Returns: A filtered set containing only gestures with equal or higher priorities. func removeLowerPriorities(than priority: _GesturePriority) -> Self { return self.filter { switch priority { diff --git a/Sources/TokamakCore/Gestures/GestureMask.swift b/Sources/TokamakCore/Gestures/GestureMask.swift index 50974c67a..64c5d0d2e 100644 --- a/Sources/TokamakCore/Gestures/GestureMask.swift +++ b/Sources/TokamakCore/Gestures/GestureMask.swift @@ -74,21 +74,5 @@ import Foundation /// Disable all gestures in the subview hierarchy, including the added gesture. public static let none: Self = [] - - // MARK: - Helper Methods - - /// Enables a specific gesture option in the mask. - /// - /// - Parameter option: The `GestureMask` representing the gesture option to enable. - mutating func enableGesture(_ option: GestureMask) { - self.insert(option) - } - - /// Disables a specific gesture option in the mask. - /// - /// - Parameter option: The `GestureMask` representing the gesture option to disable. - mutating func disableGesture(_ option: GestureMask) { - self.remove(option) - } } diff --git a/Sources/TokamakCore/Views/View+DataChanges.swift b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift similarity index 60% rename from Sources/TokamakCore/Views/View+DataChanges.swift rename to Sources/TokamakCore/Modifiers/OnChangeModifier.swift index 5dfb5cf57..e2f6a0d26 100644 --- a/Sources/TokamakCore/Views/View+DataChanges.swift +++ b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift @@ -12,25 +12,36 @@ // See the License for the specific language governing permissions and // limitations under the License. // -// Created by Szymon on 6/8/2023. +// Created by Szymon on 20/8/2023. // import Foundation -import OpenCombineShim -extension View { - /// Adds an action to perform when this view detects data emitted by the given publisher. - /// - Parameters: - /// - publisher: The publisher to subscribe to. - /// - action: The action to perform when an event is emitted by publisher. The event emitted by publisher is passed as a parameter to action. - /// - Returns: A view that triggers action when publisher emits an event. - func onReceive

( - _ publisher: P, - perform action: @escaping (P.Output) -> Void - ) -> some View where P : Publisher, P.Failure == Never { - return self.modifier(OnReceiveModifier(publisher: publisher, action: action)) - } +struct OnChangeModifier: ViewModifier { + @State var oldValue: V? = nil + + let value: V + let initial: Bool + let action: (V, V) -> Void + func body(content: Content) -> some View { + content + .task { + if self.initial { + action(value, value) + } + oldValue = value + } + ._onUpdate { + if value != oldValue { + action(oldValue ?? value, value) + } + oldValue = value + } + } +} + +extension View { /// Adds a modifier for this view that fires an action when a specific value changes. /// - Parameters: /// - value: The value to check against when determining whether to run the closure. @@ -39,7 +50,7 @@ extension View { /// - oldValue: The old value that failed the comparison check (or the initial value when requested). /// - newValue: The new value that failed the comparison check. /// - Returns: A view that fires an action when the specified value changes. - func onChange( + public func onChange( of value: V, initial: Bool = false, _ action: @escaping (V, V) -> Void @@ -53,7 +64,7 @@ extension View { /// - initial: Whether the action should be run when this view initially appears. /// - action: A closure to run when the value changes. /// - Returns: A view that fires an action when the specified value changes. - func onChange( + public func onChange( of value: V, initial: Bool = false, _ action: @escaping () -> Void @@ -61,41 +72,3 @@ extension View { return self.modifier(OnChangeModifier(value: value, initial: initial) { _, _ in action() }) } } - -private struct OnReceiveModifier: ViewModifier where P.Failure == Never { - let publisher: P - let action: (P.Output) -> Void - - func body(content: Content) -> some View { - content.onAppear() { - let _ = publisher.sink { value in - self.action(value) - } - } - } -} - -private struct OnChangeModifier: ViewModifier { - let value: V - let initial: Bool - let action: (V, V) -> Void - - init(value: V, initial: Bool, action: @escaping (V, V) -> Void) { - self.value = value - self.initial = initial - self.action = action - } - - func body(content: Content) -> some View { - content.onAppear() { - if initial { - action(value, value) - } - } - .onReceive(Just(value)) { newValue in - if newValue != value { - action(value, newValue) - } - } - } -} diff --git a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift new file mode 100644 index 000000000..977948f6c --- /dev/null +++ b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift @@ -0,0 +1,51 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 20/8/2023. +// + +import Foundation +import OpenCombineShim + +struct OnReceiveModifier: ViewModifier where P.Failure == Never { + @State var cancellable: AnyCancellable? = nil + + let publisher: P + let action: (P.Output) -> Void + + func body(content: Content) -> some View { + content + .onAppear { + cancellable = publisher.sink(receiveValue: action) + } + .onDisappear { + cancellable?.cancel() + cancellable = nil + } + } +} + +extension View { + /// Adds an action to perform when this view detects data emitted by the given publisher. + /// - Parameters: + /// - publisher: The publisher to subscribe to. + /// - action: The action to perform when an event is emitted by publisher. The event emitted by publisher is passed as a parameter to action. + /// - Returns: A view that triggers action when publisher emits an event. + public func onReceive

( + _ publisher: P, + perform action: @escaping (P.Output) -> Void + ) -> some View where P : Publisher, P.Failure == Never { + return self.modifier(OnReceiveModifier(publisher: publisher, action: action)) + } +} diff --git a/Sources/TokamakCore/Views/Gestures/_GestureView.swift b/Sources/TokamakCore/Views/Gestures/_GestureView.swift index 7ef9f013e..cec08f199 100644 --- a/Sources/TokamakCore/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -19,7 +19,7 @@ import Foundation public struct _GestureView: _PrimitiveView { @Environment(\.isEnabled) var isEnabled - @Environment(\.gestureListener) var gestureListener + @Environment(\._gestureListener) var gestureListener @State public var gestureId: String = UUID().uuidString @State public var gesture: G @State var eventId: String? = nil @@ -83,10 +83,7 @@ public struct _GestureView: _PrimitiveView { onPhaseChange(.changed(location: nil), eventId: eventId) } } - } catch { - //TODO: What do we do with this error? - print(error) - } + } catch {} } } } diff --git a/Tests/TokamakTests/ViewReactToDataChangesTests.swift b/Tests/TokamakTests/ViewReactToDataChangesTests.swift index 77d49f2d8..656bdb986 100644 --- a/Tests/TokamakTests/ViewReactToDataChangesTests.swift +++ b/Tests/TokamakTests/ViewReactToDataChangesTests.swift @@ -12,10 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -@testable import TokamakCore import XCTest +import TokamakTestRenderer import OpenCombineShim +@_spi(TokamakCore) @testable import TokamakCore + class ViewModifierTests: XCTestCase { func testOnReceive() { let publisher = PassthroughSubject() @@ -32,7 +34,7 @@ class ViewModifierTests: XCTestCase { publisher.send("Testing onReceive") // Re-evaluate the view - _ = contentView.body + let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) XCTAssertEqual(receivedValue, "Testing onReceive") } @@ -54,7 +56,7 @@ class ViewModifierTests: XCTestCase { count = 5 // Re-evaluate the view - _ = contentView.body + let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) XCTAssertEqual(count, 5) XCTAssertEqual(oldCount, 0) @@ -72,7 +74,7 @@ class ViewModifierTests: XCTestCase { XCTAssertFalse(actionFired) // Re-evaluate the view - _ = contentView.body + let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) XCTAssertTrue(actionFired) } @@ -99,7 +101,7 @@ class ViewModifierTests: XCTestCase { count = 5 // Re-evaluate the view - _ = contentView.body + let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) XCTAssertEqual(count, 5) XCTAssertEqual(receivedValue, 10) From 8a0a468c375aee15feaf280681147b8c3cebcbd5 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 20 Aug 2023 09:20:08 +1000 Subject: [PATCH 14/33] Update base branch --- .../CoordinateSpace/CoordinateSpace.swift | 54 ------------------- .../CoordinateSpaceEnviroment.swift | 53 ++++++++++++++++++ .../Views/Layout/GeometryReader.swift | 4 +- 3 files changed, 55 insertions(+), 56 deletions(-) diff --git a/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift b/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift index 4bea950a5..392b225a5 100644 --- a/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift +++ b/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift @@ -46,57 +46,3 @@ extension CoordinateSpace { } } } - -extension CoordinateSpace { - static func convertGlobalSpaceCoordinates(rect: CGRect, toNamedOrigin namedOrigin: CGPoint) -> CGRect { - let translatedOrigin = convert(rect.origin, toNamedOrigin: namedOrigin) - return CGRect(origin: translatedOrigin, size: rect.size) - } - - static func convert(_ point: CGPoint, toNamedOrigin namedOrigin: CGPoint) -> CGPoint { - return CGPoint(x: point.x - namedOrigin.x, y: point.y - namedOrigin.y) - } -} - - -extension View { - /// Assigns a name to the view’s coordinate space, so other code can operate on dimensions like points and sizes relative to the named space. - /// - Parameter name: A name used to identify this coordinate space. - public func coordinateSpace(name: T) -> some View where T : Hashable { - self.modifier(_CoordinateSpaceModifier(name: name)) - } -} - -private struct CoordinateSpaceEnvironmentKey: EnvironmentKey { - static let defaultValue: CoordinateSpaceEnvironmentValue = CoordinateSpaceEnvironmentValue() -} - -extension EnvironmentValues { - var _coordinateSpace: CoordinateSpaceEnvironmentValue { - get { self[CoordinateSpaceEnvironmentKey.self] } - set { self[CoordinateSpaceEnvironmentKey.self] = newValue } - } -} - -class CoordinateSpaceEnvironmentValue { - var activeCoordinateSpace: [CoordinateSpace: CGRect] = [:] -} - -struct _CoordinateSpaceModifier: ViewModifier { - @Environment(\._coordinateSpace) var coordinateSpace - let name: T - - public func body(content: Content) -> some View { - content.background { - GeometryReader { proxy in - Color.clear - .onChange(of: proxy.size, initial: true) { - coordinateSpace.activeCoordinateSpace[.named(name)] = proxy.frame(in: .global) - } - .onDisappear { - coordinateSpace.activeCoordinateSpace.removeValue(forKey: .named(name)) - } - } - } - } -} diff --git a/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift b/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift index 15e21eb0f..03968e9da 100644 --- a/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift +++ b/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift @@ -17,3 +17,56 @@ import Foundation +private struct CoordinateSpaceEnvironmentKey: EnvironmentKey { + static let defaultValue: CoordinateSpaceContext = CoordinateSpaceContext() +} + +extension EnvironmentValues { + var _coordinateSpace: CoordinateSpaceContext { + get { self[CoordinateSpaceEnvironmentKey.self] } + set { self[CoordinateSpaceEnvironmentKey.self] = newValue } + } +} + +class CoordinateSpaceContext { + /// Stores currently active CoordinateSpace against it's origin point in global coordinates + var activeCoordinateSpace: [CoordinateSpace: CGPoint] = [:] +} + +extension View { + /// Assigns a name to the view’s coordinate space, so other code can operate on dimensions like points and sizes relative to the named space. + /// - Parameter name: A name used to identify this coordinate space. + public func coordinateSpace(name: T) -> some View where T : Hashable { + self.modifier(_CoordinateSpaceModifier(name: name)) + } +} + +struct _CoordinateSpaceModifier: ViewModifier { + @Environment(\._coordinateSpace) var coordinateSpace + let name: T + + public func body(content: Content) -> some View { + content.background { + GeometryReader { proxy in + Color.clear + .onChange(of: proxy.size, initial: true) { + coordinateSpace.activeCoordinateSpace[.named(name)] = proxy.frame(in: .global).origin + } + .onDisappear { + coordinateSpace.activeCoordinateSpace.removeValue(forKey: .named(name)) + } + } + } + } +} + +extension CoordinateSpace { + static func convertGlobalSpaceCoordinates(rect: CGRect, toNamedOrigin namedOrigin: CGPoint) -> CGRect { + let translatedOrigin = convert(rect.origin, toNamedOrigin: namedOrigin) + return CGRect(origin: translatedOrigin, size: rect.size) + } + + static func convert(_ point: CGPoint, toNamedOrigin namedOrigin: CGPoint) -> CGPoint { + return CGPoint(x: point.x - namedOrigin.x, y: point.y - namedOrigin.y) + } +} diff --git a/Sources/TokamakCore/Views/Layout/GeometryReader.swift b/Sources/TokamakCore/Views/Layout/GeometryReader.swift index ae7117eb0..9067056ed 100644 --- a/Sources/TokamakCore/Views/Layout/GeometryReader.swift +++ b/Sources/TokamakCore/Views/Layout/GeometryReader.swift @@ -33,10 +33,10 @@ public struct GeometryProxy { case .local: return CGRect(origin: .zero, size: size) case .named(let name): - if let rect = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { + if let origin = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { return CoordinateSpace.convertGlobalSpaceCoordinates( rect: globalRect, - toNamedOrigin: rect.origin + toNamedOrigin: origin ) } // Return local if no space with given name From cbf9ef3c2aedb4c69d63b27f1799ab2c3f41097a Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 20 Aug 2023 09:32:43 +1000 Subject: [PATCH 15/33] some cosmetics --- .../Gestures/Recognizers/DragGesture.swift | 10 +++--- .../Recognizers/LongPressGesture.swift | 2 +- .../TokamakCore/Gestures/_GesturePhase.swift | 23 +++++++++++-- .../Views/Gestures/_GestureView.swift | 2 +- .../Views/Gestures/GestureView.swift | 32 +++++++++++++++++-- 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 47f2cd60d..815c8a2e6 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -52,7 +52,7 @@ public struct DragGesture: Gesture { startLocation = location previousTimestamp = nil velocity = .zero - case .changed(let location) where startLocation != nil: + case .changed(let origin, let location) where startLocation != nil: guard let startLocation, let location else { return false } let translation = calculateTranslation(from: startLocation, to: location) let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) @@ -63,6 +63,7 @@ public struct DragGesture: Gesture { let timeElapsed = Double(currentTimestamp.timeIntervalSince(previousTimestamp ?? currentTimestamp)) let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) self.velocity = velocity + self.globalOrigin = origin ?? globalOrigin // Predict end location based on velocity let predictedEndLocation = calculatePredictedEndLocation(from: location, velocity: velocity) @@ -82,9 +83,10 @@ public struct DragGesture: Gesture { return true case .changed: break - case .ended(let location): + case .ended(let origin, let location): if let startLocation { let translation = calculateTranslation(from: startLocation, to: location) + self.globalOrigin = origin ?? globalOrigin onEndedAction?( Value( startLocation: converLocation(startLocation), @@ -125,8 +127,8 @@ public struct DragGesture: Gesture { } return location case .named(let name): - if let rect = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { - return CoordinateSpace.convert(location, toNamedOrigin: rect.origin) + if let origin = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { + return CoordinateSpace.convert(location, toNamedOrigin: origin) } return location } diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift index 4016cb4af..24374ed8c 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -45,7 +45,7 @@ public struct LongPressGesture: Gesture { startLocation = location touchStartTime = Date() onChangedAction?(startLocation != nil) - case .changed(let location) where startLocation != nil: + case .changed(_, let location) where startLocation != nil: guard let startLocation else { return false } let translation = calculateTranslation(from: startLocation, to: location ?? startLocation) let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) diff --git a/Sources/TokamakCore/Gestures/_GesturePhase.swift b/Sources/TokamakCore/Gestures/_GesturePhase.swift index 55491426f..c6116e3b1 100644 --- a/Sources/TokamakCore/Gestures/_GesturePhase.swift +++ b/Sources/TokamakCore/Gestures/_GesturePhase.swift @@ -18,8 +18,27 @@ import Foundation public enum _GesturePhase { + /// The gesture phase when it begins. + /// + /// - Parameters: + /// - boundsOrigin: The origin point of the target element in global coordinates. + /// - location: The current location of the gesture in global coordinates. case began(boundsOrigin: CGPoint, location: CGPoint) - case changed(location: CGPoint?) - case ended(location: CGPoint) + + /// The gesture phase when it changes. + /// + /// - Parameters: + /// - boundsOrigin: The optional origin point of the target element in global coordinates. + /// - location: The optional current location of the gesture in global coordinates. + case changed(boundsOrigin: CGPoint?, location: CGPoint?) + + /// The gesture phase when it ends. + /// + /// - Parameters: + /// - boundsOrigin: The optional origin point of the target element in global coordinates. + /// - location: The current location of the gesture in global coordinates. + case ended(boundsOrigin: CGPoint?, location: CGPoint) + + /// The gesture phase when it is cancelled. case cancelled } diff --git a/Sources/TokamakCore/Views/Gestures/_GestureView.swift b/Sources/TokamakCore/Views/Gestures/_GestureView.swift index 841b64580..8a6f5d18c 100644 --- a/Sources/TokamakCore/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -80,7 +80,7 @@ public struct _GestureView: _PrimitiveView { try await Task.sleep(for: .seconds(minimumDuration)) if let eventId { await MainActor.run { - onPhaseChange(.changed(location: nil), eventId: eventId) + onPhaseChange(.changed(boundsOrigin: nil, location: nil), eventId: eventId) } } } catch {} diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift index fff73e987..5685ce4b3 100644 --- a/Sources/TokamakDOM/Views/Gestures/GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/GestureView.swift @@ -58,7 +58,21 @@ extension TokamakCore._GestureView: DOMPrimitive { if let event = args[0].object, let x = event.x.jsValue.number, let y = event.y.jsValue.number { - onPhaseChange(.changed(location: CGPoint(x: x, y: y))) + var origin: CGPoint? = nil + + if let target = args[0].object?[dynamicMember: "0"].object?.target.object, + let rect = target.getBoundingClientRect?(), + let originX = rect.x.number, + let originY = rect.y.number { + origin = CGPoint(x: originX, y: originY) + } + + onPhaseChange( + .changed( + boundsOrigin: origin, + location: CGPoint(x: x, y: y) + ) + ) } return .undefined } @@ -69,7 +83,21 @@ extension TokamakCore._GestureView: DOMPrimitive { if let event = args[0].object, let x = event.x.jsValue.number, let y = event.y.jsValue.number { - onPhaseChange(.ended(location: CGPoint(x: x, y: y))) + var origin: CGPoint? = nil + + if let target = args[0].object?[dynamicMember: "0"].object?.target.object, + let rect = target.getBoundingClientRect?(), + let originX = rect.x.number, + let originY = rect.y.number { + origin = CGPoint(x: originX, y: originY) + } + + onPhaseChange( + .ended( + boundsOrigin: origin, + location: CGPoint(x: x, y: y) + ) + ) } return .undefined } From 4bab2f291cadb3dfdfa96721ed2ba425e93cd750 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 20 Aug 2023 11:09:08 +1000 Subject: [PATCH 16/33] Add gesture phase context --- .../Gestures/Recognizers/DragGesture.swift | 18 +++++------ .../Recognizers/LongPressGesture.swift | 10 +++---- .../TokamakCore/Gestures/_GesturePhase.swift | 30 +++++++++---------- .../Views/Gestures/_GestureView.swift | 2 +- .../Views/Gestures/GestureView.swift | 30 ++++++++----------- 5 files changed, 42 insertions(+), 48 deletions(-) diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 815c8a2e6..062f0bbea 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -47,13 +47,13 @@ public struct DragGesture: Gesture { mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { - case .began(let origin, let location): - globalOrigin = origin - startLocation = location + case .began(let context): + globalOrigin = context.boundsOrigin + startLocation = context.location previousTimestamp = nil velocity = .zero - case .changed(let origin, let location) where startLocation != nil: - guard let startLocation, let location else { return false } + case .changed(let context) where startLocation != nil: + guard let startLocation, let location = context.location else { return false } let translation = calculateTranslation(from: startLocation, to: location) let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) @@ -63,7 +63,7 @@ public struct DragGesture: Gesture { let timeElapsed = Double(currentTimestamp.timeIntervalSince(previousTimestamp ?? currentTimestamp)) let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) self.velocity = velocity - self.globalOrigin = origin ?? globalOrigin + self.globalOrigin = context.boundsOrigin ?? globalOrigin // Predict end location based on velocity let predictedEndLocation = calculatePredictedEndLocation(from: location, velocity: velocity) @@ -83,10 +83,10 @@ public struct DragGesture: Gesture { return true case .changed: break - case .ended(let origin, let location): - if let startLocation { + case .ended(let context): + if let startLocation, let location = context.location { let translation = calculateTranslation(from: startLocation, to: location) - self.globalOrigin = origin ?? globalOrigin + self.globalOrigin = context.boundsOrigin ?? globalOrigin onEndedAction?( Value( startLocation: converLocation(startLocation), diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift index 24374ed8c..0435e7ff2 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -41,17 +41,17 @@ public struct LongPressGesture: Gesture { mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { - case .began(_, let location): - startLocation = location + case .began(let context): + startLocation = context.location touchStartTime = Date() onChangedAction?(startLocation != nil) - case .changed(_, let location) where startLocation != nil: + case .changed(let context) where startLocation != nil: guard let startLocation else { return false } - let translation = calculateTranslation(from: startLocation, to: location ?? startLocation) + let translation = calculateTranslation(from: startLocation, to: context.location ?? startLocation) let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) guard maximumDistance >= distance else { - print("Failed", distance, maximumDistance, startLocation, location ?? startLocation) + print("Failed", distance, maximumDistance, startLocation, context.location ?? startLocation) // Fail longpress if distance is to big. self.startLocation = nil return false diff --git a/Sources/TokamakCore/Gestures/_GesturePhase.swift b/Sources/TokamakCore/Gestures/_GesturePhase.swift index c6116e3b1..dee75798f 100644 --- a/Sources/TokamakCore/Gestures/_GesturePhase.swift +++ b/Sources/TokamakCore/Gestures/_GesturePhase.swift @@ -19,26 +19,26 @@ import Foundation public enum _GesturePhase { /// The gesture phase when it begins. - /// - /// - Parameters: - /// - boundsOrigin: The origin point of the target element in global coordinates. - /// - location: The current location of the gesture in global coordinates. - case began(boundsOrigin: CGPoint, location: CGPoint) + case began(_GesturePhaseContext) /// The gesture phase when it changes. - /// - /// - Parameters: - /// - boundsOrigin: The optional origin point of the target element in global coordinates. - /// - location: The optional current location of the gesture in global coordinates. - case changed(boundsOrigin: CGPoint?, location: CGPoint?) + case changed(_GesturePhaseContext) /// The gesture phase when it ends. - /// - /// - Parameters: - /// - boundsOrigin: The optional origin point of the target element in global coordinates. - /// - location: The current location of the gesture in global coordinates. - case ended(boundsOrigin: CGPoint?, location: CGPoint) + case ended(_GesturePhaseContext) /// The gesture phase when it is cancelled. case cancelled } + +public struct _GesturePhaseContext { + /// The origin point of the target element in global coordinates. + let boundsOrigin: CGPoint? + /// The current location of the gesture in global coordinates. + let location: CGPoint? + + public init(boundsOrigin: CGPoint? = nil, location: CGPoint? = nil) { + self.boundsOrigin = boundsOrigin + self.location = location + } +} diff --git a/Sources/TokamakCore/Views/Gestures/_GestureView.swift b/Sources/TokamakCore/Views/Gestures/_GestureView.swift index 8a6f5d18c..53d027799 100644 --- a/Sources/TokamakCore/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -80,7 +80,7 @@ public struct _GestureView: _PrimitiveView { try await Task.sleep(for: .seconds(minimumDuration)) if let eventId { await MainActor.run { - onPhaseChange(.changed(boundsOrigin: nil, location: nil), eventId: eventId) + onPhaseChange(.changed(_GesturePhaseContext()), eventId: eventId) } } } catch {} diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift index 5685ce4b3..dc54d711e 100644 --- a/Sources/TokamakDOM/Views/Gestures/GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/GestureView.swift @@ -33,13 +33,11 @@ extension TokamakCore._GestureView: DOMPrimitive { let rect = target.getBoundingClientRect?(), let originX = rect.x.number, let originY = rect.y.number else { return } - onPhaseChange( - .began( - boundsOrigin: CGPoint(x: originX, y: originY), - location: CGPoint(x: x, y: y) - ), - eventId: String(describing: target.hashValue) + let phase = _GesturePhaseContext( + boundsOrigin: CGPoint(x: originX, y: originY), + location: CGPoint(x: x, y: y) ) + onPhaseChange(.began(phase), eventId: String(describing: target.hashValue)) }, "pointercancel": { event in onPhaseChange(.cancelled) @@ -66,13 +64,11 @@ extension TokamakCore._GestureView: DOMPrimitive { let originY = rect.y.number { origin = CGPoint(x: originX, y: originY) } - - onPhaseChange( - .changed( - boundsOrigin: origin, - location: CGPoint(x: x, y: y) - ) + let phase = _GesturePhaseContext( + boundsOrigin: origin, + location: CGPoint(x: x, y: y) ) + onPhaseChange(.changed(phase)) } return .undefined } @@ -91,13 +87,11 @@ extension TokamakCore._GestureView: DOMPrimitive { let originY = rect.y.number { origin = CGPoint(x: originX, y: originY) } - - onPhaseChange( - .ended( - boundsOrigin: origin, - location: CGPoint(x: x, y: y) - ) + let phase = _GesturePhaseContext( + boundsOrigin: origin, + location: CGPoint(x: x, y: y) ) + onPhaseChange(.ended(phase)) } return .undefined } From 46f2a3072b4b2c04a748c90357cf036df3faa5e7 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 20 Aug 2023 11:21:09 +1000 Subject: [PATCH 17/33] move to separate file --- .../CoordinateSpaceEnviroment.swift | 27 ----------- .../Modifiers/CoordinateSpaceModifier.swift | 45 +++++++++++++++++++ 2 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift diff --git a/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift b/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift index 03968e9da..2600e358c 100644 --- a/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift +++ b/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift @@ -33,33 +33,6 @@ class CoordinateSpaceContext { var activeCoordinateSpace: [CoordinateSpace: CGPoint] = [:] } -extension View { - /// Assigns a name to the view’s coordinate space, so other code can operate on dimensions like points and sizes relative to the named space. - /// - Parameter name: A name used to identify this coordinate space. - public func coordinateSpace(name: T) -> some View where T : Hashable { - self.modifier(_CoordinateSpaceModifier(name: name)) - } -} - -struct _CoordinateSpaceModifier: ViewModifier { - @Environment(\._coordinateSpace) var coordinateSpace - let name: T - - public func body(content: Content) -> some View { - content.background { - GeometryReader { proxy in - Color.clear - .onChange(of: proxy.size, initial: true) { - coordinateSpace.activeCoordinateSpace[.named(name)] = proxy.frame(in: .global).origin - } - .onDisappear { - coordinateSpace.activeCoordinateSpace.removeValue(forKey: .named(name)) - } - } - } - } -} - extension CoordinateSpace { static func convertGlobalSpaceCoordinates(rect: CGRect, toNamedOrigin namedOrigin: CGPoint) -> CGRect { let translatedOrigin = convert(rect.origin, toNamedOrigin: namedOrigin) diff --git a/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift b/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift new file mode 100644 index 000000000..0a186db89 --- /dev/null +++ b/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift @@ -0,0 +1,45 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 20/8/2023. +// + +import Foundation + +struct _CoordinateSpaceModifier: ViewModifier { + @Environment(\._coordinateSpace) var coordinateSpace + let name: T + + public func body(content: Content) -> some View { + content.background { + GeometryReader { proxy in + EmptyView() + .onChange(of: proxy.size, initial: true) { + coordinateSpace.activeCoordinateSpace[.named(name)] = proxy.frame(in: .global).origin + } + .onDisappear { + coordinateSpace.activeCoordinateSpace.removeValue(forKey: .named(name)) + } + } + } + } +} + +extension View { + /// Assigns a name to the view’s coordinate space, so other code can operate on dimensions like points and sizes relative to the named space. + /// - Parameter name: A name used to identify this coordinate space. + public func coordinateSpace(name: T) -> some View where T : Hashable { + self.modifier(_CoordinateSpaceModifier(name: name)) + } +} From 4682ca55118b1394d9b71e28a978d6c6b0632405 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 20 Aug 2023 22:43:33 +1000 Subject: [PATCH 18/33] Remove target.getBoundingClientRect to improve performance --- .../Gestures/Recognizers/DragGesture.swift | 15 ++++-- .../Views/Gestures/GestureView.swift | 53 +++++++------------ .../Views/Layout/GeometryReader.swift | 10 ++-- 3 files changed, 34 insertions(+), 44 deletions(-) diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 062f0bbea..711a6bfe7 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -48,11 +48,12 @@ public struct DragGesture: Gesture { mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { case .began(let context): - globalOrigin = context.boundsOrigin + setGlobalOrigin(context.boundsOrigin) startLocation = context.location previousTimestamp = nil velocity = .zero case .changed(let context) where startLocation != nil: + setGlobalOrigin(context.boundsOrigin) guard let startLocation, let location = context.location else { return false } let translation = calculateTranslation(from: startLocation, to: location) let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) @@ -63,7 +64,6 @@ public struct DragGesture: Gesture { let timeElapsed = Double(currentTimestamp.timeIntervalSince(previousTimestamp ?? currentTimestamp)) let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) self.velocity = velocity - self.globalOrigin = context.boundsOrigin ?? globalOrigin // Predict end location based on velocity let predictedEndLocation = calculatePredictedEndLocation(from: location, velocity: velocity) @@ -81,8 +81,8 @@ public struct DragGesture: Gesture { ) ) return true - case .changed: - break + case .changed(let context): + setGlobalOrigin(context.boundsOrigin) case .ended(let context): if let startLocation, let location = context.location { let translation = calculateTranslation(from: startLocation, to: location) @@ -117,6 +117,13 @@ public struct DragGesture: Gesture { return gesture } + private mutating func setGlobalOrigin(_ origin: CGPoint?) { + let newOrigin = origin ?? globalOrigin + if newOrigin != self.globalOrigin { + self.globalOrigin = newOrigin + } + } + private func converLocation(_ location: CGPoint) -> CGPoint { switch coordinateSpace { case .global: diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift index dc54d711e..363fa9141 100644 --- a/Sources/TokamakDOM/Views/Gestures/GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/GestureView.swift @@ -25,27 +25,32 @@ extension TokamakCore._GestureView: DOMPrimitive { AnyView( DynamicHTML("div", [ "id": gestureId, + "style": "user-select: none;" ], listeners: [ "pointerdown": { event in guard let target = event.target.object, let x = event.x.jsValue.number, - let y = event.y.jsValue.number, - let rect = target.getBoundingClientRect?(), - let originX = rect.x.number, - let originY = rect.y.number else { return } - let phase = _GesturePhaseContext( - boundsOrigin: CGPoint(x: originX, y: originY), - location: CGPoint(x: x, y: y) - ) + let y = event.y.jsValue.number else { return } + let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) onPhaseChange(.began(phase), eventId: String(describing: target.hashValue)) }, "pointercancel": { event in onPhaseChange(.cancelled) } ]) { - content.onAppear { - setupPointerListener() - } + content + .background { + GeometryReader { proxy in + EmptyView() + .onChange(of: proxy.size, initial: true) { + let phase = _GesturePhaseContext(boundsOrigin: proxy.frame(in: .global).origin) + onPhaseChange(.changed(phase)) + } + } + } + .onAppear { + setupPointerListener() + } } ) } @@ -56,18 +61,7 @@ extension TokamakCore._GestureView: DOMPrimitive { if let event = args[0].object, let x = event.x.jsValue.number, let y = event.y.jsValue.number { - var origin: CGPoint? = nil - - if let target = args[0].object?[dynamicMember: "0"].object?.target.object, - let rect = target.getBoundingClientRect?(), - let originX = rect.x.number, - let originY = rect.y.number { - origin = CGPoint(x: originX, y: originY) - } - let phase = _GesturePhaseContext( - boundsOrigin: origin, - location: CGPoint(x: x, y: y) - ) + let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) onPhaseChange(.changed(phase)) } return .undefined @@ -79,18 +73,7 @@ extension TokamakCore._GestureView: DOMPrimitive { if let event = args[0].object, let x = event.x.jsValue.number, let y = event.y.jsValue.number { - var origin: CGPoint? = nil - - if let target = args[0].object?[dynamicMember: "0"].object?.target.object, - let rect = target.getBoundingClientRect?(), - let originX = rect.x.number, - let originY = rect.y.number { - origin = CGPoint(x: originX, y: originY) - } - let phase = _GesturePhaseContext( - boundsOrigin: origin, - location: CGPoint(x: x, y: y) - ) + let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) onPhaseChange(.ended(phase)) } return .undefined diff --git a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift index b3574751c..ce0bb76de 100644 --- a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift +++ b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift @@ -17,7 +17,9 @@ import JavaScriptKit @_spi(TokamakCore) import TokamakCore import TokamakStaticHTML -private let ResizeObserver = JSObject.global.ResizeObserver.function! +/// https://toruskit.com/blog/how-to-get-element-bounds-without-reflow/ +/// IntersectionObserver compared to getBoundingClientRect() it’s faster and doesn’t produce any reflows. +private let IntersectionObserver = JSObject.global.IntersectionObserver.function! extension GeometryReader: DOMPrimitive { var renderedBody: AnyView { @@ -62,8 +64,7 @@ struct _GeometryReader: View { // FIXME: `JSArrayRef` is not a `RandomAccessCollection` for some reason, which forces // us to use a string subscript guard - let target = args[0].object?[dynamicMember: "0"].object?.target.object, - let rect = target.getBoundingClientRect?(), + let rect = args[0].object?[dynamicMember: "0"].object?.boundingClientRect.object, let x = rect.x.number, let y = rect.y.number, let width = rect.width.number, @@ -74,12 +75,11 @@ struct _GeometryReader: View { origin: CGPoint(x: x, y: y), size: CGSize(width: width, height: height) ) - return .undefined } state.closure = closure - let observerRef = ResizeObserver.new(closure) + let observerRef = IntersectionObserver.new(closure) _ = observerRef.observe!(state.observedNodeRef!) From 086a5a165ba701b67124f10b833c9035f294dbee Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Mon, 21 Aug 2023 20:12:26 +1000 Subject: [PATCH 19/33] Fix minor issues --- Sources/TokamakCore/Gestures/Gesture.swift | 1 + .../Gestures/Recognizers/DragGesture.swift | 31 +++++++------- .../Views/Gestures/_GestureView.swift | 40 +++++++++++++------ .../Views/Gestures/GestureView.swift | 26 +++++------- .../Views/Layout/GeometryReader.swift | 28 ++++++------- 5 files changed, 68 insertions(+), 58 deletions(-) diff --git a/Sources/TokamakCore/Gestures/Gesture.swift b/Sources/TokamakCore/Gestures/Gesture.swift index 4d2c4b2ac..4cceea22f 100644 --- a/Sources/TokamakCore/Gestures/Gesture.swift +++ b/Sources/TokamakCore/Gestures/Gesture.swift @@ -107,6 +107,7 @@ extension Gesture { } func calculateVelocity(from translation: CGSize, timeElapsed: Double) -> CGSize { + guard timeElapsed > .zero else { return .zero } let velocityX = translation.width / timeElapsed let velocityY = translation.height / timeElapsed diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 711a6bfe7..1e27e282f 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -16,6 +16,7 @@ // import Foundation +import OpenCombine public struct DragGesture: Gesture { @Environment(\._coordinateSpace) private var coordinates @@ -44,33 +45,31 @@ public struct DragGesture: Gesture { self.minimumDistance = minimumDistance self.coordinateSpace = coordinateSpace } - + mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { switch phase { case .began(let context): - setGlobalOrigin(context.boundsOrigin) + globalOrigin = context.boundsOrigin startLocation = context.location previousTimestamp = nil velocity = .zero case .changed(let context) where startLocation != nil: - setGlobalOrigin(context.boundsOrigin) guard let startLocation, let location = context.location else { return false } let translation = calculateTranslation(from: startLocation, to: location) let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) - + // Do nothing if gesture has not met the criteria guard minimumDistance < distance else { return false } let currentTimestamp = Date() let timeElapsed = Double(currentTimestamp.timeIntervalSince(previousTimestamp ?? currentTimestamp)) let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) - self.velocity = velocity - + let newOrigin = context.boundsOrigin ?? globalOrigin + // Predict end location based on velocity let predictedEndLocation = calculatePredictedEndLocation(from: location, velocity: velocity) - + // Predict end translation based on velocity let predictedEndTranslation = calculatePredictedEndTranslation(from: translation, velocity: velocity) - onChangedAction?( Value( startLocation: converLocation(startLocation), @@ -80,9 +79,14 @@ public struct DragGesture: Gesture { predictedEndTranslation: predictedEndTranslation ) ) + + self.velocity = velocity + self.globalOrigin = newOrigin + self.previousTimestamp = currentTimestamp + return true - case .changed(let context): - setGlobalOrigin(context.boundsOrigin) + case .changed: + break case .ended(let context): if let startLocation, let location = context.location { let translation = calculateTranslation(from: startLocation, to: location) @@ -117,13 +121,6 @@ public struct DragGesture: Gesture { return gesture } - private mutating func setGlobalOrigin(_ origin: CGPoint?) { - let newOrigin = origin ?? globalOrigin - if newOrigin != self.globalOrigin { - self.globalOrigin = newOrigin - } - } - private func converLocation(_ location: CGPoint) -> CGPoint { switch coordinateSpace { case .global: diff --git a/Sources/TokamakCore/Views/Gestures/_GestureView.swift b/Sources/TokamakCore/Views/Gestures/_GestureView.swift index 53d027799..ccaa2d765 100644 --- a/Sources/TokamakCore/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -17,19 +17,31 @@ import Foundation + public struct _GestureView: _PrimitiveView { + final class Coordinator: ObservableObject { + var gesture: G + var gestureId: String = UUID().uuidString + var eventId: String? = nil + + init(_ gesture: G) { + self.gesture = gesture + } + } + @Environment(\.isEnabled) var isEnabled @Environment(\._gestureListener) var gestureListener - @State public var gestureId: String = UUID().uuidString - @State public var gesture: G - @State var eventId: String? = nil + @StateObject private var coordinator: Coordinator let mask: GestureMask let priority: _GesturePriority public let content: Content + public var gestureId: String { + coordinator.gestureId + } var minimumDuration: Double? { - guard let longPressGesture = gesture as? LongPressGesture else { + guard let longPressGesture = coordinator.gesture as? LongPressGesture else { return nil } return longPressGesture.minimumDuration @@ -41,24 +53,28 @@ public struct _GestureView: _PrimitiveView { priority: _GesturePriority = .standard, content: Content ) { - self._gesture = State(wrappedValue: gesture) + self._coordinator = StateObject(wrappedValue: Coordinator(gesture)) self.mask = mask self.priority = priority self.content = content } public func onPhaseChange(_ phase: _GesturePhase, eventId id: String? = nil) { - guard isEnabled, let currentEventId = eventId ?? id else { return } + guard isEnabled, let currentEventId = coordinator.eventId ?? id else { return } - let value = GestureValue(gestureId: gestureId, mask: mask, priority: priority) + let value = GestureValue( + gestureId: gestureId, + mask: mask, + priority: priority + ) switch phase { case .began: startDelay() - eventId = id + coordinator.eventId = id gestureListener.registerStart(value, for: currentEventId) case .cancelled, .ended: - eventId = nil + coordinator.eventId = nil default: break } @@ -67,8 +83,8 @@ public struct _GestureView: _PrimitiveView { // Event being processed by another gestures return } - - if gesture._onPhaseChange(phase) { + + if coordinator.gesture._onPhaseChange(phase) { gestureListener.recognizeGesture(value, for: currentEventId) } } @@ -78,7 +94,7 @@ public struct _GestureView: _PrimitiveView { Task { do { try await Task.sleep(for: .seconds(minimumDuration)) - if let eventId { + if let eventId = coordinator.eventId { await MainActor.run { onPhaseChange(.changed(_GesturePhaseContext()), eventId: eventId) } diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift index 363fa9141..03101aa23 100644 --- a/Sources/TokamakDOM/Views/Gestures/GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/GestureView.swift @@ -25,13 +25,19 @@ extension TokamakCore._GestureView: DOMPrimitive { AnyView( DynamicHTML("div", [ "id": gestureId, - "style": "user-select: none;" + "style": "touch-action: none; user-select: none;" ], listeners: [ "pointerdown": { event in guard let target = event.target.object, let x = event.x.jsValue.number, - let y = event.y.jsValue.number else { return } - let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) + let y = event.y.jsValue.number, + let rect = target.getBoundingClientRect?(), + let originX = rect.x.number, + let originY = rect.y.number else { return } + let phase = _GesturePhaseContext( + boundsOrigin: CGPoint(x: originX, y: originY), + location: CGPoint(x: x, y: y) + ) onPhaseChange(.began(phase), eventId: String(describing: target.hashValue)) }, "pointercancel": { event in @@ -39,18 +45,8 @@ extension TokamakCore._GestureView: DOMPrimitive { } ]) { content - .background { - GeometryReader { proxy in - EmptyView() - .onChange(of: proxy.size, initial: true) { - let phase = _GesturePhaseContext(boundsOrigin: proxy.frame(in: .global).origin) - onPhaseChange(.changed(phase)) - } - } - } - .onAppear { - setupPointerListener() - } + }.task { + setupPointerListener() } ) } diff --git a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift index ce0bb76de..d78a9dd99 100644 --- a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift +++ b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift @@ -17,9 +17,7 @@ import JavaScriptKit @_spi(TokamakCore) import TokamakCore import TokamakStaticHTML -/// https://toruskit.com/blog/how-to-get-element-bounds-without-reflow/ -/// IntersectionObserver compared to getBoundingClientRect() it’s faster and doesn’t produce any reflows. -private let IntersectionObserver = JSObject.global.IntersectionObserver.function! +private let ResizeObserver = JSObject.global.ResizeObserver.function! extension GeometryReader: DOMPrimitive { var renderedBody: AnyView { @@ -40,7 +38,7 @@ struct _GeometryReader: View { /// A reference to a `ResizeObserver` instance. var observerRef: JSObject? - /// The last known size of the `observedNodeRef` DOM node. + /// The last known rect of the `observedNodeRef` DOM node. @Published var rect: CGRect? } @@ -63,24 +61,26 @@ struct _GeometryReader: View { let closure = JSClosure { [weak state] args -> JSValue in // FIXME: `JSArrayRef` is not a `RandomAccessCollection` for some reason, which forces // us to use a string subscript - guard - let rect = args[0].object?[dynamicMember: "0"].object?.boundingClientRect.object, - let x = rect.x.number, - let y = rect.y.number, - let width = rect.width.number, - let height = rect.height.number - else { return .undefined } + guard let target = args[0].object?[dynamicMember: "0"].object?.target.object, + let rect = target.getBoundingClientRect?(), + let x = rect.x.number, + let y = rect.y.number, + let width = rect.width.number, + let height = rect.height.number else { + return .undefined + } + state?.rect = CGRect( origin: CGPoint(x: x, y: y), size: CGSize(width: width, height: height) ) + return .undefined } state.closure = closure - - let observerRef = IntersectionObserver.new(closure) - + + let observerRef = ResizeObserver.new(closure) _ = observerRef.observe!(state.observedNodeRef!) state.observerRef = observerRef From 28757d50ee27ce51ebfee40f673bc39d5cbd60c9 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Wed, 23 Aug 2023 20:43:24 +1000 Subject: [PATCH 20/33] Add global publisher, fix drag issue state --- .../Gestures/Recognizers/DragGesture.swift | 5 +- .../Gestures/Recognizers/TapGesture.swift | 1 - .../TokamakCore/Gestures/_GesturePhase.swift | 5 +- .../Modifiers/OnReceiveModifier.swift | 18 ++-- .../Views/Gestures/_GestureView.swift | 25 +++-- Sources/TokamakDOM/App/App.swift | 101 +++++++++--------- .../App/GestureEventsObserver.swift | 88 +++++++++++++++ .../Views/Gestures/GestureView.swift | 79 -------------- .../Views/Gestures/_GestureView.swift | 30 ++++++ 9 files changed, 201 insertions(+), 151 deletions(-) create mode 100644 Sources/TokamakDOM/App/GestureEventsObserver.swift delete mode 100644 Sources/TokamakDOM/Views/Gestures/GestureView.swift create mode 100644 Sources/TokamakDOM/Views/Gestures/_GestureView.swift diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 1e27e282f..8a63710f8 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -88,7 +88,8 @@ public struct DragGesture: Gesture { case .changed: break case .ended(let context): - if let startLocation, let location = context.location { + let didRecognize = previousTimestamp != nil + if didRecognize, let startLocation, let location = context.location { let translation = calculateTranslation(from: startLocation, to: location) self.globalOrigin = context.boundsOrigin ?? globalOrigin onEndedAction?( @@ -102,7 +103,7 @@ public struct DragGesture: Gesture { ) } startLocation = nil - return true + return didRecognize case .cancelled: startLocation = nil } diff --git a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift index 75708844a..4f4745536 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift @@ -54,7 +54,6 @@ public struct TapGesture: Gesture { numberOfTapsSinceGestureBegan += 1 } } - // If we ended touch and have desired count we complete gesture if numberOfTapsSinceGestureBegan >= count { onEndedAction?(()) diff --git a/Sources/TokamakCore/Gestures/_GesturePhase.swift b/Sources/TokamakCore/Gestures/_GesturePhase.swift index dee75798f..a00a3c83b 100644 --- a/Sources/TokamakCore/Gestures/_GesturePhase.swift +++ b/Sources/TokamakCore/Gestures/_GesturePhase.swift @@ -32,12 +32,15 @@ public enum _GesturePhase { } public struct _GesturePhaseContext { + /// The event id in which phase has originated form. + let eventId: String? /// The origin point of the target element in global coordinates. let boundsOrigin: CGPoint? /// The current location of the gesture in global coordinates. let location: CGPoint? - public init(boundsOrigin: CGPoint? = nil, location: CGPoint? = nil) { + public init(eventId: String? = nil, boundsOrigin: CGPoint? = nil, location: CGPoint? = nil) { + self.eventId = eventId self.boundsOrigin = boundsOrigin self.location = location } diff --git a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift index 977948f6c..dfe869e2c 100644 --- a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift +++ b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift @@ -19,20 +19,16 @@ import Foundation import OpenCombineShim struct OnReceiveModifier: ViewModifier where P.Failure == Never { - @State var cancellable: AnyCancellable? = nil + @State var cancellable: AnyCancellable - let publisher: P - let action: (P.Output) -> Void + init(publisher: P, action: @escaping (P.Output) -> Void) { + self._cancellable = State(initialValue: publisher.sink(receiveValue: action)) + } func body(content: Content) -> some View { - content - .onAppear { - cancellable = publisher.sink(receiveValue: action) - } - .onDisappear { - cancellable?.cancel() - cancellable = nil - } + content._onUnmount { + cancellable.cancel() + } } } diff --git a/Sources/TokamakCore/Views/Gestures/_GestureView.swift b/Sources/TokamakCore/Views/Gestures/_GestureView.swift index ccaa2d765..07b3457ec 100644 --- a/Sources/TokamakCore/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -17,7 +17,6 @@ import Foundation - public struct _GestureView: _PrimitiveView { final class Coordinator: ObservableObject { var gesture: G @@ -59,8 +58,11 @@ public struct _GestureView: _PrimitiveView { self.content = content } - public func onPhaseChange(_ phase: _GesturePhase, eventId id: String? = nil) { - guard isEnabled, let currentEventId = coordinator.eventId ?? id else { return } + public func onPhaseChange(_ phase: _GesturePhase) { + guard isEnabled else { + // View needs to be enabled in order for the gestures to work + return + } let value = GestureValue( gestureId: gestureId, @@ -68,22 +70,31 @@ public struct _GestureView: _PrimitiveView { priority: priority ) + var eventId = coordinator.eventId + switch phase { - case .began: + case let .began(context) where context.eventId != nil: startDelay() - coordinator.eventId = id - gestureListener.registerStart(value, for: currentEventId) + coordinator.eventId = context.eventId + gestureListener.registerStart(value, for: context.eventId!) + eventId = context.eventId case .cancelled, .ended: coordinator.eventId = nil default: break } + guard let currentEventId = eventId else { + // Gesture has not started + return + } guard gestureListener.canProcessGesture(value, for: currentEventId) else { // Event being processed by another gestures return } + print("🟡", eventId, phase, coordinator.gesture) + if coordinator.gesture._onPhaseChange(phase) { gestureListener.recognizeGesture(value, for: currentEventId) } @@ -96,7 +107,7 @@ public struct _GestureView: _PrimitiveView { try await Task.sleep(for: .seconds(minimumDuration)) if let eventId = coordinator.eventId { await MainActor.run { - onPhaseChange(.changed(_GesturePhaseContext()), eventId: eventId) + onPhaseChange(.changed(_GesturePhaseContext())) } } } catch {} diff --git a/Sources/TokamakDOM/App/App.swift b/Sources/TokamakDOM/App/App.swift index da786ee19..c6f0fd154 100644 --- a/Sources/TokamakDOM/App/App.swift +++ b/Sources/TokamakDOM/App/App.swift @@ -21,56 +21,57 @@ import TokamakCore import TokamakStaticHTML public extension App { - static func _launch(_ app: Self, with configuration: _AppConfiguration) { - switch configuration.reconciler { - case .stack: - _launch(app, configuration.rootEnvironment, TokamakDOM.body) - case let .fiber(useDynamicLayout): - DOMFiberRenderer("body", useDynamicLayout: useDynamicLayout).render(app) + static func _launch(_ app: Self, with configuration: _AppConfiguration) { + switch configuration.reconciler { + case .stack: + _launch(app, configuration.rootEnvironment, TokamakDOM.body) + case let .fiber(useDynamicLayout): + DOMFiberRenderer("body", useDynamicLayout: useDynamicLayout).render(app) + } } - } - - /// The default implementation of `launch` for a `TokamakDOM` app. - /// - /// Creates a host `div` node and appends it to the body. - /// - /// The body is styled with `margin: 0;` to match the `SwiftUI` layout - /// system as closely as possible - /// - static func _launch( - _ app: Self, - _ rootEnvironment: EnvironmentValues, - _ body: JSObject - ) { - if body.style.object!.all == "" { - body.style = "margin: 0;" + + /// The default implementation of `launch` for a `TokamakDOM` app. + /// + /// Creates a host `div` node and appends it to the body. + /// + /// The body is styled with `margin: 0;` to match the `SwiftUI` layout + /// system as closely as possible + /// + static func _launch( + _ app: Self, + _ rootEnvironment: EnvironmentValues, + _ body: JSObject + ) { + if body.style.object!.all == "" { + body.style = "margin: 0;" + } + let rootStyle = document.createElement!("style").object! + rootStyle.id = "_tokamak-app-style" + rootStyle.innerHTML = .string(tokamakStyles) + _ = head.appendChild!(rootStyle) + + let div = document.createElement!("div").object! + _ = Unmanaged.passRetained(DOMRenderer(app, div, rootEnvironment)) + + _ = body.appendChild!(div) + + GestureEventsObserver.observe() + ScenePhaseObserver.observe() + ColorSchemeObserver.observe(div) + } + + static func _setTitle(_ title: String) { + let titleTag = document.createElement!("title").object! + titleTag.id = "_tokamak-app-title" + titleTag.innerHTML = .string(title) + _ = head.appendChild!(titleTag) + } + + var _phasePublisher: AnyPublisher { + ScenePhaseObserver.publisher.eraseToAnyPublisher() + } + + var _colorSchemePublisher: AnyPublisher { + ColorSchemeObserver.publisher.eraseToAnyPublisher() } - let rootStyle = document.createElement!("style").object! - rootStyle.id = "_tokamak-app-style" - rootStyle.innerHTML = .string(tokamakStyles) - _ = head.appendChild!(rootStyle) - - let div = document.createElement!("div").object! - _ = Unmanaged.passRetained(DOMRenderer(app, div, rootEnvironment)) - - _ = body.appendChild!(div) - - ScenePhaseObserver.observe() - ColorSchemeObserver.observe(div) - } - - static func _setTitle(_ title: String) { - let titleTag = document.createElement!("title").object! - titleTag.id = "_tokamak-app-title" - titleTag.innerHTML = .string(title) - _ = head.appendChild!(titleTag) - } - - var _phasePublisher: AnyPublisher { - ScenePhaseObserver.publisher.eraseToAnyPublisher() - } - - var _colorSchemePublisher: AnyPublisher { - ColorSchemeObserver.publisher.eraseToAnyPublisher() - } } diff --git a/Sources/TokamakDOM/App/GestureEventsObserver.swift b/Sources/TokamakDOM/App/GestureEventsObserver.swift new file mode 100644 index 000000000..5e0295a65 --- /dev/null +++ b/Sources/TokamakDOM/App/GestureEventsObserver.swift @@ -0,0 +1,88 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 23/8/2023. +// + +import Foundation +import JavaScriptKit +import OpenCombineShim +import TokamakCore + + +enum GestureEventsObserver { + static var publisher = CurrentValueSubject<_GesturePhase?, Never>(nil) + + private static var pointerdown: JSClosure? + private static var pointermove: JSClosure? + private static var pointerup: JSClosure? + private static var pointercancel: JSClosure? + + static func observe() { + let pointerdown = JSClosure { args -> JSValue in + if let event = args[0].object, + let target = event.target.object, + let x = event.x.jsValue.number, + let y = event.y.jsValue.number, + let rect = target.getBoundingClientRect?(), + let originX = rect.x.number, + let originY = rect.y.number { + + let phase = _GesturePhaseContext( + eventId: String(describing: target.hashValue), + boundsOrigin: CGPoint(x: originX, y: originY), + location: CGPoint(x: x, y: y) + ) + publisher.send(.began(phase)) + } + + return .undefined + } + + let pointermove = JSClosure { args -> JSValue in + if let event = args[0].object, + let x = event.x.jsValue.number, + let y = event.y.jsValue.number { + let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) + publisher.send(.changed(phase)) + } + return .undefined + } + + let pointerup = JSClosure { args -> JSValue in + if let event = args[0].object, + let x = event.x.jsValue.number, + let y = event.y.jsValue.number { + let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) + publisher.send(.ended(phase)) + } + return .undefined + } + + let pointercancel = JSClosure { args -> JSValue in + publisher.send(.cancelled) + return .undefined + } + + _ = document.addEventListener?("pointerdown", pointerdown) + _ = document.addEventListener?("pointermove", pointermove) + _ = document.addEventListener?("pointerup", pointerup) + _ = document.addEventListener?("pointercancel", pointercancel) + + Self.pointerdown = pointerdown + Self.pointermove = pointermove + Self.pointerup = pointerup + Self.pointercancel = pointercancel + } +} diff --git a/Sources/TokamakDOM/Views/Gestures/GestureView.swift b/Sources/TokamakDOM/Views/Gestures/GestureView.swift deleted file mode 100644 index 03101aa23..000000000 --- a/Sources/TokamakDOM/Views/Gestures/GestureView.swift +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright 2020 Tokamak contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// Created by Szymon on 16/7/2023. -// - -import JavaScriptKit -import TokamakCore -import TokamakStaticHTML -import Foundation - -extension TokamakCore._GestureView: DOMPrimitive { - var renderedBody: AnyView { - AnyView( - DynamicHTML("div", [ - "id": gestureId, - "style": "touch-action: none; user-select: none;" - ], listeners: [ - "pointerdown": { event in - guard let target = event.target.object, - let x = event.x.jsValue.number, - let y = event.y.jsValue.number, - let rect = target.getBoundingClientRect?(), - let originX = rect.x.number, - let originY = rect.y.number else { return } - let phase = _GesturePhaseContext( - boundsOrigin: CGPoint(x: originX, y: originY), - location: CGPoint(x: x, y: y) - ) - onPhaseChange(.began(phase), eventId: String(describing: target.hashValue)) - }, - "pointercancel": { event in - onPhaseChange(.cancelled) - } - ]) { - content - }.task { - setupPointerListener() - } - ) - } - - private func setupPointerListener() { - /// Global listeners to track continuous pointer movement, providing real-time position updates even outside the initial target bounds. - let pointermoveClosure = JSClosure { args -> JSValue in - if let event = args[0].object, - let x = event.x.jsValue.number, - let y = event.y.jsValue.number { - let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) - onPhaseChange(.changed(phase)) - } - return .undefined - } - _ = JSObject.global.window.object?.addEventListener?("pointermove", pointermoveClosure) - - /// Global listeners to track continuous pointer up, providing real-time updates even outside the initial target bounds. - let pointerupClosure = JSClosure { args -> JSValue in - if let event = args[0].object, - let x = event.x.jsValue.number, - let y = event.y.jsValue.number { - let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) - onPhaseChange(.ended(phase)) - } - return .undefined - } - _ = JSObject.global.window.object?.addEventListener?("pointerup", pointerupClosure) - } -} diff --git a/Sources/TokamakDOM/Views/Gestures/_GestureView.swift b/Sources/TokamakDOM/Views/Gestures/_GestureView.swift new file mode 100644 index 000000000..3c1dc0d65 --- /dev/null +++ b/Sources/TokamakDOM/Views/Gestures/_GestureView.swift @@ -0,0 +1,30 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 16/7/2023. +// + +import TokamakCore + +extension TokamakCore._GestureView: DOMPrimitive { + var renderedBody: AnyView { + AnyView( + content + .onReceive(GestureEventsObserver.publisher) { phase in + guard let phase else { return } + onPhaseChange(phase) + } + ) + } +} From 98915cc69d0370dc7ffe7e5e48a027a9000ab6b9 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Wed, 23 Aug 2023 20:43:48 +1000 Subject: [PATCH 21/33] remove print --- Sources/TokamakCore/Views/Gestures/_GestureView.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/TokamakCore/Views/Gestures/_GestureView.swift b/Sources/TokamakCore/Views/Gestures/_GestureView.swift index 07b3457ec..0f17b6277 100644 --- a/Sources/TokamakCore/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -92,8 +92,6 @@ public struct _GestureView: _PrimitiveView { // Event being processed by another gestures return } - - print("🟡", eventId, phase, coordinator.gesture) if coordinator.gesture._onPhaseChange(phase) { gestureListener.recognizeGesture(value, for: currentEventId) From b60386afb9ad3b6cf903a33c2c0e8a4804ca3f3b Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Fri, 25 Aug 2023 19:38:28 +1000 Subject: [PATCH 22/33] Add Fibre support for gestures --- Sources/TokamakDOM/App/App.swift | 2 +- .../App/GestureEventsObserver.swift | 10 +++---- Sources/TokamakDOM/DOMFiberRenderer.swift | 2 ++ .../Views/Gestures/_GestureView.swift | 27 ++++++++++++++++++- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/Sources/TokamakDOM/App/App.swift b/Sources/TokamakDOM/App/App.swift index c6f0fd154..97796dd8b 100644 --- a/Sources/TokamakDOM/App/App.swift +++ b/Sources/TokamakDOM/App/App.swift @@ -55,8 +55,8 @@ public extension App { _ = body.appendChild!(div) - GestureEventsObserver.observe() ScenePhaseObserver.observe() + GestureEventsObserver.observe(div) ColorSchemeObserver.observe(div) } diff --git a/Sources/TokamakDOM/App/GestureEventsObserver.swift b/Sources/TokamakDOM/App/GestureEventsObserver.swift index 5e0295a65..5ac268b16 100644 --- a/Sources/TokamakDOM/App/GestureEventsObserver.swift +++ b/Sources/TokamakDOM/App/GestureEventsObserver.swift @@ -29,7 +29,7 @@ enum GestureEventsObserver { private static var pointerup: JSClosure? private static var pointercancel: JSClosure? - static func observe() { + static func observe(_ rootElement: JSObject) { let pointerdown = JSClosure { args -> JSValue in if let event = args[0].object, let target = event.target.object, @@ -75,10 +75,10 @@ enum GestureEventsObserver { return .undefined } - _ = document.addEventListener?("pointerdown", pointerdown) - _ = document.addEventListener?("pointermove", pointermove) - _ = document.addEventListener?("pointerup", pointerup) - _ = document.addEventListener?("pointercancel", pointercancel) + _ = rootElement.addEventListener?("pointerdown", pointerdown) + _ = rootElement.addEventListener?("pointermove", pointermove) + _ = rootElement.addEventListener?("pointerup", pointerup) + _ = rootElement.addEventListener?("pointercancel", pointercancel) Self.pointerdown = pointerdown Self.pointermove = pointermove diff --git a/Sources/TokamakDOM/DOMFiberRenderer.swift b/Sources/TokamakDOM/DOMFiberRenderer.swift index ccd7095c5..35c8452c0 100644 --- a/Sources/TokamakDOM/DOMFiberRenderer.swift +++ b/Sources/TokamakDOM/DOMFiberRenderer.swift @@ -146,6 +146,8 @@ public struct DOMFiberRenderer: FiberRenderer { style.innerHTML = .string(TokamakStaticHTML.tokamakStyles) _ = document.head.appendChild(style) } + + GestureEventsObserver.observe(body) } public static func isPrimitive(_ view: V) -> Bool where V: View { diff --git a/Sources/TokamakDOM/Views/Gestures/_GestureView.swift b/Sources/TokamakDOM/Views/Gestures/_GestureView.swift index 3c1dc0d65..e22e870ad 100644 --- a/Sources/TokamakDOM/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/_GestureView.swift @@ -17,8 +17,11 @@ import TokamakCore +@_spi(TokamakStaticHTML) +import TokamakStaticHTML + extension TokamakCore._GestureView: DOMPrimitive { - var renderedBody: AnyView { + public var renderedBody: AnyView { AnyView( content .onReceive(GestureEventsObserver.publisher) { phase in @@ -28,3 +31,25 @@ extension TokamakCore._GestureView: DOMPrimitive { ) } } + +@_spi(TokamakStaticHTML) +extension TokamakCore._GestureView: HTMLConvertible { + public var tag: String { "div" } + public var listeners: [String : Listener] { [:] } + + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + [:] + } + + public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V : ViewVisitor { + { + $0.visit( + content + .onReceive(GestureEventsObserver.publisher) { phase in + guard let phase else { return } + onPhaseChange(phase) + } + ) + } + } +} From 73d4d7194ba3c021bd00a285e7cbe836db781532 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Fri, 25 Aug 2023 19:44:50 +1000 Subject: [PATCH 23/33] swap to root --- Sources/TokamakDOM/DOMFiberRenderer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TokamakDOM/DOMFiberRenderer.swift b/Sources/TokamakDOM/DOMFiberRenderer.swift index 35c8452c0..5927f5169 100644 --- a/Sources/TokamakDOM/DOMFiberRenderer.swift +++ b/Sources/TokamakDOM/DOMFiberRenderer.swift @@ -147,7 +147,7 @@ public struct DOMFiberRenderer: FiberRenderer { _ = document.head.appendChild(style) } - GestureEventsObserver.observe(body) + GestureEventsObserver.observe(reference) } public static func isPrimitive(_ view: V) -> Bool where V: View { From d89bc061f34851a2e55600e408b2cf85d639a3a7 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sat, 26 Aug 2023 11:55:10 +1000 Subject: [PATCH 24/33] Add tests & clean up --- .../Gestures/Recognizers/DragGesture.swift | 1 + .../Recognizers/LongPressGesture.swift | 1 - .../Modifiers/CoordinateSpaceModifier.swift | 21 +++--- .../App/GestureEventsObserver.swift | 1 - .../Views/Gestures/_GestureView.swift | 2 +- .../Views/Layout/GeometryReader.swift | 17 ++++- .../TokamakTests/SpaceCoordinatesTests.swift | 70 +++++++++++++++++++ 7 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 Tests/TokamakTests/SpaceCoordinatesTests.swift diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 8a63710f8..6432448bd 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -135,6 +135,7 @@ public struct DragGesture: Gesture { if let origin = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { return CoordinateSpace.convert(location, toNamedOrigin: origin) } + print("Coordinate Space not found in active coordinates. Falling back to global.", coordinateSpace) return location } } diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift index 0435e7ff2..d11e9b3e3 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -51,7 +51,6 @@ public struct LongPressGesture: Gesture { let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) guard maximumDistance >= distance else { - print("Failed", distance, maximumDistance, startLocation, context.location ?? startLocation) // Fail longpress if distance is to big. self.startLocation = nil return false diff --git a/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift b/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift index 0a186db89..3c157cbbd 100644 --- a/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift +++ b/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift @@ -22,17 +22,18 @@ struct _CoordinateSpaceModifier: ViewModifier { let name: T public func body(content: Content) -> some View { - content.background { - GeometryReader { proxy in - EmptyView() - .onChange(of: proxy.size, initial: true) { - coordinateSpace.activeCoordinateSpace[.named(name)] = proxy.frame(in: .global).origin - } - .onDisappear { - coordinateSpace.activeCoordinateSpace.removeValue(forKey: .named(name)) - } + content + .background { + GeometryReader { proxy in + EmptyView() + .onChange(of: proxy.size, initial: true) { + coordinateSpace.activeCoordinateSpace[.named(name)] = proxy.frame(in: .global).origin + } + .onDisappear { + coordinateSpace.activeCoordinateSpace.removeValue(forKey: .named(name)) + } + } } - } } } diff --git a/Sources/TokamakDOM/App/GestureEventsObserver.swift b/Sources/TokamakDOM/App/GestureEventsObserver.swift index 5ac268b16..1e7af1d66 100644 --- a/Sources/TokamakDOM/App/GestureEventsObserver.swift +++ b/Sources/TokamakDOM/App/GestureEventsObserver.swift @@ -38,7 +38,6 @@ enum GestureEventsObserver { let rect = target.getBoundingClientRect?(), let originX = rect.x.number, let originY = rect.y.number { - let phase = _GesturePhaseContext( eventId: String(describing: target.hashValue), boundsOrigin: CGPoint(x: originX, y: originY), diff --git a/Sources/TokamakDOM/Views/Gestures/_GestureView.swift b/Sources/TokamakDOM/Views/Gestures/_GestureView.swift index e22e870ad..8094eb569 100644 --- a/Sources/TokamakDOM/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/_GestureView.swift @@ -21,7 +21,7 @@ import TokamakCore import TokamakStaticHTML extension TokamakCore._GestureView: DOMPrimitive { - public var renderedBody: AnyView { + var renderedBody: AnyView { AnyView( content .onReceive(GestureEventsObserver.publisher) { phase in diff --git a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift index d78a9dd99..ee1a46d2f 100644 --- a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift +++ b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift @@ -14,7 +14,9 @@ import Foundation import JavaScriptKit -@_spi(TokamakCore) import TokamakCore +@_spi(TokamakCore) +import TokamakCore +@_spi(TokamakStaticHTML) import TokamakStaticHTML private let ResizeObserver = JSObject.global.ResizeObserver.function! @@ -25,6 +27,19 @@ extension GeometryReader: DOMPrimitive { } } +@_spi(TokamakStaticHTML) +extension GeometryReader: HTMLConvertible { + public var tag: String { "div" } + + public func attributes(useDynamicLayout: Bool) -> [TokamakStaticHTML.HTMLAttribute : String] { + [:] + } + + public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V : ViewVisitor { + return { $0.visit(_GeometryReader(content: content)) } + } +} + struct _GeometryReader: View { final class State: ObservableObject { /** Holds a strong reference to a `JSClosure` instance that has to stay alive as long as diff --git a/Tests/TokamakTests/SpaceCoordinatesTests.swift b/Tests/TokamakTests/SpaceCoordinatesTests.swift new file mode 100644 index 000000000..1bee067d0 --- /dev/null +++ b/Tests/TokamakTests/SpaceCoordinatesTests.swift @@ -0,0 +1,70 @@ +// Copyright 2021 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import XCTest +@testable import TokamakCore + +final class SpaceCoordinatesTests: XCTestCase { + func testCoordinateSpaceEquatable() { + XCTAssertTrue(CoordinateSpace.global == CoordinateSpace.global) + XCTAssertTrue(CoordinateSpace.local == CoordinateSpace.local) + XCTAssertFalse(CoordinateSpace.global == CoordinateSpace.local) + } + + func testCoordinateSpaceHashable() { + let set: Set = [.global, .local, .named("custom")] + XCTAssertEqual(set.count, 3) + } + + func testIsGlobal() { + XCTAssertTrue(CoordinateSpace.global.isGlobal) + XCTAssertFalse(CoordinateSpace.local.isGlobal) + XCTAssertFalse(CoordinateSpace.named("custom").isGlobal) + } + + func testIsLocal() { + XCTAssertTrue(CoordinateSpace.local.isLocal) + XCTAssertFalse(CoordinateSpace.global.isLocal) + XCTAssertFalse(CoordinateSpace.named("custom").isLocal) + } + + func testActiveCoordinateSpaceInitialization() { + let context = CoordinateSpaceContext() + XCTAssertTrue(context.activeCoordinateSpace.isEmpty) + } + + func testActiveCoordinateSpaceUpdate() { + var context = CoordinateSpaceContext() + let origin = CGPoint(x: 10, y: 20) + context.activeCoordinateSpace[.global] = origin + + XCTAssertEqual(context.activeCoordinateSpace[.global], origin) + } + + func testConvertGlobalSpaceCoordinates() { + let rect = CGRect(x: 10, y: 20, width: 30, height: 40) + let namedOrigin = CGPoint(x: 5, y: 10) + let translatedRect = CoordinateSpace.convertGlobalSpaceCoordinates(rect: rect, toNamedOrigin: namedOrigin) + + XCTAssertEqual(translatedRect.origin, CGPoint(x: 5, y: 10)) + XCTAssertEqual(translatedRect.size, CGSize(width: 30, height: 40)) + } + + func testConvertPointToNamedOrigin() { + let point = CGPoint(x: 20, y: 30) + let namedOrigin = CGPoint(x: 5, y: 10) + let translatedPoint = CoordinateSpace.convert(point, toNamedOrigin: namedOrigin) + + XCTAssertEqual(translatedPoint, CGPoint(x: 15, y: 20)) + } +} From 2a7485afc1fe297c01e8fed8b2a8a1d587d060a9 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sat, 26 Aug 2023 13:32:20 +1000 Subject: [PATCH 25/33] Squashed commit of the following: commit 6ad4a06a6d11a461d993cda8697a6fa8d9ebbdc6 Author: Szymon Lorenz Date: Sat Aug 26 13:32:06 2023 +1000 Add demos --- .../Gestures/View+HitTesting.swift | 1 + Sources/TokamakDemo/DOM/URLHashDemo.swift | 8 +- .../Gestures/GestureCoordinateSpaceDemo.swift | 70 +++++++ .../TokamakDemo/Gestures/GesturesDemo.swift | 193 ++++++++++++++++++ Sources/TokamakDemo/TokamakDemo.swift | 4 + 5 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift create mode 100644 Sources/TokamakDemo/Gestures/GesturesDemo.swift diff --git a/Sources/TokamakCore/Gestures/View+HitTesting.swift b/Sources/TokamakCore/Gestures/View+HitTesting.swift index f86035216..559986074 100644 --- a/Sources/TokamakCore/Gestures/View+HitTesting.swift +++ b/Sources/TokamakCore/Gestures/View+HitTesting.swift @@ -28,6 +28,7 @@ extension View { eoFill: Bool = false ) -> some View { // TODO: Add content shape modifier. Verify gesture start against the shape fill area. + // https://github.com/TokamakUI/Tokamak/issues/548 self } } diff --git a/Sources/TokamakDemo/DOM/URLHashDemo.swift b/Sources/TokamakDemo/DOM/URLHashDemo.swift index ff523a6b4..7c3f5194b 100644 --- a/Sources/TokamakDemo/DOM/URLHashDemo.swift +++ b/Sources/TokamakDemo/DOM/URLHashDemo.swift @@ -16,18 +16,18 @@ import JavaScriptKit import TokamakDOM -private let startLocation = JSObject.global.startLocation.object! +private let location = JSObject.global.location.object! private let window = JSObject.global.window.object! private final class HashState: ObservableObject { var onHashChange: JSClosure! @Published - var currentHash = startLocation["hash"].string! + var currentHash = location["hash"].string! init() { let onHashChange = JSClosure { [weak self] _ in - self?.currentHash = startLocation["hash"].string! + self?.currentHash = location["hash"].string! return .undefined } @@ -50,7 +50,7 @@ struct URLHashDemo: View { var body: some View { VStack { Button("Assign random location.hash") { - startLocation["hash"] = .string("\(Int.random(in: 0...1000))") + location["hash"] = .string("\(Int.random(in: 0...1000))") } Text("Current location.hash is \(hashState.currentHash)") } diff --git a/Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift b/Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift new file mode 100644 index 000000000..88b003cfe --- /dev/null +++ b/Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift @@ -0,0 +1,70 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 26/8/2023. +// + +import TokamakShim + +struct GestureCoordinateSpaceDemo: View { + let rows = 16 + let columns = 16 + + struct Rect: Hashable { + let row: Int + let column: Int + } + + @State private var selectedRects: Set = [] + + var body: some View { + VStack(spacing: 0) { + ForEach(0.. Bool { + selectedRects.contains(Rect(row: row, column: column)) + } +} diff --git a/Sources/TokamakDemo/Gestures/GesturesDemo.swift b/Sources/TokamakDemo/Gestures/GesturesDemo.swift new file mode 100644 index 000000000..32729735d --- /dev/null +++ b/Sources/TokamakDemo/Gestures/GesturesDemo.swift @@ -0,0 +1,193 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 26/8/2023. +// + +import TokamakShim +import Foundation + +struct GesturesDemo: View { + @State var count: Int = 0 + @State var countDouble: Int = 0 + @GestureState var isDetectingTap = false + + @GestureState var isDetectingLongPress = false + @State var completedLongPress = false + @State var countLongpress: Int = 0 + + @GestureState var dragAmount = CGSize.zero + @State private var countDragLongPress = 0 + + var body: some View { + HStack(alignment: .top, spacing: 8) { + tapGestures + longPressGestures + dragGestures + } + .padding() + } + + var dragGestures: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Drag Gestures") + + HStack { + Rectangle() + .fill(Color.yellow) + .frame(width: 100, height: 100) + .gesture(DragGesture().updating($dragAmount) { value, state, transaction in + state = value.translation + }.onEnded { value in + print(value) + }) + Text("dragAmount: \(dragAmount.width), \(dragAmount.height)") + } + + HStack { + Rectangle() + .fill(Color.red) + .frame(width: 100, height: 100) + .gesture(DragGesture(minimumDistance: 0) + .onChanged { _ in + self.countDragLongPress += 1 + }) + Text("Drag Count: \(countDragLongPress)") + } + } + } + + var longPressGestures: some View { + VStack(alignment: .leading, spacing: 8) { + Text("LongPress Gestures") + + HStack { + Rectangle() + .fill(self.isDetectingLongPress ? Color.pink : (self.completedLongPress ? Color.purple : Color.gray)) + .frame(width: 100, height: 100) + .gesture(LongPressGesture(minimumDuration: 2) + .updating($isDetectingLongPress) { currentState, gestureState, transaction in + gestureState = currentState + transaction.animation = Animation.easeIn(duration: 2.0) + } + .onEnded { finished in + self.completedLongPress = finished + }) + Text(self.isDetectingLongPress ? "detecting" : (self.completedLongPress ? "completed" : "unknow")) + } + + HStack { + Rectangle() + .fill(Color.orange) + .frame(width: 100, height: 100) + .onLongPressGesture(minimumDuration: 0) { + countLongpress += 1 + } + .onTapGesture() { + fatalError("onTapGesture, should not be called") + } + Text("Long Pressed: \(countLongpress)") + } + } + } + + var tapGestures: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Tap Gestures") + HStack { + Rectangle() + .fill(Color.white) + .frame(width: 100, height: 100) + .onTapGesture { + count += 1 + print("⚪️ gesture") + } + Text("Tap: \(count)") + } + HStack { + Rectangle() + .fill(Color.green) + .frame(width: 100, height: 100) + .onTapGesture(count: 2) { + countDouble += 1 + print("🟢 double gesture") + } + Text("double tap: \(countDouble)") + } + HStack { + Rectangle() + .fill(Color.blue) + .frame(width: 100, height: 100) + .onTapGesture() { + print("🔵 1st gesture") + } + .onTapGesture() { + fatalError("should not be called") + } + Text("1st tap gesture") + } + HStack { + Rectangle() + .fill(Color.pink) + .frame(width: 100, height: 100) + .simultaneousGesture( + TapGesture() + .onEnded({ _ in + print("🩷 simultaneousGesture gesture") + }) + ) + .onTapGesture() { + fatalError("should not be called") + } + .onTapGesture() { + fatalError("should not be called") + } + .simultaneousGesture( + TapGesture() + .onEnded({ _ in + print("🩷 simultaneousGesture 2 gesture") + }) + ) + Text("simultaneousGesture") + } + HStack { + Rectangle() + .fill(Color.purple) + .frame(width: 100, height: 100) + .simultaneousGesture( + TapGesture() + .onEnded({ _ in + fatalError("should not be called") + }) + ) + .onTapGesture() { + fatalError("should not be called") + } + .highPriorityGesture( + TapGesture() + .onEnded({ _ in + fatalError("should not be called") + }) + ) + .highPriorityGesture( + TapGesture() + .onEnded({ _ in + print("🟣 highPriorityGesture 3 gesture") + }) + ) + Text("highPriorityGesture") + } + } + } +} diff --git a/Sources/TokamakDemo/TokamakDemo.swift b/Sources/TokamakDemo/TokamakDemo.swift index 2a6452acd..c9c796c4f 100644 --- a/Sources/TokamakDemo/TokamakDemo.swift +++ b/Sources/TokamakDemo/TokamakDemo.swift @@ -148,6 +148,10 @@ struct TokamakDemoView: View { NavItem("TextField", destination: TextFieldDemo()) NavItem("TextEditor", destination: TextEditorDemo()) } + Section(header: Text("Text")) { + NavItem("Gestures", destination: GesturesDemo()) + NavItem("Gesture & CoordinateSpace", destination: GestureCoordinateSpaceDemo()) + } Section(header: Text("Misc")) { NavItem("Animation", destination: AnimationDemo()) NavItem("Transitions", destination: TransitionDemo()) From 37db9e42c18582b7c85a94704b6f086cb05a8891 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Fri, 1 Sep 2023 19:50:07 +1000 Subject: [PATCH 26/33] fix test --- Tests/TokamakTests/ViewReactToDataChangesTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/TokamakTests/ViewReactToDataChangesTests.swift b/Tests/TokamakTests/ViewReactToDataChangesTests.swift index 656bdb986..6bc83ff6e 100644 --- a/Tests/TokamakTests/ViewReactToDataChangesTests.swift +++ b/Tests/TokamakTests/ViewReactToDataChangesTests.swift @@ -62,12 +62,12 @@ class ViewModifierTests: XCTestCase { XCTAssertEqual(oldCount, 0) } - func testOnChangeWithoutValue() { + func testOnChangeWithInitialValue() { var count = 0 var actionFired = false let contentView = Text("Hello, world!") - .onChange(of: count) { + .onChange(of: count, initial: true) { actionFired = true } From 6a97c98602db4306b547f395d876967157aa0524 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 3 Sep 2023 13:03:02 +1000 Subject: [PATCH 27/33] Remove redundant functionality --- .../TokamakCore/Gestures/GestureMask.swift | 37 +------------------ 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/Sources/TokamakCore/Gestures/GestureMask.swift b/Sources/TokamakCore/Gestures/GestureMask.swift index 64c5d0d2e..801cf0250 100644 --- a/Sources/TokamakCore/Gestures/GestureMask.swift +++ b/Sources/TokamakCore/Gestures/GestureMask.swift @@ -18,7 +18,7 @@ import Foundation /// Options that control how adding a gesture to a view affects other gestures recognized by the view and its subviews. -@frozen public struct GestureMask: Equatable, ExpressibleByArrayLiteral, OptionSet, Sendable { +@frozen public struct GestureMask: OptionSet, Sendable { public typealias RawValue = Int8 public var rawValue: Int8 @@ -28,43 +28,10 @@ import Foundation self.rawValue = rawValue } - // MARK: - Equatable - - public static func == (lhs: GestureMask, rhs: GestureMask) -> Bool { - return lhs.rawValue == rhs.rawValue - } - - // MARK: - ExpressibleByArrayLiteral - - /// Creates a gesture mask from an array of gesture options. - /// - /// - Parameter elements: An array of `GestureMask` elements. - public init(arrayLiteral elements: GestureMask...) { - self.rawValue = elements.reduce(0) { $0 | $1.rawValue } - } - - // MARK: - SetAlgebra - - static var allZeros: GestureMask { - return GestureMask(rawValue: 0) - } - - static func | (lhs: GestureMask, rhs: GestureMask) -> GestureMask { - return GestureMask(rawValue: lhs.rawValue | rhs.rawValue) - } - - static func & (lhs: GestureMask, rhs: GestureMask) -> GestureMask { - return GestureMask(rawValue: lhs.rawValue & rhs.rawValue) - } - - static prefix func ~ (x: GestureMask) -> GestureMask { - return GestureMask(rawValue: ~x.rawValue) - } - // MARK: - Gesture Options /// Enable both the added gesture as well as all other gestures on the view and its subviews. - public static let all: Self = .gesture | .subviews + public static let all: Self = [.gesture, .subviews] /// Enable the added gesture but disable all gestures in the subview hierarchy. public static let gesture: Self = GestureMask(rawValue: 1 << 0) From 39bb53534729f2d3eea5f520ac75bb20af230e06 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Mon, 4 Sep 2023 18:05:02 +1000 Subject: [PATCH 28/33] run checks --- NativeDemo/NSAppDelegate.swift | 2 +- NativeDemo/UIAppDelegate.swift | 2 +- .../TokamakCore/Animation/Animatable.swift | 2 +- Sources/TokamakCore/App/Scenes/Scene.swift | 3 +- .../CoordinateSpace/CoordinateSpace.swift | 40 +- .../CoordinateSpaceEnviroment.swift | 33 +- .../Environment/EnvironmentObject.swift | 2 +- .../Fiber+CustomDebugStringConvertible.swift | 6 +- .../Fiber/FiberReconciler+TreeReducer.swift | 4 +- Sources/TokamakCore/Fiber/FiberRenderer.swift | 3 +- .../Fiber/Layout/StackLayout.swift | 4 +- .../Fiber/Scene/SceneVisitor.swift | 2 +- Sources/TokamakCore/Fiber/ViewArguments.swift | 3 +- Sources/TokamakCore/Fiber/ViewVisitor.swift | 2 +- .../Gestures/Composing/ExclusiveGesture.swift | 38 +- .../Gestures/Composing/SequenceGesture.swift | 39 +- .../Composing/SimultaneousGesture.swift | 37 +- Sources/TokamakCore/Gestures/Gesture.swift | 179 ++++----- .../Gestures/GestureEnvironmentKey.swift | 156 ++++---- .../TokamakCore/Gestures/GestureMask.swift | 37 +- .../TokamakCore/Gestures/GestureState.swift | 43 ++- .../Performing/GestureStateGesture.swift | 79 ++-- .../Gestures/Performing/_ChangedGesture.swift | 64 ++-- .../Gestures/Performing/_EndedGesture.swift | 64 ++-- .../Gestures/Recognizers/DragGesture.swift | 294 ++++++++------- .../Recognizers/LongPressGesture.swift | 265 +++++++------- .../Gestures/Recognizers/TapGesture.swift | 150 ++++---- .../Gestures/View+HitTesting.swift | 34 -- .../TokamakCore/Gestures/_GesturePhase.swift | 46 +-- .../Gestures/_GesturePriority.swift | 6 +- .../Modifiers/CoordinateSpaceModifier.swift | 49 +-- .../Modifiers/OnChangeModifier.swift | 104 +++--- .../Modifiers/OnReceiveModifier.swift | 46 +-- .../Shapes/ContainerRelativeShape.swift | 10 +- .../Gradients/AngularGradient.swift | 8 +- .../Gradients/EllipticalGradient.swift | 8 +- .../Gradients/LinearGradient.swift | 6 +- .../Gradients/RadialGradient.swift | 8 +- .../ShapeStyles/HierarchicalShapeStyle.swift | 4 +- Sources/TokamakCore/StackReconciler.swift | 2 +- Sources/TokamakCore/State/State.swift | 8 +- Sources/TokamakCore/Tokens/Color/Color.swift | 2 +- .../Views/Controls/Selectors/Picker.swift | 2 +- .../Views/Gestures/_GestureView.swift | 274 +++++++------- .../Views/Layout/GeometryReader.swift | 55 +-- Sources/TokamakDOM/App/App.swift | 102 +++--- .../App/GestureEventsObserver.swift | 124 ++++--- Sources/TokamakDOM/DOMFiberRenderer.swift | 2 +- .../Views/Gestures/_GestureView.swift | 52 +-- .../Views/Layout/GeometryReader.swift | 135 +++---- Sources/TokamakDemo/DOM/URLHashDemo.swift | 2 +- .../Gestures/GestureCoordinateSpaceDemo.swift | 95 ++--- .../TokamakDemo/Gestures/GesturesDemo.swift | 346 +++++++++--------- .../Modifiers/_BackgroundStyleModifier.swift | 10 +- Sources/TokamakStaticHTML/Sanitizer.swift | 3 +- .../StaticHTMLFiberRenderer.swift | 6 +- Sources/TokamakStaticHTML/Views/HTML.swift | 6 +- .../Views/Layout/LazyHGrid.swift | 10 +- .../Views/Layout/LazyVGrid.swift | 10 +- .../TokamakStaticHTML/Views/Text/Text.swift | 14 +- .../TokamakTests/SpaceCoordinatesTests.swift | 95 ++--- .../ViewReactToDataChangesTests.swift | 178 ++++----- 62 files changed, 1752 insertions(+), 1663 deletions(-) delete mode 100644 Sources/TokamakCore/Gestures/View+HitTesting.swift diff --git a/NativeDemo/NSAppDelegate.swift b/NativeDemo/NSAppDelegate.swift index 792fc8a55..03f1bd2f6 100644 --- a/NativeDemo/NSAppDelegate.swift +++ b/NativeDemo/NSAppDelegate.swift @@ -18,7 +18,7 @@ import Cocoa import SwiftUI -@NSApplicationMain +@main class AppDelegate: NSObject, NSApplicationDelegate { var window: NSWindow! diff --git a/NativeDemo/UIAppDelegate.swift b/NativeDemo/UIAppDelegate.swift index 2bdb2d8ee..72c645958 100644 --- a/NativeDemo/UIAppDelegate.swift +++ b/NativeDemo/UIAppDelegate.swift @@ -21,7 +21,7 @@ import UIKit // so we only need one Info.plist public class NSApplication: UIApplication {} -@UIApplicationMain +@main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application( diff --git a/Sources/TokamakCore/Animation/Animatable.swift b/Sources/TokamakCore/Animation/Animatable.swift index ca75dcac2..3804facfa 100644 --- a/Sources/TokamakCore/Animation/Animatable.swift +++ b/Sources/TokamakCore/Animation/Animatable.swift @@ -86,7 +86,7 @@ public struct AnimatablePair: VectorArithmetic } @inlinable - internal subscript() -> (First, Second) { + subscript() -> (First, Second) { get { (first, second) } set { (first, second) = newValue } } diff --git a/Sources/TokamakCore/App/Scenes/Scene.swift b/Sources/TokamakCore/App/Scenes/Scene.swift index 65c3122d6..811aad2dd 100644 --- a/Sources/TokamakCore/App/Scenes/Scene.swift +++ b/Sources/TokamakCore/App/Scenes/Scene.swift @@ -28,7 +28,8 @@ public protocol Scene { /// You can `visit(_:)` either another `Scene` or a `View` with a `SceneVisitor` func _visitChildren(_ visitor: V) - /// Create `SceneOutputs`, including any modifications to the environment, preferences, or a custom + /// Create `SceneOutputs`, including any modifications to the environment, preferences, or a + /// custom /// `LayoutComputer` from the `SceneInputs`. /// /// > At the moment, `SceneInputs`/`SceneOutputs` are identical to `ViewInputs`/`ViewOutputs`. diff --git a/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift b/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift index 392b225a5..be4b174e8 100644 --- a/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift +++ b/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift @@ -18,31 +18,31 @@ import Foundation public enum CoordinateSpace { - case global - case local - case named(AnyHashable) + case global + case local + case named(AnyHashable) } extension CoordinateSpace: Equatable, Hashable { - // Equatable and Hashable conformance + // Equatable and Hashable conformance } -extension CoordinateSpace { - public var isGlobal: Bool { - switch self { - case .global: - return true - default: - return false - } +public extension CoordinateSpace { + var isGlobal: Bool { + switch self { + case .global: + return true + default: + return false } - - public var isLocal: Bool { - switch self { - case .local: - return true - default: - return false - } + } + + var isLocal: Bool { + switch self { + case .local: + return true + default: + return false } + } } diff --git a/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift b/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift index 2600e358c..de17286a3 100644 --- a/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift +++ b/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift @@ -18,28 +18,31 @@ import Foundation private struct CoordinateSpaceEnvironmentKey: EnvironmentKey { - static let defaultValue: CoordinateSpaceContext = CoordinateSpaceContext() + static let defaultValue: CoordinateSpaceContext = .init() } extension EnvironmentValues { - var _coordinateSpace: CoordinateSpaceContext { - get { self[CoordinateSpaceEnvironmentKey.self] } - set { self[CoordinateSpaceEnvironmentKey.self] = newValue } - } + var _coordinateSpace: CoordinateSpaceContext { + get { self[CoordinateSpaceEnvironmentKey.self] } + set { self[CoordinateSpaceEnvironmentKey.self] = newValue } + } } class CoordinateSpaceContext { - /// Stores currently active CoordinateSpace against it's origin point in global coordinates - var activeCoordinateSpace: [CoordinateSpace: CGPoint] = [:] + /// Stores currently active CoordinateSpace against it's origin point in global coordinates + var activeCoordinateSpace: [CoordinateSpace: CGPoint] = [:] } extension CoordinateSpace { - static func convertGlobalSpaceCoordinates(rect: CGRect, toNamedOrigin namedOrigin: CGPoint) -> CGRect { - let translatedOrigin = convert(rect.origin, toNamedOrigin: namedOrigin) - return CGRect(origin: translatedOrigin, size: rect.size) - } - - static func convert(_ point: CGPoint, toNamedOrigin namedOrigin: CGPoint) -> CGPoint { - return CGPoint(x: point.x - namedOrigin.x, y: point.y - namedOrigin.y) - } + static func convertGlobalSpaceCoordinates( + rect: CGRect, + toNamedOrigin namedOrigin: CGPoint + ) -> CGRect { + let translatedOrigin = convert(rect.origin, toNamedOrigin: namedOrigin) + return CGRect(origin: translatedOrigin, size: rect.size) + } + + static func convert(_ point: CGPoint, toNamedOrigin namedOrigin: CGPoint) -> CGPoint { + CGPoint(x: point.x - namedOrigin.x, y: point.y - namedOrigin.y) + } } diff --git a/Sources/TokamakCore/Environment/EnvironmentObject.swift b/Sources/TokamakCore/Environment/EnvironmentObject.swift index 59956a31f..898676c2f 100644 --- a/Sources/TokamakCore/Environment/EnvironmentObject.swift +++ b/Sources/TokamakCore/Environment/EnvironmentObject.swift @@ -23,7 +23,7 @@ public struct EnvironmentObject: DynamicProperty { @dynamicMemberLookup public struct Wrapper { - internal let root: ObjectType + let root: ObjectType public subscript( dynamicMember keyPath: ReferenceWritableKeyPath ) -> Binding { diff --git a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift index 49f98bb04..61f339dff 100644 --- a/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift +++ b/Sources/TokamakCore/Fiber/Fiber+CustomDebugStringConvertible.swift @@ -34,8 +34,10 @@ extension FiberReconciler.Fiber: CustomDebugStringConvertible { proposal: .unspecified ) return """ - \(spaces)\(String(describing: typeInfo?.type ?? Any.self) - .split(separator: "<")[0])\(element != nil ? "(\(element!))" : "") {\(element != nil ? + \(spaces)\( + String(describing: typeInfo?.type ?? Any.self) + .split(separator: "<")[0] + )\(element != nil ? "(\(element!))" : "") {\(element != nil ? "\n\(spaces)geometry: \(geometry)" : "") \(child?.flush(level: level + 2) ?? "") diff --git a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift index 1d85dc541..48bb08974 100644 --- a/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift +++ b/Sources/TokamakCore/Fiber/FiberReconciler+TreeReducer.swift @@ -58,7 +58,7 @@ extension FiberReconciler { } static func reduce(into partialResult: inout Result, nextScene: S) where S: Scene { - Self.reduce( + reduce( into: &partialResult, nextValue: nextScene, createFiber: { scene, element, parent, elementParent, preferenceParent, _, _, reconciler in @@ -80,7 +80,7 @@ extension FiberReconciler { } static func reduce(into partialResult: inout Result, nextView: V) where V: View { - Self.reduce( + reduce( into: &partialResult, nextValue: nextView, createFiber: { diff --git a/Sources/TokamakCore/Fiber/FiberRenderer.swift b/Sources/TokamakCore/Fiber/FiberRenderer.swift index 51c6e070b..bbb90f20d 100644 --- a/Sources/TokamakCore/Fiber/FiberRenderer.swift +++ b/Sources/TokamakCore/Fiber/FiberRenderer.swift @@ -64,7 +64,8 @@ public protocol FiberRenderer { /// Run `action` on the next run loop. /// - /// Called by the `FiberReconciler` to perform reconciliation after all changed Fibers are collected. + /// Called by the `FiberReconciler` to perform reconciliation after all changed Fibers are + /// collected. /// /// For example, take the following sample `View`: /// diff --git a/Sources/TokamakCore/Fiber/Layout/StackLayout.swift b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift index 85a6d22e9..d178c312f 100644 --- a/Sources/TokamakCore/Fiber/Layout/StackLayout.swift +++ b/Sources/TokamakCore/Fiber/Layout/StackLayout.swift @@ -75,7 +75,7 @@ public extension StackLayout { /// A `vertical` axis will return `height`. /// A `horizontal` axis will return `width`. static var mainAxis: WritableKeyPath { - switch Self.orientation { + switch orientation { case .vertical: return \.height case .horizontal: return \.width } @@ -86,7 +86,7 @@ public extension StackLayout { /// A `vertical` axis will return `width`. /// A `horizontal` axis will return `height`. static var crossAxis: WritableKeyPath { - switch Self.orientation { + switch orientation { case .vertical: return \.width case .horizontal: return \.height } diff --git a/Sources/TokamakCore/Fiber/Scene/SceneVisitor.swift b/Sources/TokamakCore/Fiber/Scene/SceneVisitor.swift index 51b2e78ec..5c36299fa 100644 --- a/Sources/TokamakCore/Fiber/Scene/SceneVisitor.swift +++ b/Sources/TokamakCore/Fiber/Scene/SceneVisitor.swift @@ -35,7 +35,7 @@ protocol SceneReducer: ViewReducer { extension SceneReducer { static func reduce(into partialResult: inout Result, nextScene: S) { - partialResult = Self.reduce(partialResult: partialResult, nextScene: nextScene) + partialResult = reduce(partialResult: partialResult, nextScene: nextScene) } static func reduce(partialResult: Result, nextScene: S) -> Result { diff --git a/Sources/TokamakCore/Fiber/ViewArguments.swift b/Sources/TokamakCore/Fiber/ViewArguments.swift index 3a2d316e7..041b2d78f 100644 --- a/Sources/TokamakCore/Fiber/ViewArguments.swift +++ b/Sources/TokamakCore/Fiber/ViewArguments.swift @@ -23,7 +23,8 @@ public struct ViewInputs { /// Mutate the underlying content with the given inputs. /// - /// Used to inject values such as environment values, traits, and preferences into the `View` type. + /// Used to inject values such as environment values, traits, and preferences into the `View` + /// type. public let updateContent: ((inout V) -> ()) -> () @_spi(TokamakCore) diff --git a/Sources/TokamakCore/Fiber/ViewVisitor.swift b/Sources/TokamakCore/Fiber/ViewVisitor.swift index 0aa02c9ad..376a422e0 100644 --- a/Sources/TokamakCore/Fiber/ViewVisitor.swift +++ b/Sources/TokamakCore/Fiber/ViewVisitor.swift @@ -37,7 +37,7 @@ protocol ViewReducer { extension ViewReducer { static func reduce(into partialResult: inout Result, nextView: V) { - partialResult = Self.reduce(partialResult: partialResult, nextView: nextView) + partialResult = reduce(partialResult: partialResult, nextView: nextView) } static func reduce(partialResult: Result, nextView: V) -> Result { diff --git a/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift b/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift index be3d1d03b..c7a5d5396 100644 --- a/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift +++ b/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift @@ -17,23 +17,23 @@ @frozen /// The ExclusiveGesture gives precedence to its first gesture. -public struct ExclusiveGesture where First : Gesture, Second : Gesture { - /// The value of an exclusive gesture that indicates which of two gestures succeeded. - public typealias Value = ExclusiveGesture.ExclusiveValue - - public struct ExclusiveValue { - public var first: First.Value - public var second: First.Value - } - - /// The first of two gestures. - public var first: First - /// The second of two gestures. - public var second: Second - - /// Creates a gesture from two gestures where only one of them succeeds. - init(first: First, second: Second) { - self.first = first - self.second = second - } +public struct ExclusiveGesture where First: Gesture, Second: Gesture { + /// The value of an exclusive gesture that indicates which of two gestures succeeded. + public typealias Value = ExclusiveGesture.ExclusiveValue + + public struct ExclusiveValue { + public var first: First.Value + public var second: First.Value + } + + /// The first of two gestures. + public var first: First + /// The second of two gestures. + public var second: Second + + /// Creates a gesture from two gestures where only one of them succeeds. + init(first: First, second: Second) { + self.first = first + self.second = second + } } diff --git a/Sources/TokamakCore/Gestures/Composing/SequenceGesture.swift b/Sources/TokamakCore/Gestures/Composing/SequenceGesture.swift index 698b49666..94483fb07 100644 --- a/Sources/TokamakCore/Gestures/Composing/SequenceGesture.swift +++ b/Sources/TokamakCore/Gestures/Composing/SequenceGesture.swift @@ -16,23 +16,24 @@ // @frozen -public struct SequenceGesture where First : Gesture, Second : Gesture { - /// The value of a sequence gesture that helps to detect whether the first gesture succeeded, so the second gesture can start. - public typealias Value = SequenceGesture.SequenceValue - - public struct SequenceValue { - public var first: First.Value - public var second: First.Value - } - - /// The first gesture in a sequence of two gestures. - public var first: First - /// The second gesture in a sequence of two gestures. - public var second: Second - - /// Creates a sequence gesture with two gestures. - init(first: First, second: Second) { - self.first = first - self.second = second - } +public struct SequenceGesture where First: Gesture, Second: Gesture { + /// The value of a sequence gesture that helps to detect whether the first gesture succeeded, so + /// the second gesture can start. + public typealias Value = SequenceGesture.SequenceValue + + public struct SequenceValue { + public var first: First.Value + public var second: First.Value + } + + /// The first gesture in a sequence of two gestures. + public var first: First + /// The second gesture in a sequence of two gestures. + public var second: Second + + /// Creates a sequence gesture with two gestures. + init(first: First, second: Second) { + self.first = first + self.second = second + } } diff --git a/Sources/TokamakCore/Gestures/Composing/SimultaneousGesture.swift b/Sources/TokamakCore/Gestures/Composing/SimultaneousGesture.swift index 0aea6c3e4..21ac858b0 100644 --- a/Sources/TokamakCore/Gestures/Composing/SimultaneousGesture.swift +++ b/Sources/TokamakCore/Gestures/Composing/SimultaneousGesture.swift @@ -16,22 +16,23 @@ // @frozen -public struct SimultaneousGesture where First : Gesture, Second : Gesture { - public typealias Value = SimultaneousGesture.SimultaneousValue - - public struct SimultaneousValue { - public let first: First.Value? - public let second: First.Value? - } - - /// The first of two gestures that can happen simultaneously. - public let first: First - /// The second of two gestures that can happen simultaneously. - public let second: Second - - /// Creates a gesture with two gestures that can receive updates or succeed independently of each other. - init(first: First, second: Second) { - self.first = first - self.second = second - } +public struct SimultaneousGesture where First: Gesture, Second: Gesture { + public typealias Value = SimultaneousGesture.SimultaneousValue + + public struct SimultaneousValue { + public let first: First.Value? + public let second: First.Value? + } + + /// The first of two gestures that can happen simultaneously. + public let first: First + /// The second of two gestures that can happen simultaneously. + public let second: Second + + /// Creates a gesture with two gestures that can receive updates or succeed independently of each + /// other. + init(first: First, second: Second) { + self.first = first + self.second = second + } } diff --git a/Sources/TokamakCore/Gestures/Gesture.swift b/Sources/TokamakCore/Gestures/Gesture.swift index 4cceea22f..5721da9c8 100644 --- a/Sources/TokamakCore/Gestures/Gesture.swift +++ b/Sources/TokamakCore/Gestures/Gesture.swift @@ -18,71 +18,74 @@ import Foundation public protocol Gesture { - // MARK: Required - - /// The type representing the gesture’s value. - associatedtype Value - - /// The type of gesture representing the body of Self. - associatedtype Body: Gesture - - /// The content and behavior of the gesture. - var body: Self.Body { get } - - /// Adds an action to perform when the gesture’s phase changes. - /// - Parameter phase: Gesture new phase - /// - Returns: Returns `true` if the gesture is recognized, false otherwise. - mutating func _onPhaseChange(_ phase: _GesturePhase) -> Bool - /// Adds an action to perform when the gesture’s value changes. - func _onChanged(perform action: @escaping (Value) -> Void) -> Self - /// Adds an action to perform when the gesture ends. - func _onEnded(perform action: @escaping (Value) -> Void) -> Self + // MARK: Required + + /// The type representing the gesture’s value. + associatedtype Value + + /// The type of gesture representing the body of Self. + associatedtype Body: Gesture + + /// The content and behavior of the gesture. + var body: Self.Body { get } + + /// Adds an action to perform when the gesture’s phase changes. + /// - Parameter phase: Gesture new phase + /// - Returns: Returns `true` if the gesture is recognized, false otherwise. + mutating func _onPhaseChange(_ phase: _GesturePhase) -> Bool + /// Adds an action to perform when the gesture’s value changes. + func _onChanged(perform action: @escaping (Value) -> ()) -> Self + /// Adds an action to perform when the gesture ends. + func _onEnded(perform action: @escaping (Value) -> ()) -> Self } // MARK: Performing the gesture -extension Gesture { - /// Adds an action to perform when the gesture ends. - public func onEnded(_ action: @escaping (Self.Value) -> Void) -> _EndedGesture { - _EndedGesture(self, onEnded: action) - } - - /// Updates the provided gesture value property as the gesture’s value changes. - public func updating( - _ state: GestureState, - body: @escaping (Self.Value, inout State, inout Transaction) -> Void - ) -> GestureStateGesture { - GestureStateGesture(base: self, state: state, updatingBody: body) - } +public extension Gesture { + /// Adds an action to perform when the gesture ends. + func onEnded(_ action: @escaping (Self.Value) -> ()) -> _EndedGesture { + _EndedGesture(self, onEnded: action) + } + + /// Updates the provided gesture value property as the gesture’s value changes. + func updating( + _ state: GestureState, + body: @escaping (Self.Value, inout State, inout Transaction) -> () + ) -> GestureStateGesture { + GestureStateGesture(base: self, state: state, updatingBody: body) + } } // MARK: Performing the gesture -extension Gesture where Value: Equatable { - /// Adds an action to perform when the gesture’s value changes. - /// Available when Value conforms to Equatable. - public func onChanged(_ action: @escaping (Self.Value) -> Void) -> _ChangedGesture { - _ChangedGesture(self, onChanged: action) - } +public extension Gesture where Value: Equatable { + /// Adds an action to perform when the gesture’s value changes. + /// Available when Value conforms to Equatable. + func onChanged(_ action: @escaping (Self.Value) -> ()) -> _ChangedGesture { + _ChangedGesture(self, onChanged: action) + } } // MARK: Composing gestures -extension Gesture { - /// Combines a gesture with another gesture to create a new gesture that recognizes both gestures at the same time. - public func simultaneously(with gesture: Other) -> SimultaneousGesture { - SimultaneousGesture(first: self, second: gesture) - } - - /// Sequences a gesture with another one to create a new gesture, which results in the second gesture only receiving events after the first gesture succeeds. - public func sequenced(before gesture: Other) -> SequenceGesture { - SequenceGesture(first: self, second: gesture) - } - - /// Combines two gestures exclusively to create a new gesture where only one gesture succeeds, giving precedence to the first gesture. - public func exclusively(before gesture: Other) -> ExclusiveGesture { - ExclusiveGesture(first: self, second: gesture) - } +public extension Gesture { + /// Combines a gesture with another gesture to create a new gesture that recognizes both gestures + /// at the same time. + func simultaneously(with gesture: Other) -> SimultaneousGesture { + SimultaneousGesture(first: self, second: gesture) + } + + /// Sequences a gesture with another one to create a new gesture, which results in the second + /// gesture only receiving events after the first gesture succeeds. + func sequenced(before gesture: Other) -> SequenceGesture { + SequenceGesture(first: self, second: gesture) + } + + /// Combines two gestures exclusively to create a new gesture where only one gesture succeeds, + /// giving precedence to the first gesture. + func exclusively(before gesture: Other) -> ExclusiveGesture { + ExclusiveGesture(first: self, second: gesture) + } } // MARK: Transforming a gesture @@ -92,39 +95,39 @@ extension Gesture {} // MARK: Private Helpers extension Gesture { - func calculateDistance(xOffset: Double, yOffset: Double) -> Double { - let xSquared = pow(xOffset, 2) - let ySquared = pow(yOffset, 2) - let sumOfSquares = xSquared + ySquared - let distance = sqrt(sumOfSquares) - return distance - } - - func calculateTranslation(from pointA: CGPoint, to pointB: CGPoint) -> CGSize { - let dx = pointB.x - pointA.x - let dy = pointB.y - pointA.y - return CGSize(width: dx, height: dy) - } - - func calculateVelocity(from translation: CGSize, timeElapsed: Double) -> CGSize { - guard timeElapsed > .zero else { return .zero } - let velocityX = translation.width / timeElapsed - let velocityY = translation.height / timeElapsed - - return CGSize(width: velocityX, height: velocityY) - } - - func calculatePredictedEndLocation(from location: CGPoint, velocity: CGSize) -> CGPoint { - let predictedX = location.x + velocity.width - let predictedY = location.y + velocity.height - - return CGPoint(x: predictedX, y: predictedY) - } - - func calculatePredictedEndTranslation(from translation: CGSize, velocity: CGSize) -> CGSize { - let predictedWidth = translation.width + velocity.width - let predictedHeight = translation.height + velocity.height - - return CGSize(width: predictedWidth, height: predictedHeight) - } + func calculateDistance(xOffset: Double, yOffset: Double) -> Double { + let xSquared = pow(xOffset, 2) + let ySquared = pow(yOffset, 2) + let sumOfSquares = xSquared + ySquared + let distance = sqrt(sumOfSquares) + return distance + } + + func calculateTranslation(from pointA: CGPoint, to pointB: CGPoint) -> CGSize { + let dx = pointB.x - pointA.x + let dy = pointB.y - pointA.y + return CGSize(width: dx, height: dy) + } + + func calculateVelocity(from translation: CGSize, timeElapsed: Double) -> CGSize { + guard timeElapsed > .zero else { return .zero } + let velocityX = translation.width / timeElapsed + let velocityY = translation.height / timeElapsed + + return CGSize(width: velocityX, height: velocityY) + } + + func calculatePredictedEndLocation(from location: CGPoint, velocity: CGSize) -> CGPoint { + let predictedX = location.x + velocity.width + let predictedY = location.y + velocity.height + + return CGPoint(x: predictedX, y: predictedY) + } + + func calculatePredictedEndTranslation(from translation: CGSize, velocity: CGSize) -> CGSize { + let predictedWidth = translation.width + velocity.width + let predictedHeight = translation.height + velocity.height + + return CGSize(width: predictedWidth, height: predictedHeight) + } } diff --git a/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift index 2b884b9d8..5a3582b80 100644 --- a/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift +++ b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift @@ -16,98 +16,102 @@ // private struct GestureEnvironmentKey: EnvironmentKey { - static let defaultValue: GestureContext = GestureContext() + static let defaultValue: GestureContext = .init() } extension EnvironmentValues { - /// An environment value that provides a central hub for managing gesture recognition and priority handling. - var _gestureListener: GestureContext { - get { self[GestureEnvironmentKey.self] } - set { self[GestureEnvironmentKey.self] = newValue } - } + /// An environment value that provides a central hub for managing gesture recognition and priority + /// handling. + var _gestureListener: GestureContext { + get { self[GestureEnvironmentKey.self] } + set { self[GestureEnvironmentKey.self] = newValue } + } } final class GestureContext { - // MARK: Gesture Management - - /// A dictionary that tracks active gestures for different events, organized by event name. - var activeGestures: [String: Set] = [:] - - /// Registers the start of a gesture for a specific event, respecting priority levels. - /// - /// - Parameters: - /// - gesture: The gesture to be registered. - /// - event: The name of the event associated with the gesture. - func registerStart(_ gesture: GestureValue, for event: String) { - if activeGestures[event] == nil { - activeGestures[event] = [gesture] - } else if case .highPriority = gesture.priority { - activeGestures[event] = [gesture] - } else { - activeGestures[event]?.insert(gesture) - } + // MARK: Gesture Management + + /// A dictionary that tracks active gestures for different events, organized by event name. + var activeGestures: [String: Set] = [:] + + /// Registers the start of a gesture for a specific event, respecting priority levels. + /// + /// - Parameters: + /// - gesture: The gesture to be registered. + /// - event: The name of the event associated with the gesture. + func registerStart(_ gesture: GestureValue, for event: String) { + if activeGestures[event] == nil { + activeGestures[event] = [gesture] + } else if case .highPriority = gesture.priority { + activeGestures[event] = [gesture] + } else { + activeGestures[event]?.insert(gesture) } - - /// Recognizes a gesture for a specific event, considering its priority and adjusting active gestures accordingly. - /// - /// - Parameters: - /// - gesture: The gesture to be recognized. - /// - event: The name of the event associated with the gesture. - func recognizeGesture(_ gesture: GestureValue, for event: String) { - guard activeGestures[event]?.contains(gesture) == true else { - return - } - var gestures: Set = activeGestures[event]?.removeLowerPriorities(than: gesture.priority) ?? [] - gestures.insert(gesture) - activeGestures[event] = gestures + } + + /// Recognizes a gesture for a specific event, considering its priority and adjusting active + /// gestures accordingly. + /// + /// - Parameters: + /// - gesture: The gesture to be recognized. + /// - event: The name of the event associated with the gesture. + func recognizeGesture(_ gesture: GestureValue, for event: String) { + guard activeGestures[event]?.contains(gesture) == true else { + return } - - /// Checks if a gesture can be processed for a specific event, considering its recognition status and priority. - /// - /// - Parameters: - /// - gesture: The gesture to be checked. - /// - event: The name of the event associated with the gesture. - /// - Returns: `true` if the gesture can be processed, `false` otherwise. - func canProcessGesture(_ gesture: GestureValue, for event: String) -> Bool { - guard activeGestures[event]?.contains(gesture) == true else { - return false - } - return true + var gestures: Set = activeGestures[event]? + .removeLowerPriorities(than: gesture.priority) ?? [] + gestures.insert(gesture) + activeGestures[event] = gestures + } + + /// Checks if a gesture can be processed for a specific event, considering its recognition status + /// and priority. + /// + /// - Parameters: + /// - gesture: The gesture to be checked. + /// - event: The name of the event associated with the gesture. + /// - Returns: `true` if the gesture can be processed, `false` otherwise. + func canProcessGesture(_ gesture: GestureValue, for event: String) -> Bool { + guard activeGestures[event]?.contains(gesture) == true else { + return false } + return true + } } struct GestureValue: Hashable { - // MARK: Gesture Metadata - - /// A unique identifier for the gesture. - let gestureId: String - - /// A mask that defines the type of gesture. - let mask: GestureMask - - /// The priority level of the gesture. - let priority: _GesturePriority - - func hash(into hasher: inout Hasher) { - hasher.combine(gestureId) - } + // MARK: Gesture Metadata + + /// A unique identifier for the gesture. + let gestureId: String + + /// A mask that defines the type of gesture. + let mask: GestureMask + + /// The priority level of the gesture. + let priority: _GesturePriority + + func hash(into hasher: inout Hasher) { + hasher.combine(gestureId) + } } // MARK: Helpers private extension Set where Element == GestureValue { - /// Removes gestures with lower priorities than the given priority. - /// - /// - Parameter priority: The priority to compare against. - /// - Returns: A filtered set containing only gestures with equal or higher priorities. - func removeLowerPriorities(than priority: _GesturePriority) -> Self { - return self.filter { - switch priority { - case .standard, .simultaneous: - return $0.priority != .standard - case .highPriority: - return $0.priority == .highPriority - } - } + /// Removes gestures with lower priorities than the given priority. + /// + /// - Parameter priority: The priority to compare against. + /// - Returns: A filtered set containing only gestures with equal or higher priorities. + func removeLowerPriorities(than priority: _GesturePriority) -> Self { + filter { + switch priority { + case .standard, .simultaneous: + return $0.priority != .standard + case .highPriority: + return $0.priority == .highPriority + } } + } } diff --git a/Sources/TokamakCore/Gestures/GestureMask.swift b/Sources/TokamakCore/Gestures/GestureMask.swift index 801cf0250..e39f55fee 100644 --- a/Sources/TokamakCore/Gestures/GestureMask.swift +++ b/Sources/TokamakCore/Gestures/GestureMask.swift @@ -17,29 +17,30 @@ import Foundation -/// Options that control how adding a gesture to a view affects other gestures recognized by the view and its subviews. -@frozen public struct GestureMask: OptionSet, Sendable { - public typealias RawValue = Int8 - public var rawValue: Int8 +/// Options that control how adding a gesture to a view affects other gestures recognized by the +/// view and its subviews. +@frozen +public struct GestureMask: OptionSet, Sendable { + public typealias RawValue = Int8 + public var rawValue: Int8 - // MARK: - OptionSet + // MARK: - OptionSet - public init(rawValue: Int8) { - self.rawValue = rawValue - } + public init(rawValue: Int8) { + self.rawValue = rawValue + } - // MARK: - Gesture Options + // MARK: - Gesture Options - /// Enable both the added gesture as well as all other gestures on the view and its subviews. - public static let all: Self = [.gesture, .subviews] + /// Enable both the added gesture as well as all other gestures on the view and its subviews. + public static let all: Self = [.gesture, .subviews] - /// Enable the added gesture but disable all gestures in the subview hierarchy. - public static let gesture: Self = GestureMask(rawValue: 1 << 0) + /// Enable the added gesture but disable all gestures in the subview hierarchy. + public static let gesture: Self = GestureMask(rawValue: 1 << 0) - /// Enable all gestures in the subview hierarchy but disable the added gesture. - public static let subviews: Self = GestureMask(rawValue: 1 << 1) + /// Enable all gestures in the subview hierarchy but disable the added gesture. + public static let subviews: Self = GestureMask(rawValue: 1 << 1) - /// Disable all gestures in the subview hierarchy, including the added gesture. - public static let none: Self = [] + /// Disable all gestures in the subview hierarchy, including the added gesture. + public static let none: Self = [] } - diff --git a/Sources/TokamakCore/Gestures/GestureState.swift b/Sources/TokamakCore/Gestures/GestureState.swift index e8f9dde4a..9f7496af0 100644 --- a/Sources/TokamakCore/Gestures/GestureState.swift +++ b/Sources/TokamakCore/Gestures/GestureState.swift @@ -15,33 +15,32 @@ // Created by Szymon on 16/7/2023. // - @propertyWrapper public struct GestureState: DynamicProperty { - private let initialValue: Value - - var anyInitialValue: Any { initialValue } - - var getter: (() -> Any)? - var setter: ((Any, Transaction) -> ())? - - public init(wrappedValue value: Value) { - initialValue = value - } - - public var wrappedValue: Value { - get { getter?() as? Value ?? initialValue } - nonmutating set { setter?(newValue, Transaction._active ?? .init(animation: nil)) } - } - - public var projectedValue: GestureState { - self - } + private let initialValue: Value + + var anyInitialValue: Any { initialValue } + + var getter: (() -> Any)? + var setter: ((Any, Transaction) -> ())? + + public init(wrappedValue value: Value) { + initialValue = value + } + + public var wrappedValue: Value { + get { getter?() as? Value ?? initialValue } + nonmutating set { setter?(newValue, Transaction._active ?? .init(animation: nil)) } + } + + public var projectedValue: GestureState { + self + } } extension GestureState: WritableValueStorage {} public extension GestureState where Value: ExpressibleByNilLiteral { - @inlinable - init() { self.init(wrappedValue: nil) } + @inlinable + init() { self.init(wrappedValue: nil) } } diff --git a/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift b/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift index 13e0fb89f..317109110 100644 --- a/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift +++ b/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift @@ -16,43 +16,50 @@ // public struct GestureStateGesture: Gesture { - public typealias Value = Base.Value - - @GestureState private var gestureState: State - private var gesture: Base - - private let updatingBody: (Base.Value, inout State, inout Transaction) -> Void - private var onEnded: ((Value) -> Void)? - - public var body: Base.Body { - var gesture = gesture._onChanged(perform: { value in - // TODO: Is this transaction working? - var transaction = Transaction._active ?? .init(animation: nil) - updatingBody(value, &gestureState, &transaction) - }) - if let onEnded { - gesture = gesture._onEnded(perform: onEnded) - } - return gesture.body - } - - init(base: Base, state: GestureState, updatingBody: @escaping (Base.Value, inout State, inout Transaction) -> Void) { - self.gesture = base - self._gestureState = state - self.updatingBody = updatingBody - } + public typealias Value = Base.Value - mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { - fatalError("\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called.") - } - - public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onEnded = action - return gesture - } + @GestureState + private var gestureState: State + private var gesture: Base + + private let updatingBody: (Base.Value, inout State, inout Transaction) -> () + private var onEnded: ((Value) -> ())? - public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { - self + public var body: Base.Body { + var gesture = gesture._onChanged(perform: { value in + // TODO: Is this transaction working? + var transaction = Transaction._active ?? .init(animation: nil) + updatingBody(value, &gestureState, &transaction) + }) + if let onEnded { + gesture = gesture._onEnded(perform: onEnded) } + return gesture.body + } + + init( + base: Base, + state: GestureState, + updatingBody: @escaping (Base.Value, inout State, inout Transaction) -> () + ) { + gesture = base + _gestureState = state + self.updatingBody = updatingBody + } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + fatalError( + "\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called." + ) + } + + public func _onEnded(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onEnded = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> ()) -> Self { + self + } } diff --git a/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift b/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift index 613704c56..69e6c8ead 100644 --- a/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift +++ b/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift @@ -16,38 +16,40 @@ // public struct _ChangedGesture: Gesture { - public typealias Value = Base.Value - - private var gesture: Base - private var onChanged: (Base.Value) -> Void - private var onEnded: ((Value) -> Void)? - - public var body: Base.Body { - var gesture = gesture._onChanged(perform: onChanged) - if let onEnded { - gesture = gesture._onEnded(perform: onEnded) - } - return gesture.body - } - - init(_ gesture: Base, onChanged: @escaping (Base.Value) -> Void) { - self.gesture = gesture - self.onChanged = onChanged - } + public typealias Value = Base.Value - mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { - fatalError("\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called.") - } - - public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onEnded = action - return gesture - } + private var gesture: Base + private var onChanged: (Base.Value) -> () + private var onEnded: ((Value) -> ())? - public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onChanged = action - return gesture + public var body: Base.Body { + var gesture = gesture._onChanged(perform: onChanged) + if let onEnded { + gesture = gesture._onEnded(perform: onEnded) } + return gesture.body + } + + init(_ gesture: Base, onChanged: @escaping (Base.Value) -> ()) { + self.gesture = gesture + self.onChanged = onChanged + } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + fatalError( + "\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called." + ) + } + + public func _onEnded(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onEnded = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onChanged = action + return gesture + } } diff --git a/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift b/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift index 207b752ab..2cbac375b 100644 --- a/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift +++ b/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift @@ -16,38 +16,40 @@ // public struct _EndedGesture: Gesture { - public typealias Value = Base.Value - - private var gesture: Base - private var onEnded: (Value) -> Void - private var onChanged: ((Value) -> Void)? - - public var body: Base.Body { - var gesture = gesture._onEnded(perform: onEnded) - if let onChanged { - gesture = gesture._onChanged(perform: onChanged) - } - return gesture.body - } - - init(_ gesture: Base, onEnded: @escaping (Value) -> Void) { - self.gesture = gesture - self.onEnded = onEnded - } + public typealias Value = Base.Value - mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { - fatalError("\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called.") - } - - public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onEnded = action - return gesture - } + private var gesture: Base + private var onEnded: (Value) -> () + private var onChanged: ((Value) -> ())? - public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onChanged = action - return gesture + public var body: Base.Body { + var gesture = gesture._onEnded(perform: onEnded) + if let onChanged { + gesture = gesture._onChanged(perform: onChanged) } + return gesture.body + } + + init(_ gesture: Base, onEnded: @escaping (Value) -> ()) { + self.gesture = gesture + self.onEnded = onEnded + } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + fatalError( + "\(String(reflecting: Self.self)) is a proxy `Gesture`, onPhaseChange should never be called." + ) + } + + public func _onEnded(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onEnded = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onChanged = action + return gesture + } } diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 6432448bd..268cc3d66 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -19,143 +19,163 @@ import Foundation import OpenCombine public struct DragGesture: Gesture { - @Environment(\._coordinateSpace) private var coordinates - private var globalOrigin: CGPoint? = nil - private var startLocation: CGPoint? = nil - private var previousTimestamp: Date? - private var velocity: CGSize = .zero - private var onEndedAction: ((Value) -> Void)? = nil - private var onChangedAction: ((Value) -> Void)? = nil - private var minimumDistance: Double - private var coordinateSpace: CoordinateSpace - - public var body: DragGesture { - self - } - - /// Creates a dragging gesture with the minimum dragging distance before the gesture succeeds and the coordinate space of the gesture’s location. - /// By default, the minimum distance needed to recognize a gesture is 10. - /// - Parameters: - /// - minimumDistance: The minimum dragging distance before the gesture succeeds. - /// - coordinateSpace: The coordinate space in which to receive location values. - public init( - minimumDistance: CGFloat = 10, - coordinateSpace: CoordinateSpace = .local - ) { - self.minimumDistance = minimumDistance - self.coordinateSpace = coordinateSpace - } - - mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { - switch phase { - case .began(let context): - globalOrigin = context.boundsOrigin - startLocation = context.location - previousTimestamp = nil - velocity = .zero - case .changed(let context) where startLocation != nil: - guard let startLocation, let location = context.location else { return false } - let translation = calculateTranslation(from: startLocation, to: location) - let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) - - // Do nothing if gesture has not met the criteria - guard minimumDistance < distance else { return false } - let currentTimestamp = Date() - let timeElapsed = Double(currentTimestamp.timeIntervalSince(previousTimestamp ?? currentTimestamp)) - let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) - let newOrigin = context.boundsOrigin ?? globalOrigin - - // Predict end location based on velocity - let predictedEndLocation = calculatePredictedEndLocation(from: location, velocity: velocity) - - // Predict end translation based on velocity - let predictedEndTranslation = calculatePredictedEndTranslation(from: translation, velocity: velocity) - onChangedAction?( - Value( - startLocation: converLocation(startLocation), - location: converLocation(location), - predictedEndLocation: converLocation(predictedEndLocation), - translation: translation, - predictedEndTranslation: predictedEndTranslation - ) - ) - - self.velocity = velocity - self.globalOrigin = newOrigin - self.previousTimestamp = currentTimestamp - - return true - case .changed: - break - case .ended(let context): - let didRecognize = previousTimestamp != nil - if didRecognize, let startLocation, let location = context.location { - let translation = calculateTranslation(from: startLocation, to: location) - self.globalOrigin = context.boundsOrigin ?? globalOrigin - onEndedAction?( - Value( - startLocation: converLocation(startLocation), - location: converLocation(location), - predictedEndLocation: converLocation(location), - translation: translation, - predictedEndTranslation: translation - ) - ) - } - startLocation = nil - return didRecognize - case .cancelled: - startLocation = nil - } - return false - } - - public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onEndedAction = action - return gesture - } - - public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onChangedAction = action - return gesture - } - - private func converLocation(_ location: CGPoint) -> CGPoint { - switch coordinateSpace { - case .global: - return location - case .local: - if let origin = globalOrigin { - return CoordinateSpace.convert(location, toNamedOrigin: origin) - } - return location - case .named(let name): - if let origin = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { - return CoordinateSpace.convert(location, toNamedOrigin: origin) - } - print("Coordinate Space not found in active coordinates. Falling back to global.", coordinateSpace) - return location - } + @Environment(\._coordinateSpace) + private var coordinates + private var globalOrigin: CGPoint? = nil + private var startLocation: CGPoint? = nil + private var previousTimestamp: Date? + private var velocity: CGSize = .zero + private var onEndedAction: ((Value) -> ())? = nil + private var onChangedAction: ((Value) -> ())? = nil + private var minimumDistance: Double + private var coordinateSpace: CoordinateSpace + + public var body: DragGesture { + self + } + + /// Creates a dragging gesture with the minimum dragging distance before the gesture succeeds and + /// the coordinate space of the gesture’s location. + /// By default, the minimum distance needed to recognize a gesture is 10. + /// - Parameters: + /// - minimumDistance: The minimum dragging distance before the gesture succeeds. + /// - coordinateSpace: The coordinate space in which to receive location values. + public init( + minimumDistance: CGFloat = 10, + coordinateSpace: CoordinateSpace = .local + ) { + self.minimumDistance = minimumDistance + self.coordinateSpace = coordinateSpace + } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + switch phase { + case let .began(context): + globalOrigin = context.boundsOrigin + startLocation = context.location + previousTimestamp = nil + velocity = .zero + case let .changed(context) where startLocation != nil: + guard let startLocation, let location = context.location else { return false } + let translation = calculateTranslation(from: startLocation, to: location) + let distance = calculateDistance( + xOffset: translation.width, + yOffset: translation.height + ) + + // Do nothing if gesture has not met the criteria + guard minimumDistance < distance else { return false } + let currentTimestamp = Date() + let timeElapsed = Double( + currentTimestamp + .timeIntervalSince(previousTimestamp ?? currentTimestamp) + ) + let velocity = calculateVelocity(from: translation, timeElapsed: timeElapsed) + let newOrigin = context.boundsOrigin ?? globalOrigin + + // Predict end location based on velocity + let predictedEndLocation = calculatePredictedEndLocation( + from: location, + velocity: velocity + ) + + // Predict end translation based on velocity + let predictedEndTranslation = calculatePredictedEndTranslation( + from: translation, + velocity: velocity + ) + onChangedAction?( + Value( + startLocation: converLocation(startLocation), + location: converLocation(location), + predictedEndLocation: converLocation(predictedEndLocation), + translation: translation, + predictedEndTranslation: predictedEndTranslation + ) + ) + + self.velocity = velocity + globalOrigin = newOrigin + previousTimestamp = currentTimestamp + + return true + case .changed: + break + case let .ended(context): + let didRecognize = previousTimestamp != nil + if didRecognize, let startLocation, let location = context.location { + let translation = calculateTranslation(from: startLocation, to: location) + globalOrigin = context.boundsOrigin ?? globalOrigin + onEndedAction?( + Value( + startLocation: converLocation(startLocation), + location: converLocation(location), + predictedEndLocation: converLocation(location), + translation: translation, + predictedEndTranslation: translation + ) + ) + } + startLocation = nil + return didRecognize + case .cancelled: + startLocation = nil } - - // MARK: Types - - public struct Value: Equatable { - /// The location of the drag gesture’s first event. - public var startLocation: CGPoint = .zero - - /// The location of the drag gesture’s current event. - public var location: CGPoint = .zero - - /// A prediction, based on the current drag velocity, of where the final location will be if dragging stopped now. - public var predictedEndLocation: CGPoint = .zero - - /// The total translation from the start of the drag gesture to the current event of the drag gesture. - public var translation: CGSize = .zero - - /// A prediction, based on the current drag velocity, of what the final translation will be if dragging stopped now. - public var predictedEndTranslation: CGSize = .zero + return false + } + + public func _onEnded(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onEndedAction = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onChangedAction = action + return gesture + } + + private func converLocation(_ location: CGPoint) -> CGPoint { + switch coordinateSpace { + case .global: + return location + case .local: + if let origin = globalOrigin { + return CoordinateSpace.convert(location, toNamedOrigin: origin) + } + return location + case let .named(name): + if let origin = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { + return CoordinateSpace.convert(location, toNamedOrigin: origin) + } + print( + "Coordinate Space not found in active coordinates. Falling back to global.", + coordinateSpace + ) + return location } + } + + // MARK: Types + + public struct Value: Equatable { + /// The location of the drag gesture’s first event. + public var startLocation: CGPoint = .zero + + /// The location of the drag gesture’s current event. + public var location: CGPoint = .zero + + /// A prediction, based on the current drag velocity, of where the final location will be if + /// dragging stopped now. + public var predictedEndLocation: CGPoint = .zero + + /// The total translation from the start of the drag gesture to the current event of the drag + /// gesture. + public var translation: CGSize = .zero + + /// A prediction, based on the current drag velocity, of what the final translation will be if + /// dragging stopped now. + public var predictedEndTranslation: CGSize = .zero + } } diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift index d11e9b3e3..334c954f0 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -18,140 +18,153 @@ import Foundation public struct LongPressGesture: Gesture { - public typealias Value = Bool - - private var startLocation: CGPoint? = nil - private var touchStartTime = Date(timeIntervalSince1970: 0) - private var maximumDistance: Double - private var onEndedAction: ((Value) -> Void)? = nil - private var onChangedAction: ((Value) -> Void)? = nil - public private(set) var minimumDuration: Double - public var body: LongPressGesture { - self - } - - /// Creates a long-press gesture with a minimum duration and a maximum distance that the interaction can move before the gesture fails. - /// - Parameters: - /// - minimumDuration: The minimum duration of the long press that must elapse before the gesture succeeds. - /// - maximumDistance: The maximum distance that the long press can move before the gesture fails. - public init(minimumDuration: Double = 0.5, maximumDistance: Double = 10) { - self.minimumDuration = minimumDuration - self.maximumDistance = maximumDistance - } - - mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { - switch phase { - case .began(let context): - startLocation = context.location - touchStartTime = Date() - onChangedAction?(startLocation != nil) - case .changed(let context) where startLocation != nil: - guard let startLocation else { return false } - let translation = calculateTranslation(from: startLocation, to: context.location ?? startLocation) - let distance = calculateDistance(xOffset: translation.width, yOffset: translation.height) - - guard maximumDistance >= distance else { - // Fail longpress if distance is to big. - self.startLocation = nil - return false - } - - let touch = Date() - let delayInSeconds = touch.timeIntervalSince(touchStartTime) - - if delayInSeconds >= minimumDuration { - // Reset state, so behaviour matches SwiftUI. Although, SwiftUI doesn't trigger it, but we have to. - onChangedAction?(false) - // The LongPress gesture ends when the required duration is met. - onEndedAction?(true) - self.startLocation = nil - return true - } - case .changed: - break - case .cancelled, .ended: - onChangedAction?(false) - startLocation = nil - } - // The long press gesture is recognized only when both the maximum distance and minimum time conditions are met. + public typealias Value = Bool + + private var startLocation: CGPoint? = nil + private var touchStartTime = Date(timeIntervalSince1970: 0) + private var maximumDistance: Double + private var onEndedAction: ((Value) -> ())? = nil + private var onChangedAction: ((Value) -> ())? = nil + public private(set) var minimumDuration: Double + public var body: LongPressGesture { + self + } + + /// Creates a long-press gesture with a minimum duration and a maximum distance that the + /// interaction can move before the gesture fails. + /// - Parameters: + /// - minimumDuration: The minimum duration of the long press that must elapse before the + /// gesture succeeds. + /// - maximumDistance: The maximum distance that the long press can move before the gesture + /// fails. + public init(minimumDuration: Double = 0.5, maximumDistance: Double = 10) { + self.minimumDuration = minimumDuration + self.maximumDistance = maximumDistance + } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + switch phase { + case let .began(context): + startLocation = context.location + touchStartTime = Date() + onChangedAction?(startLocation != nil) + case let .changed(context) where startLocation != nil: + guard let startLocation else { return false } + let translation = calculateTranslation( + from: startLocation, + to: context.location ?? startLocation + ) + let distance = calculateDistance( + xOffset: translation.width, + yOffset: translation.height + ) + + guard maximumDistance >= distance else { + // Fail longpress if distance is to big. + self.startLocation = nil return false - } + } - public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onEndedAction = action - return gesture - } - - public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onChangedAction = action - return gesture + let touch = Date() + let delayInSeconds = touch.timeIntervalSince(touchStartTime) + + if delayInSeconds >= minimumDuration { + // Reset state, so behaviour matches SwiftUI. Although, SwiftUI doesn't trigger it, but we + // have to. + onChangedAction?(false) + // The LongPress gesture ends when the required duration is met. + onEndedAction?(true) + self.startLocation = nil + return true + } + case .changed: + break + case .cancelled, .ended: + onChangedAction?(false) + startLocation = nil } + // The long press gesture is recognized only when both the maximum distance and minimum time + // conditions are met. + return false + } + + public func _onEnded(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onEndedAction = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onChangedAction = action + return gesture + } } // MARK: View Modifiers -extension View { - /// Adds an action to perform when this view recognizes a remote long touch gesture. - /// A long touch gesture is when the finger is on the remote touch surface without actually pressing. - public func onLongPressGesture(perform action: @escaping () -> Void) -> some View { - self.modifier(LongPressGestureModifier(action: action)) - } - - /// Adds an action to perform when this view recognizes a long press gesture. - public func onLongPressGesture( - minimumDuration: Double, - maximumDistance: Double, - perform action: @escaping () -> Void, - onPressingChanged: ((Bool) -> Void)? - ) -> some View { - self.modifier( - LongPressGestureModifier( - minimumDuration: minimumDuration, - maximumDistance: maximumDistance, - onPressingChanged: onPressingChanged, - action: action - ) - ) - } - - /// Adds an action to perform when this view recognizes a long press gesture. - public func onLongPressGesture( - minimumDuration: Double = 0.5, - maximumDistance: Double = 10.0, - pressing: ((Bool) -> Void)? = nil, - perform action: @escaping () -> Void - ) -> some View { - self.modifier( - LongPressGestureModifier( - minimumDuration: minimumDuration, - maximumDistance: maximumDistance, - onPressingChanged: pressing, - action: action - ) - ) - } +public extension View { + /// Adds an action to perform when this view recognizes a remote long touch gesture. + /// A long touch gesture is when the finger is on the remote touch surface without actually + /// pressing. + func onLongPressGesture(perform action: @escaping () -> ()) -> some View { + modifier(LongPressGestureModifier(action: action)) + } + + /// Adds an action to perform when this view recognizes a long press gesture. + func onLongPressGesture( + minimumDuration: Double, + maximumDistance: Double, + perform action: @escaping () -> (), + onPressingChanged: ((Bool) -> ())? + ) -> some View { + modifier( + LongPressGestureModifier( + minimumDuration: minimumDuration, + maximumDistance: maximumDistance, + onPressingChanged: onPressingChanged, + action: action + ) + ) + } + + /// Adds an action to perform when this view recognizes a long press gesture. + func onLongPressGesture( + minimumDuration: Double = 0.5, + maximumDistance: Double = 10.0, + pressing: ((Bool) -> ())? = nil, + perform action: @escaping () -> () + ) -> some View { + modifier( + LongPressGestureModifier( + minimumDuration: minimumDuration, + maximumDistance: maximumDistance, + onPressingChanged: pressing, + action: action + ) + ) + } } struct LongPressGestureModifier: ViewModifier { - var minimumDuration: Double = 0.5 - var maximumDistance: Double = 10.0 - var onPressingChanged: ((Bool) -> Void)? = nil - let action: () -> Void - - @GestureState private var isPressing = false - - func body(content: Content) -> some View { - content.gesture( - LongPressGesture(minimumDuration: minimumDuration, maximumDistance: maximumDistance) - .updating($isPressing) { currentState, gestureState, _ in - gestureState = currentState - onPressingChanged?(isPressing) - } - .onEnded { _ in - action() - } - ) - } + var minimumDuration: Double = 0.5 + var maximumDistance: Double = 10.0 + var onPressingChanged: ((Bool) -> ())? = nil + let action: () -> () + + @GestureState + private var isPressing = false + + func body(content: Content) -> some View { + content.gesture( + LongPressGesture(minimumDuration: minimumDuration, maximumDistance: maximumDistance) + .updating($isPressing) { currentState, gestureState, _ in + gestureState = currentState + onPressingChanged?(isPressing) + } + .onEnded { _ in + action() + } + ) + } } diff --git a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift index 4f4745536..6bb176c96 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift @@ -18,86 +18,86 @@ import Foundation public struct TapGesture: Gesture { - public typealias Value = () - /// The required number of taps to complete the tap gesture. - private var count: Int - /// The maximum duration between the taps - private var delay: Double = 0.3 - private var touchEndTime = Date() - private var numberOfTapsSinceGestureBegan: Int = 0 - private var phase: _GesturePhase = .cancelled - private var onEndedAction: ((Value) -> Void)? = nil - - private var isActive: Bool { - switch phase { - case .began, .changed: - return true - default: - return false - } + public typealias Value = () + /// The required number of taps to complete the tap gesture. + private var count: Int + /// The maximum duration between the taps + private var delay: Double = 0.3 + private var touchEndTime = Date() + private var numberOfTapsSinceGestureBegan: Int = 0 + private var phase: _GesturePhase = .cancelled + private var onEndedAction: ((Value) -> ())? = nil + + private var isActive: Bool { + switch phase { + case .began, .changed: + return true + default: + return false } - - mutating public func _onPhaseChange(_ phase: _GesturePhase) -> Bool { - switch phase { - case .cancelled: - numberOfTapsSinceGestureBegan = 0 - case .ended: - if isActive { - let touch = Date() - let delayInSeconds = touch.timeIntervalSince(touchEndTime) - touchEndTime = touch - - // If we have multi count tap gesture, handle it if the taps are with in desired delays - if numberOfTapsSinceGestureBegan > 0, delayInSeconds > delay { - numberOfTapsSinceGestureBegan = 0 - } else { - numberOfTapsSinceGestureBegan += 1 - } - } - // If we ended touch and have desired count we complete gesture - if numberOfTapsSinceGestureBegan >= count { - onEndedAction?(()) - numberOfTapsSinceGestureBegan = 0 - return true - } - default: - // TapGesture in SwiftUI have no change update nor events - break + } + + public mutating func _onPhaseChange(_ phase: _GesturePhase) -> Bool { + switch phase { + case .cancelled: + numberOfTapsSinceGestureBegan = 0 + case .ended: + if isActive { + let touch = Date() + let delayInSeconds = touch.timeIntervalSince(touchEndTime) + touchEndTime = touch + + // If we have multi count tap gesture, handle it if the taps are with in desired delays + if numberOfTapsSinceGestureBegan > 0, delayInSeconds > delay { + numberOfTapsSinceGestureBegan = 0 + } else { + numberOfTapsSinceGestureBegan += 1 } - self.phase = phase - // Tap gesture is recognized on touch up - return false - } - - public var body: TapGesture { - self - } - - /// Creates a tap gesture with the number of required taps. - /// - Parameter count: The required number of taps to complete the tap gesture. - public init(count: Int = 1) { - self.count = count - } - - public func _onEnded(perform action: @escaping (Value) -> Void) -> Self { - var gesture = self - gesture.onEndedAction = action - return gesture - } - - public func _onChanged(perform action: @escaping (Value) -> Void) -> Self { - // TapGesture in SwiftUI have no change update nor events - self + } + // If we ended touch and have desired count we complete gesture + if numberOfTapsSinceGestureBegan >= count { + onEndedAction?(()) + numberOfTapsSinceGestureBegan = 0 + return true + } + default: + // TapGesture in SwiftUI have no change update nor events + break } + self.phase = phase + // Tap gesture is recognized on touch up + return false + } + + public var body: TapGesture { + self + } + + /// Creates a tap gesture with the number of required taps. + /// - Parameter count: The required number of taps to complete the tap gesture. + public init(count: Int = 1) { + self.count = count + } + + public func _onEnded(perform action: @escaping (Value) -> ()) -> Self { + var gesture = self + gesture.onEndedAction = action + return gesture + } + + public func _onChanged(perform action: @escaping (Value) -> ()) -> Self { + // TapGesture in SwiftUI have no change update nor events + self + } } // MARK: View Modifiers -extension View { - /// Adds an action to perform when this view recognizes a tap gesture. - public func onTapGesture(count: Int = 1, perform action: @escaping () -> Void) -> some View { - self.gesture( - TapGesture(count: count).onEnded(action) - ) - } +public extension View { + /// Adds an action to perform when this view recognizes a tap gesture. + func onTapGesture(count: Int = 1, perform action: @escaping () -> ()) -> some View { + gesture( + TapGesture(count: count).onEnded(action) + ) + } } diff --git a/Sources/TokamakCore/Gestures/View+HitTesting.swift b/Sources/TokamakCore/Gestures/View+HitTesting.swift deleted file mode 100644 index 559986074..000000000 --- a/Sources/TokamakCore/Gestures/View+HitTesting.swift +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2020 Tokamak contributors -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// Created by Szymon on 18/8/2023. -// - -import Foundation - -extension View { - /// Defines the content shape for hit testing. - /// - Parameters: - /// - shape: The hit testing shape for the view. - /// - eoFill: A Boolean that indicates whether the shape is interpreted with the even-odd winding number rule. - /// - Returns: A view that uses the given shape for hit testing. - @inlinable public func contentShape( - _ shape: S, - eoFill: Bool = false - ) -> some View { - // TODO: Add content shape modifier. Verify gesture start against the shape fill area. - // https://github.com/TokamakUI/Tokamak/issues/548 - self - } -} diff --git a/Sources/TokamakCore/Gestures/_GesturePhase.swift b/Sources/TokamakCore/Gestures/_GesturePhase.swift index a00a3c83b..cddb55734 100644 --- a/Sources/TokamakCore/Gestures/_GesturePhase.swift +++ b/Sources/TokamakCore/Gestures/_GesturePhase.swift @@ -18,30 +18,30 @@ import Foundation public enum _GesturePhase { - /// The gesture phase when it begins. - case began(_GesturePhaseContext) - - /// The gesture phase when it changes. - case changed(_GesturePhaseContext) - - /// The gesture phase when it ends. - case ended(_GesturePhaseContext) - - /// The gesture phase when it is cancelled. - case cancelled + /// The gesture phase when it begins. + case began(_GesturePhaseContext) + + /// The gesture phase when it changes. + case changed(_GesturePhaseContext) + + /// The gesture phase when it ends. + case ended(_GesturePhaseContext) + + /// The gesture phase when it is cancelled. + case cancelled } public struct _GesturePhaseContext { - /// The event id in which phase has originated form. - let eventId: String? - /// The origin point of the target element in global coordinates. - let boundsOrigin: CGPoint? - /// The current location of the gesture in global coordinates. - let location: CGPoint? - - public init(eventId: String? = nil, boundsOrigin: CGPoint? = nil, location: CGPoint? = nil) { - self.eventId = eventId - self.boundsOrigin = boundsOrigin - self.location = location - } + /// The event id in which phase has originated form. + let eventId: String? + /// The origin point of the target element in global coordinates. + let boundsOrigin: CGPoint? + /// The current location of the gesture in global coordinates. + let location: CGPoint? + + public init(eventId: String? = nil, boundsOrigin: CGPoint? = nil, location: CGPoint? = nil) { + self.eventId = eventId + self.boundsOrigin = boundsOrigin + self.location = location + } } diff --git a/Sources/TokamakCore/Gestures/_GesturePriority.swift b/Sources/TokamakCore/Gestures/_GesturePriority.swift index 42c1dbb37..231ae96f5 100644 --- a/Sources/TokamakCore/Gestures/_GesturePriority.swift +++ b/Sources/TokamakCore/Gestures/_GesturePriority.swift @@ -16,7 +16,7 @@ // public enum _GesturePriority { - case standard - case simultaneous - case highPriority + case standard + case simultaneous + case highPriority } diff --git a/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift b/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift index 3c157cbbd..b6adc5616 100644 --- a/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift +++ b/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift @@ -17,30 +17,33 @@ import Foundation -struct _CoordinateSpaceModifier: ViewModifier { - @Environment(\._coordinateSpace) var coordinateSpace - let name: T - - public func body(content: Content) -> some View { - content - .background { - GeometryReader { proxy in - EmptyView() - .onChange(of: proxy.size, initial: true) { - coordinateSpace.activeCoordinateSpace[.named(name)] = proxy.frame(in: .global).origin - } - .onDisappear { - coordinateSpace.activeCoordinateSpace.removeValue(forKey: .named(name)) - } - } +struct _CoordinateSpaceModifier: ViewModifier { + @Environment(\._coordinateSpace) + var coordinateSpace + let name: T + + public func body(content: Content) -> some View { + content + .background { + GeometryReader { proxy in + EmptyView() + .onChange(of: proxy.size, initial: true) { + coordinateSpace.activeCoordinateSpace[.named(name)] = proxy + .frame(in: .global).origin + } + .onDisappear { + coordinateSpace.activeCoordinateSpace.removeValue(forKey: .named(name)) } - } + } + } + } } -extension View { - /// Assigns a name to the view’s coordinate space, so other code can operate on dimensions like points and sizes relative to the named space. - /// - Parameter name: A name used to identify this coordinate space. - public func coordinateSpace(name: T) -> some View where T : Hashable { - self.modifier(_CoordinateSpaceModifier(name: name)) - } +public extension View { + /// Assigns a name to the view’s coordinate space, so other code can operate on dimensions like + /// points and sizes relative to the named space. + /// - Parameter name: A name used to identify this coordinate space. + func coordinateSpace(name: T) -> some View where T: Hashable { + modifier(_CoordinateSpaceModifier(name: name)) + } } diff --git a/Sources/TokamakCore/Modifiers/OnChangeModifier.swift b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift index e2f6a0d26..b049efdc8 100644 --- a/Sources/TokamakCore/Modifiers/OnChangeModifier.swift +++ b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift @@ -18,57 +18,59 @@ import Foundation struct OnChangeModifier: ViewModifier { - @State var oldValue: V? = nil - - let value: V - let initial: Bool - let action: (V, V) -> Void - - func body(content: Content) -> some View { - content - .task { - if self.initial { - action(value, value) - } - oldValue = value - } - ._onUpdate { - if value != oldValue { - action(oldValue ?? value, value) - } - oldValue = value - } - } + @State + var oldValue: V? = nil + + let value: V + let initial: Bool + let action: (V, V) -> () + + func body(content: Content) -> some View { + content + .task { + if initial { + action(value, value) + } + oldValue = value + } + ._onUpdate { + if value != oldValue { + action(oldValue ?? value, value) + } + oldValue = value + } + } } -extension View { - /// Adds a modifier for this view that fires an action when a specific value changes. - /// - Parameters: - /// - value: The value to check against when determining whether to run the closure. - /// - initial: Whether the action should be run when this view initially appears. - /// - action: A closure to run when the value changes. - /// - oldValue: The old value that failed the comparison check (or the initial value when requested). - /// - newValue: The new value that failed the comparison check. - /// - Returns: A view that fires an action when the specified value changes. - public func onChange( - of value: V, - initial: Bool = false, - _ action: @escaping (V, V) -> Void - ) -> some View where V : Equatable { - return self.modifier(OnChangeModifier(value: value, initial: initial, action: action)) - } - - /// Adds a modifier for this view that fires an action when a specific value changes. - /// - Parameters: - /// - value: The value to check against when determining whether to run the closure. - /// - initial: Whether the action should be run when this view initially appears. - /// - action: A closure to run when the value changes. - /// - Returns: A view that fires an action when the specified value changes. - public func onChange( - of value: V, - initial: Bool = false, - _ action: @escaping () -> Void - ) -> some View where V : Equatable { - return self.modifier(OnChangeModifier(value: value, initial: initial) { _, _ in action() }) - } +public extension View { + /// Adds a modifier for this view that fires an action when a specific value changes. + /// - Parameters: + /// - value: The value to check against when determining whether to run the closure. + /// - initial: Whether the action should be run when this view initially appears. + /// - action: A closure to run when the value changes. + /// - oldValue: The old value that failed the comparison check (or the initial value when + /// requested). + /// - newValue: The new value that failed the comparison check. + /// - Returns: A view that fires an action when the specified value changes. + func onChange( + of value: V, + initial: Bool = false, + _ action: @escaping (V, V) -> () + ) -> some View where V: Equatable { + modifier(OnChangeModifier(value: value, initial: initial, action: action)) + } + + /// Adds a modifier for this view that fires an action when a specific value changes. + /// - Parameters: + /// - value: The value to check against when determining whether to run the closure. + /// - initial: Whether the action should be run when this view initially appears. + /// - action: A closure to run when the value changes. + /// - Returns: A view that fires an action when the specified value changes. + func onChange( + of value: V, + initial: Bool = false, + _ action: @escaping () -> () + ) -> some View where V: Equatable { + modifier(OnChangeModifier(value: value, initial: initial) { _, _ in action() }) + } } diff --git a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift index dfe869e2c..637c179a4 100644 --- a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift +++ b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift @@ -19,29 +19,31 @@ import Foundation import OpenCombineShim struct OnReceiveModifier: ViewModifier where P.Failure == Never { - @State var cancellable: AnyCancellable - - init(publisher: P, action: @escaping (P.Output) -> Void) { - self._cancellable = State(initialValue: publisher.sink(receiveValue: action)) - } - - func body(content: Content) -> some View { - content._onUnmount { - cancellable.cancel() - } + @State + var cancellable: AnyCancellable + + init(publisher: P, action: @escaping (P.Output) -> ()) { + _cancellable = State(initialValue: publisher.sink(receiveValue: action)) + } + + func body(content: Content) -> some View { + content._onUnmount { + cancellable.cancel() } + } } -extension View { - /// Adds an action to perform when this view detects data emitted by the given publisher. - /// - Parameters: - /// - publisher: The publisher to subscribe to. - /// - action: The action to perform when an event is emitted by publisher. The event emitted by publisher is passed as a parameter to action. - /// - Returns: A view that triggers action when publisher emits an event. - public func onReceive

( - _ publisher: P, - perform action: @escaping (P.Output) -> Void - ) -> some View where P : Publisher, P.Failure == Never { - return self.modifier(OnReceiveModifier(publisher: publisher, action: action)) - } +public extension View { + /// Adds an action to perform when this view detects data emitted by the given publisher. + /// - Parameters: + /// - publisher: The publisher to subscribe to. + /// - action: The action to perform when an event is emitted by publisher. The event emitted by + /// publisher is passed as a parameter to action. + /// - Returns: A view that triggers action when publisher emits an event. + func onReceive

( + _ publisher: P, + perform action: @escaping (P.Output) -> () + ) -> some View where P: Publisher, P.Failure == Never { + modifier(OnReceiveModifier(publisher: publisher, action: action)) + } } diff --git a/Sources/TokamakCore/Shapes/ContainerRelativeShape.swift b/Sources/TokamakCore/Shapes/ContainerRelativeShape.swift index f894b770f..90e0baa6b 100644 --- a/Sources/TokamakCore/Shapes/ContainerRelativeShape.swift +++ b/Sources/TokamakCore/Shapes/ContainerRelativeShape.swift @@ -39,22 +39,22 @@ extension ContainerRelativeShape: InsettableShape { @usableFromInline @frozen - internal struct _Inset: InsettableShape, DynamicProperty { + struct _Inset: InsettableShape, DynamicProperty { @usableFromInline - internal var amount: CGFloat + var amount: CGFloat @inlinable - internal init(amount: CGFloat) { + init(amount: CGFloat) { self.amount = amount } @usableFromInline - internal func path(in rect: CGRect) -> Path { + func path(in rect: CGRect) -> Path { // FIXME: Inset the container shape. Rectangle().path(in: rect) } @inlinable - internal func inset(by amount: CGFloat) -> ContainerRelativeShape._Inset { + func inset(by amount: CGFloat) -> ContainerRelativeShape._Inset { var copy = self copy.amount += amount return copy diff --git a/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/AngularGradient.swift b/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/AngularGradient.swift index eec2fc63c..a0b76b98d 100644 --- a/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/AngularGradient.swift +++ b/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/AngularGradient.swift @@ -19,10 +19,10 @@ import Foundation @frozen public struct AngularGradient: ShapeStyle, View { - internal var gradient: Gradient - internal var center: UnitPoint - internal var startAngle: Angle - internal var endAngle: Angle + var gradient: Gradient + var center: UnitPoint + var startAngle: Angle + var endAngle: Angle public init( gradient: Gradient, diff --git a/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/EllipticalGradient.swift b/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/EllipticalGradient.swift index f1fed7a86..e0a2e346f 100644 --- a/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/EllipticalGradient.swift +++ b/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/EllipticalGradient.swift @@ -19,10 +19,10 @@ import Foundation @frozen public struct EllipticalGradient: ShapeStyle, View { - internal var gradient: Gradient - internal var center: UnitPoint - internal var startRadiusFraction: CGFloat - internal var endRadiusFraction: CGFloat + var gradient: Gradient + var center: UnitPoint + var startRadiusFraction: CGFloat + var endRadiusFraction: CGFloat public init( gradient: Gradient, diff --git a/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/LinearGradient.swift b/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/LinearGradient.swift index 00bf1e614..d234438d2 100644 --- a/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/LinearGradient.swift +++ b/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/LinearGradient.swift @@ -19,9 +19,9 @@ import Foundation @frozen public struct LinearGradient: ShapeStyle, View { - internal var gradient: Gradient - internal var startPoint: UnitPoint - internal var endPoint: UnitPoint + var gradient: Gradient + var startPoint: UnitPoint + var endPoint: UnitPoint public init(gradient: Gradient, startPoint: UnitPoint, endPoint: UnitPoint) { self.gradient = gradient diff --git a/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/RadialGradient.swift b/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/RadialGradient.swift index d94c5a4dc..6a83f3452 100644 --- a/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/RadialGradient.swift +++ b/Sources/TokamakCore/Shapes/ShapeStyles/Gradients/RadialGradient.swift @@ -19,10 +19,10 @@ import Foundation @frozen public struct RadialGradient: ShapeStyle, View { - internal var gradient: Gradient - internal var center: UnitPoint - internal var startRadius: CGFloat - internal var endRadius: CGFloat + var gradient: Gradient + var center: UnitPoint + var startRadius: CGFloat + var endRadius: CGFloat public init(gradient: Gradient, center: UnitPoint, startRadius: CGFloat, endRadius: CGFloat) { self.gradient = gradient diff --git a/Sources/TokamakCore/Shapes/ShapeStyles/HierarchicalShapeStyle.swift b/Sources/TokamakCore/Shapes/ShapeStyles/HierarchicalShapeStyle.swift index e0f533296..38199373f 100644 --- a/Sources/TokamakCore/Shapes/ShapeStyles/HierarchicalShapeStyle.swift +++ b/Sources/TokamakCore/Shapes/ShapeStyles/HierarchicalShapeStyle.swift @@ -19,10 +19,10 @@ @frozen public struct HierarchicalShapeStyle: ShapeStyle { @usableFromInline - internal var id: UInt32 + var id: UInt32 @inlinable - internal init(id: UInt32) { + init(id: UInt32) { self.id = id } diff --git a/Sources/TokamakCore/StackReconciler.swift b/Sources/TokamakCore/StackReconciler.swift index 71f84e1f5..20eab8097 100644 --- a/Sources/TokamakCore/StackReconciler.swift +++ b/Sources/TokamakCore/StackReconciler.swift @@ -132,7 +132,7 @@ public final class StackReconciler { queueUpdate(for: mountedElement, transaction: transaction) } - internal func queueUpdate( + func queueUpdate( for mountedElement: MountedCompositeElement, transaction: Transaction ) { diff --git a/Sources/TokamakCore/State/State.swift b/Sources/TokamakCore/State/State.swift index 4fd2f2e3c..437f89a51 100644 --- a/Sources/TokamakCore/State/State.swift +++ b/Sources/TokamakCore/State/State.swift @@ -34,14 +34,16 @@ public struct State: DynamicProperty { /// Creates a state property that stores an initial value. /// - Parameter value: An initial value to store in the state property. - /// - Discussion: You don’t call this initializer directly. Instead, Tokamak calls it for you when you declare a property with the @State attribute and provide an initial value: + /// - Discussion: You don’t call this initializer directly. Instead, Tokamak calls it for you when + /// you declare a property with the @State attribute and provide an initial value: public init(wrappedValue value: Value) { initialValue = value } - + /// Creates a state property that stores an initial value. /// - Parameter value: An initial value to store in the state property. - /// - Discussion: This initializer has the same behavior as the init(wrappedValue:) initializer. See that initializer for more information. + /// - Discussion: This initializer has the same behavior as the init(wrappedValue:) initializer. + /// See that initializer for more information. public init(initialValue value: Value) { initialValue = value } diff --git a/Sources/TokamakCore/Tokens/Color/Color.swift b/Sources/TokamakCore/Tokens/Color/Color.swift index 895282c00..cbfe75992 100644 --- a/Sources/TokamakCore/Tokens/Color/Color.swift +++ b/Sources/TokamakCore/Tokens/Color/Color.swift @@ -26,7 +26,7 @@ public struct Color: Hashable, Equatable { let provider: AnyColorBox - internal init(_ provider: AnyColorBox) { + init(_ provider: AnyColorBox) { self.provider = provider } diff --git a/Sources/TokamakCore/Views/Controls/Selectors/Picker.swift b/Sources/TokamakCore/Views/Controls/Selectors/Picker.swift index e2eae8ca9..1c1dff10b 100644 --- a/Sources/TokamakCore/Views/Controls/Selectors/Picker.swift +++ b/Sources/TokamakCore/Views/Controls/Selectors/Picker.swift @@ -71,7 +71,7 @@ public struct Picker: View @_spi(TokamakCore) public var body: some View { - let children = self.children + let children = children return _PickerContainer(selection: selection, label: label, elements: elements) { // Need to implement a special behavior here. If one of the children is `ForEach` diff --git a/Sources/TokamakCore/Views/Gestures/_GestureView.swift b/Sources/TokamakCore/Views/Gestures/_GestureView.swift index 0f17b6277..3508850b6 100644 --- a/Sources/TokamakCore/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -18,150 +18,160 @@ import Foundation public struct _GestureView: _PrimitiveView { - final class Coordinator: ObservableObject { - var gesture: G - var gestureId: String = UUID().uuidString - var eventId: String? = nil - - init(_ gesture: G) { - self.gesture = gesture - } + final class Coordinator: ObservableObject { + var gesture: G + var gestureId: String = UUID().uuidString + var eventId: String? = nil + + init(_ gesture: G) { + self.gesture = gesture } - - @Environment(\.isEnabled) var isEnabled - @Environment(\._gestureListener) var gestureListener - @StateObject private var coordinator: Coordinator - - let mask: GestureMask - let priority: _GesturePriority - public let content: Content - public var gestureId: String { - coordinator.gestureId + } + + @Environment(\.isEnabled) + var isEnabled + @Environment(\._gestureListener) + var gestureListener + @StateObject + private var coordinator: Coordinator + + let mask: GestureMask + let priority: _GesturePriority + public let content: Content + public var gestureId: String { + coordinator.gestureId + } + + var minimumDuration: Double? { + guard let longPressGesture = coordinator.gesture as? LongPressGesture else { + return nil } - - var minimumDuration: Double? { - guard let longPressGesture = coordinator.gesture as? LongPressGesture else { - return nil - } - return longPressGesture.minimumDuration + return longPressGesture.minimumDuration + } + + public init( + gesture: G, + mask: GestureMask, + priority: _GesturePriority = .standard, + content: Content + ) { + _coordinator = StateObject(wrappedValue: Coordinator(gesture)) + self.mask = mask + self.priority = priority + self.content = content + } + + public func onPhaseChange(_ phase: _GesturePhase) { + guard isEnabled else { + // View needs to be enabled in order for the gestures to work + return } - - public init( - gesture: G, - mask: GestureMask, - priority: _GesturePriority = .standard, - content: Content - ) { - self._coordinator = StateObject(wrappedValue: Coordinator(gesture)) - self.mask = mask - self.priority = priority - self.content = content + + let value = GestureValue( + gestureId: gestureId, + mask: mask, + priority: priority + ) + + var eventId = coordinator.eventId + + switch phase { + case let .began(context) where context.eventId != nil: + startDelay() + coordinator.eventId = context.eventId + gestureListener.registerStart(value, for: context.eventId!) + eventId = context.eventId + case .cancelled, .ended: + coordinator.eventId = nil + default: + break } - - public func onPhaseChange(_ phase: _GesturePhase) { - guard isEnabled else { - // View needs to be enabled in order for the gestures to work - return - } - - let value = GestureValue( - gestureId: gestureId, - mask: mask, - priority: priority - ) - - var eventId = coordinator.eventId - - switch phase { - case let .began(context) where context.eventId != nil: - startDelay() - coordinator.eventId = context.eventId - gestureListener.registerStart(value, for: context.eventId!) - eventId = context.eventId - case .cancelled, .ended: - coordinator.eventId = nil - default: - break - } - - guard let currentEventId = eventId else { - // Gesture has not started - return - } - guard gestureListener.canProcessGesture(value, for: currentEventId) else { - // Event being processed by another gestures - return - } - - if coordinator.gesture._onPhaseChange(phase) { - gestureListener.recognizeGesture(value, for: currentEventId) - } + + guard let currentEventId = eventId else { + // Gesture has not started + return + } + guard gestureListener.canProcessGesture(value, for: currentEventId) else { + // Event being processed by another gestures + return + } + + if coordinator.gesture._onPhaseChange(phase) { + gestureListener.recognizeGesture(value, for: currentEventId) } - - private func startDelay() { - guard let minimumDuration else { return } - Task { - do { - try await Task.sleep(for: .seconds(minimumDuration)) - if let eventId = coordinator.eventId { - await MainActor.run { - onPhaseChange(.changed(_GesturePhaseContext())) - } - } - } catch {} + } + + private func startDelay() { + guard let minimumDuration else { return } + Task { + do { + try await Task.sleep(for: .seconds(minimumDuration)) + if let eventId = coordinator.eventId { + await MainActor.run { + onPhaseChange(.changed(_GesturePhaseContext(eventId: eventId))) + } } + } catch {} } + } } // MARK: View Extension -extension View { - /// Attaches a single gesture to the view. - /// - /// - Parameter gesture: The gesture to attach. - /// - Returns: A modified version of the view with the gesture attached. - @ViewBuilder - public func gesture(_ gesture: T?, including mask: GestureMask = .all) -> some View where T: Gesture { - if let gesture { - _GestureView(gesture: gesture.body, mask: mask, content: self) - } else { - self - } +public extension View { + /// Attaches a single gesture to the view. + /// + /// - Parameter gesture: The gesture to attach. + /// - Returns: A modified version of the view with the gesture attached. + @ViewBuilder + func gesture(_ gesture: T?, including mask: GestureMask = .all) -> some View + where T: Gesture + { + if let gesture { + _GestureView(gesture: gesture.body, mask: mask, content: self) + } else { + self } - - /// Attaches a gesture to the view to process simultaneously with gestures defined by the view. - /// - Parameter gesture: The gesture to attach. - /// - Returns: A modified version of the view with the gesture attached. - @ViewBuilder - public func simultaneousGesture(_ gesture: T?, including mask: GestureMask = .all) -> some View where T : Gesture { - if let gesture { - _GestureView( - gesture: gesture.body, - mask: mask, - priority: .simultaneous, - content: self - ) - } else { - self - } + } + + /// Attaches a gesture to the view to process simultaneously with gestures defined by the view. + /// - Parameter gesture: The gesture to attach. + /// - Returns: A modified version of the view with the gesture attached. + @ViewBuilder + func simultaneousGesture(_ gesture: T?, including mask: GestureMask = .all) -> some View + where T: Gesture + { + if let gesture { + _GestureView( + gesture: gesture.body, + mask: mask, + priority: .simultaneous, + content: self + ) + } else { + self } - - /// Attaches a gesture to the view with a higher precedence than gestures defined by the view. - /// - Parameters: - /// - gesture: A gesture to attach to the view. - /// - mask: A value that controls how adding this gesture to the view affects other gestures recognized by the view and its subviews. Defaults to all. - /// - Returns: A modified version of the view with the gesture attached. - @ViewBuilder - public func highPriorityGesture(_ gesture: T?, including mask: GestureMask = .all) -> some View where T : Gesture { - if let gesture { - _GestureView( - gesture: gesture.body, - mask: mask, - priority: .highPriority, - content: self - ) - } else { - self - } + } + + /// Attaches a gesture to the view with a higher precedence than gestures defined by the view. + /// - Parameters: + /// - gesture: A gesture to attach to the view. + /// - mask: A value that controls how adding this gesture to the view affects other gestures + /// recognized by the view and its subviews. Defaults to all. + /// - Returns: A modified version of the view with the gesture attached. + @ViewBuilder + func highPriorityGesture(_ gesture: T?, including mask: GestureMask = .all) -> some View + where T: Gesture + { + if let gesture { + _GestureView( + gesture: gesture.body, + mask: mask, + priority: .highPriority, + content: self + ) + } else { + self } + } } diff --git a/Sources/TokamakCore/Views/Layout/GeometryReader.swift b/Sources/TokamakCore/Views/Layout/GeometryReader.swift index 9067056ed..0c1885057 100644 --- a/Sources/TokamakCore/Views/Layout/GeometryReader.swift +++ b/Sources/TokamakCore/Views/Layout/GeometryReader.swift @@ -15,34 +15,35 @@ import Foundation public struct GeometryProxy { - @Environment(\._coordinateSpace) var coordinates - let globalRect: CGRect - - public var size: CGSize { - globalRect.size - } - - public init(globalRect: CGRect) { - self.globalRect = globalRect - } - - public func frame(in coordinateSpace: CoordinateSpace) -> CGRect { - switch coordinateSpace { - case .global: - return globalRect - case .local: - return CGRect(origin: .zero, size: size) - case .named(let name): - if let origin = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { - return CoordinateSpace.convertGlobalSpaceCoordinates( - rect: globalRect, - toNamedOrigin: origin - ) - } - // Return local if no space with given name - return CGRect(origin: .zero, size: size) - } + @Environment(\._coordinateSpace) + var coordinates + let globalRect: CGRect + + public var size: CGSize { + globalRect.size + } + + public init(globalRect: CGRect) { + self.globalRect = globalRect + } + + public func frame(in coordinateSpace: CoordinateSpace) -> CGRect { + switch coordinateSpace { + case .global: + return globalRect + case .local: + return CGRect(origin: .zero, size: size) + case let .named(name): + if let origin = coordinates.activeCoordinateSpace[CoordinateSpace.named(name)] { + return CoordinateSpace.convertGlobalSpaceCoordinates( + rect: globalRect, + toNamedOrigin: origin + ) + } + // Return local if no space with given name + return CGRect(origin: .zero, size: size) } + } } public func makeProxy(from rect: CGRect) -> GeometryProxy { diff --git a/Sources/TokamakDOM/App/App.swift b/Sources/TokamakDOM/App/App.swift index 97796dd8b..8372e2677 100644 --- a/Sources/TokamakDOM/App/App.swift +++ b/Sources/TokamakDOM/App/App.swift @@ -21,57 +21,57 @@ import TokamakCore import TokamakStaticHTML public extension App { - static func _launch(_ app: Self, with configuration: _AppConfiguration) { - switch configuration.reconciler { - case .stack: - _launch(app, configuration.rootEnvironment, TokamakDOM.body) - case let .fiber(useDynamicLayout): - DOMFiberRenderer("body", useDynamicLayout: useDynamicLayout).render(app) - } + static func _launch(_ app: Self, with configuration: _AppConfiguration) { + switch configuration.reconciler { + case .stack: + _launch(app, configuration.rootEnvironment, TokamakDOM.body) + case let .fiber(useDynamicLayout): + DOMFiberRenderer("body", useDynamicLayout: useDynamicLayout).render(app) } - - /// The default implementation of `launch` for a `TokamakDOM` app. - /// - /// Creates a host `div` node and appends it to the body. - /// - /// The body is styled with `margin: 0;` to match the `SwiftUI` layout - /// system as closely as possible - /// - static func _launch( - _ app: Self, - _ rootEnvironment: EnvironmentValues, - _ body: JSObject - ) { - if body.style.object!.all == "" { - body.style = "margin: 0;" - } - let rootStyle = document.createElement!("style").object! - rootStyle.id = "_tokamak-app-style" - rootStyle.innerHTML = .string(tokamakStyles) - _ = head.appendChild!(rootStyle) - - let div = document.createElement!("div").object! - _ = Unmanaged.passRetained(DOMRenderer(app, div, rootEnvironment)) - - _ = body.appendChild!(div) - - ScenePhaseObserver.observe() - GestureEventsObserver.observe(div) - ColorSchemeObserver.observe(div) - } - - static func _setTitle(_ title: String) { - let titleTag = document.createElement!("title").object! - titleTag.id = "_tokamak-app-title" - titleTag.innerHTML = .string(title) - _ = head.appendChild!(titleTag) - } - - var _phasePublisher: AnyPublisher { - ScenePhaseObserver.publisher.eraseToAnyPublisher() - } - - var _colorSchemePublisher: AnyPublisher { - ColorSchemeObserver.publisher.eraseToAnyPublisher() + } + + /// The default implementation of `launch` for a `TokamakDOM` app. + /// + /// Creates a host `div` node and appends it to the body. + /// + /// The body is styled with `margin: 0;` to match the `SwiftUI` layout + /// system as closely as possible + /// + static func _launch( + _ app: Self, + _ rootEnvironment: EnvironmentValues, + _ body: JSObject + ) { + if body.style.object!.all == "" { + body.style = "margin: 0;" } + let rootStyle = document.createElement!("style").object! + rootStyle.id = "_tokamak-app-style" + rootStyle.innerHTML = .string(tokamakStyles) + _ = head.appendChild!(rootStyle) + + let div = document.createElement!("div").object! + _ = Unmanaged.passRetained(DOMRenderer(app, div, rootEnvironment)) + + _ = body.appendChild!(div) + + ScenePhaseObserver.observe() + GestureEventsObserver.observe(div) + ColorSchemeObserver.observe(div) + } + + static func _setTitle(_ title: String) { + let titleTag = document.createElement!("title").object! + titleTag.id = "_tokamak-app-title" + titleTag.innerHTML = .string(title) + _ = head.appendChild!(titleTag) + } + + var _phasePublisher: AnyPublisher { + ScenePhaseObserver.publisher.eraseToAnyPublisher() + } + + var _colorSchemePublisher: AnyPublisher { + ColorSchemeObserver.publisher.eraseToAnyPublisher() + } } diff --git a/Sources/TokamakDOM/App/GestureEventsObserver.swift b/Sources/TokamakDOM/App/GestureEventsObserver.swift index 1e7af1d66..79876e7b2 100644 --- a/Sources/TokamakDOM/App/GestureEventsObserver.swift +++ b/Sources/TokamakDOM/App/GestureEventsObserver.swift @@ -20,68 +20,70 @@ import JavaScriptKit import OpenCombineShim import TokamakCore - enum GestureEventsObserver { - static var publisher = CurrentValueSubject<_GesturePhase?, Never>(nil) - - private static var pointerdown: JSClosure? - private static var pointermove: JSClosure? - private static var pointerup: JSClosure? - private static var pointercancel: JSClosure? - - static func observe(_ rootElement: JSObject) { - let pointerdown = JSClosure { args -> JSValue in - if let event = args[0].object, - let target = event.target.object, - let x = event.x.jsValue.number, - let y = event.y.jsValue.number, - let rect = target.getBoundingClientRect?(), - let originX = rect.x.number, - let originY = rect.y.number { - let phase = _GesturePhaseContext( - eventId: String(describing: target.hashValue), - boundsOrigin: CGPoint(x: originX, y: originY), - location: CGPoint(x: x, y: y) - ) - publisher.send(.began(phase)) - } - - return .undefined - } + static var publisher = CurrentValueSubject<_GesturePhase?, Never>(nil) + + private static var pointerdown: JSClosure? + private static var pointermove: JSClosure? + private static var pointerup: JSClosure? + private static var pointercancel: JSClosure? + + static func observe(_ rootElement: JSObject) { + let pointerdown = JSClosure { args -> JSValue in + if let event = args[0].object, + let target = event.target.object, + let x = event.x.jsValue.number, + let y = event.y.jsValue.number, + let rect = target.getBoundingClientRect?(), + let originX = rect.x.number, + let originY = rect.y.number + { + let phase = _GesturePhaseContext( + eventId: String(describing: target.hashValue), + boundsOrigin: CGPoint(x: originX, y: originY), + location: CGPoint(x: x, y: y) + ) + publisher.send(.began(phase)) + } - let pointermove = JSClosure { args -> JSValue in - if let event = args[0].object, - let x = event.x.jsValue.number, - let y = event.y.jsValue.number { - let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) - publisher.send(.changed(phase)) - } - return .undefined - } - - let pointerup = JSClosure { args -> JSValue in - if let event = args[0].object, - let x = event.x.jsValue.number, - let y = event.y.jsValue.number { - let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) - publisher.send(.ended(phase)) - } - return .undefined - } - - let pointercancel = JSClosure { args -> JSValue in - publisher.send(.cancelled) - return .undefined - } - - _ = rootElement.addEventListener?("pointerdown", pointerdown) - _ = rootElement.addEventListener?("pointermove", pointermove) - _ = rootElement.addEventListener?("pointerup", pointerup) - _ = rootElement.addEventListener?("pointercancel", pointercancel) - - Self.pointerdown = pointerdown - Self.pointermove = pointermove - Self.pointerup = pointerup - Self.pointercancel = pointercancel + return .undefined } + + let pointermove = JSClosure { args -> JSValue in + if let event = args[0].object, + let x = event.x.jsValue.number, + let y = event.y.jsValue.number + { + let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) + publisher.send(.changed(phase)) + } + return .undefined + } + + let pointerup = JSClosure { args -> JSValue in + if let event = args[0].object, + let x = event.x.jsValue.number, + let y = event.y.jsValue.number + { + let phase = _GesturePhaseContext(location: CGPoint(x: x, y: y)) + publisher.send(.ended(phase)) + } + return .undefined + } + + let pointercancel = JSClosure { _ -> JSValue in + publisher.send(.cancelled) + return .undefined + } + + _ = rootElement.addEventListener?("pointerdown", pointerdown) + _ = rootElement.addEventListener?("pointermove", pointermove) + _ = rootElement.addEventListener?("pointerup", pointerup) + _ = rootElement.addEventListener?("pointercancel", pointercancel) + + Self.pointerdown = pointerdown + Self.pointermove = pointermove + Self.pointerup = pointerup + Self.pointercancel = pointercancel + } } diff --git a/Sources/TokamakDOM/DOMFiberRenderer.swift b/Sources/TokamakDOM/DOMFiberRenderer.swift index 5927f5169..670d856e9 100644 --- a/Sources/TokamakDOM/DOMFiberRenderer.swift +++ b/Sources/TokamakDOM/DOMFiberRenderer.swift @@ -146,7 +146,7 @@ public struct DOMFiberRenderer: FiberRenderer { style.innerHTML = .string(TokamakStaticHTML.tokamakStyles) _ = document.head.appendChild(style) } - + GestureEventsObserver.observe(reference) } diff --git a/Sources/TokamakDOM/Views/Gestures/_GestureView.swift b/Sources/TokamakDOM/Views/Gestures/_GestureView.swift index 8094eb569..deff12080 100644 --- a/Sources/TokamakDOM/Views/Gestures/_GestureView.swift +++ b/Sources/TokamakDOM/Views/Gestures/_GestureView.swift @@ -21,35 +21,35 @@ import TokamakCore import TokamakStaticHTML extension TokamakCore._GestureView: DOMPrimitive { - var renderedBody: AnyView { - AnyView( - content - .onReceive(GestureEventsObserver.publisher) { phase in - guard let phase else { return } - onPhaseChange(phase) - } - ) - } + var renderedBody: AnyView { + AnyView( + content + .onReceive(GestureEventsObserver.publisher) { phase in + guard let phase else { return } + onPhaseChange(phase) + } + ) + } } @_spi(TokamakStaticHTML) extension TokamakCore._GestureView: HTMLConvertible { - public var tag: String { "div" } - public var listeners: [String : Listener] { [:] } - - public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { - [:] - } - - public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V : ViewVisitor { - { - $0.visit( - content - .onReceive(GestureEventsObserver.publisher) { phase in - guard let phase else { return } - onPhaseChange(phase) - } - ) - } + public var tag: String { "div" } + public var listeners: [String: Listener] { [:] } + + public func attributes(useDynamicLayout: Bool) -> [HTMLAttribute: String] { + [:] + } + + public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor { + { + $0.visit( + content + .onReceive(GestureEventsObserver.publisher) { phase in + guard let phase else { return } + onPhaseChange(phase) + } + ) } + } } diff --git a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift index ee1a46d2f..0401ec0d3 100644 --- a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift +++ b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift @@ -22,83 +22,84 @@ import TokamakStaticHTML private let ResizeObserver = JSObject.global.ResizeObserver.function! extension GeometryReader: DOMPrimitive { - var renderedBody: AnyView { - AnyView(_GeometryReader(content: content)) - } + var renderedBody: AnyView { + AnyView(_GeometryReader(content: content)) + } } @_spi(TokamakStaticHTML) extension GeometryReader: HTMLConvertible { - public var tag: String { "div" } + public var tag: String { "div" } - public func attributes(useDynamicLayout: Bool) -> [TokamakStaticHTML.HTMLAttribute : String] { - [:] - } + public func attributes(useDynamicLayout: Bool) -> [TokamakStaticHTML.HTMLAttribute: String] { + [:] + } - public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V : ViewVisitor { - return { $0.visit(_GeometryReader(content: content)) } - } + public func primitiveVisitor(useDynamicLayout: Bool) -> ((V) -> ())? where V: ViewVisitor { + { $0.visit(_GeometryReader(content: content)) } + } } struct _GeometryReader: View { - final class State: ObservableObject { - /** Holds a strong reference to a `JSClosure` instance that has to stay alive as long as - the `_GeometryReader` owner is alive. - */ - var closure: JSClosure? - - /// A reference to a DOM node being observed for size updates. - var observedNodeRef: JSObject? - - /// A reference to a `ResizeObserver` instance. - var observerRef: JSObject? - - /// The last known rect of the `observedNodeRef` DOM node. - @Published - var rect: CGRect? + final class State: ObservableObject { + /** Holds a strong reference to a `JSClosure` instance that has to stay alive as long as + the `_GeometryReader` owner is alive. + */ + var closure: JSClosure? + + /// A reference to a DOM node being observed for size updates. + var observedNodeRef: JSObject? + + /// A reference to a `ResizeObserver` instance. + var observerRef: JSObject? + + /// The last known rect of the `observedNodeRef` DOM node. + @Published + var rect: CGRect? + } + + let content: (GeometryProxy) -> Content + + @StateObject + private var state = State() + + var body: some View { + HTML("div", ["class": "_tokamak-geometryreader"]) { + if let rect = state.rect { + content(makeProxy(from: rect)) + } else { + EmptyView() + } } - - let content: (GeometryProxy) -> Content - - @StateObject - private var state = State() - - var body: some View { - HTML("div", ["class": "_tokamak-geometryreader"]) { - if let rect = state.rect { - content(makeProxy(from: rect)) - } else { - EmptyView() - } - } - ._domRef($state.observedNodeRef) - ._onMount { - let closure = JSClosure { [weak state] args -> JSValue in - // FIXME: `JSArrayRef` is not a `RandomAccessCollection` for some reason, which forces - // us to use a string subscript - - guard let target = args[0].object?[dynamicMember: "0"].object?.target.object, - let rect = target.getBoundingClientRect?(), - let x = rect.x.number, - let y = rect.y.number, - let width = rect.width.number, - let height = rect.height.number else { - return .undefined - } - - state?.rect = CGRect( - origin: CGPoint(x: x, y: y), - size: CGSize(width: width, height: height) - ) - - return .undefined - } - state.closure = closure - - let observerRef = ResizeObserver.new(closure) - _ = observerRef.observe!(state.observedNodeRef!) - - state.observerRef = observerRef + ._domRef($state.observedNodeRef) + ._onMount { + let closure = JSClosure { [weak state] args -> JSValue in + // FIXME: `JSArrayRef` is not a `RandomAccessCollection` for some reason, which forces + // us to use a string subscript + + guard let target = args[0].object?[dynamicMember: "0"].object?.target.object, + let rect = target.getBoundingClientRect?(), + let x = rect.x.number, + let y = rect.y.number, + let width = rect.width.number, + let height = rect.height.number + else { + return .undefined } + + state?.rect = CGRect( + origin: CGPoint(x: x, y: y), + size: CGSize(width: width, height: height) + ) + + return .undefined + } + state.closure = closure + + let observerRef = ResizeObserver.new(closure) + _ = observerRef.observe!(state.observedNodeRef!) + + state.observerRef = observerRef } + } } diff --git a/Sources/TokamakDemo/DOM/URLHashDemo.swift b/Sources/TokamakDemo/DOM/URLHashDemo.swift index 7c3f5194b..ae117e184 100644 --- a/Sources/TokamakDemo/DOM/URLHashDemo.swift +++ b/Sources/TokamakDemo/DOM/URLHashDemo.swift @@ -50,7 +50,7 @@ struct URLHashDemo: View { var body: some View { VStack { Button("Assign random location.hash") { - location["hash"] = .string("\(Int.random(in: 0...1000))") + location["hash"] = .string("\(Int.random(in: 0...1000))") } Text("Current location.hash is \(hashState.currentHash)") } diff --git a/Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift b/Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift index 88b003cfe..63c03ceb3 100644 --- a/Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift +++ b/Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift @@ -18,53 +18,54 @@ import TokamakShim struct GestureCoordinateSpaceDemo: View { - let rows = 16 - let columns = 16 - - struct Rect: Hashable { - let row: Int - let column: Int - } - - @State private var selectedRects: Set = [] - - var body: some View { - VStack(spacing: 0) { - ForEach(0.. = [] + + var body: some View { + VStack(spacing: 0) { + ForEach(0.. Bool { - selectedRects.contains(Rect(row: row, column: column)) + } } + .coordinateSpace(name: "MyView") + .gesture( + TapGesture() + .onEnded { + selectedRects.removeAll() + } + ) + .gesture( + DragGesture(coordinateSpace: .named("MyView")) + .onChanged { value in + let location = value.location + let row = Int(location.y / 50) + let column = Int(location.x / 50) + if !isSelected(row: row, column: column) { + selectedRects.insert(Rect(row: row, column: column)) + } + } + ) + } + + func isSelected(row: Int, column: Int) -> Bool { + selectedRects.contains(Rect(row: row, column: column)) + } } diff --git a/Sources/TokamakDemo/Gestures/GesturesDemo.swift b/Sources/TokamakDemo/Gestures/GesturesDemo.swift index 32729735d..4b97f4387 100644 --- a/Sources/TokamakDemo/Gestures/GesturesDemo.swift +++ b/Sources/TokamakDemo/Gestures/GesturesDemo.swift @@ -15,179 +15,197 @@ // Created by Szymon on 26/8/2023. // -import TokamakShim import Foundation +import TokamakShim struct GesturesDemo: View { - @State var count: Int = 0 - @State var countDouble: Int = 0 - @GestureState var isDetectingTap = false - - @GestureState var isDetectingLongPress = false - @State var completedLongPress = false - @State var countLongpress: Int = 0 - - @GestureState var dragAmount = CGSize.zero - @State private var countDragLongPress = 0 - - var body: some View { - HStack(alignment: .top, spacing: 8) { - tapGestures - longPressGestures - dragGestures - } - .padding() + @State + var count: Int = 0 + @State + var countDouble: Int = 0 + @GestureState + var isDetectingTap = false + + @GestureState + var isDetectingLongPress = false + @State + var completedLongPress = false + @State + var countLongpress: Int = 0 + + @GestureState + var dragAmount = CGSize.zero + @State + private var countDragLongPress = 0 + + var body: some View { + HStack(alignment: .top, spacing: 8) { + tapGestures + longPressGestures + dragGestures } - - var dragGestures: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Drag Gestures") + .padding() + } + + var dragGestures: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Drag Gestures") - HStack { - Rectangle() - .fill(Color.yellow) - .frame(width: 100, height: 100) - .gesture(DragGesture().updating($dragAmount) { value, state, transaction in - state = value.translation - }.onEnded { value in - print(value) - }) - Text("dragAmount: \(dragAmount.width), \(dragAmount.height)") - } + HStack { + Rectangle() + .fill(Color.yellow) + .frame(width: 100, height: 100) + .gesture(DragGesture().updating($dragAmount) { value, state, _ in + state = value.translation + }.onEnded { value in + print(value) + }) + Text("dragAmount: \(dragAmount.width), \(dragAmount.height)") + } - HStack { - Rectangle() - .fill(Color.red) - .frame(width: 100, height: 100) - .gesture(DragGesture(minimumDistance: 0) - .onChanged { _ in - self.countDragLongPress += 1 - }) - Text("Drag Count: \(countDragLongPress)") - } - } + HStack { + Rectangle() + .fill(Color.red) + .frame(width: 100, height: 100) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + countDragLongPress += 1 + } + ) + Text("Drag Count: \(countDragLongPress)") + } } - - var longPressGestures: some View { - VStack(alignment: .leading, spacing: 8) { - Text("LongPress Gestures") + } - HStack { - Rectangle() - .fill(self.isDetectingLongPress ? Color.pink : (self.completedLongPress ? Color.purple : Color.gray)) - .frame(width: 100, height: 100) - .gesture(LongPressGesture(minimumDuration: 2) - .updating($isDetectingLongPress) { currentState, gestureState, transaction in - gestureState = currentState - transaction.animation = Animation.easeIn(duration: 2.0) - } - .onEnded { finished in - self.completedLongPress = finished - }) - Text(self.isDetectingLongPress ? "detecting" : (self.completedLongPress ? "completed" : "unknow")) - } + var longPressGestures: some View { + VStack(alignment: .leading, spacing: 8) { + Text("LongPress Gestures") - HStack { - Rectangle() - .fill(Color.orange) - .frame(width: 100, height: 100) - .onLongPressGesture(minimumDuration: 0) { - countLongpress += 1 - } - .onTapGesture() { - fatalError("onTapGesture, should not be called") - } - Text("Long Pressed: \(countLongpress)") - } - } + HStack { + Rectangle() + .fill( + isDetectingLongPress ? Color + .pink : (completedLongPress ? Color.purple : Color.gray) + ) + .frame(width: 100, height: 100) + .gesture( + LongPressGesture(minimumDuration: 2) + .updating($isDetectingLongPress) { currentState, gestureState, transaction in + gestureState = currentState + transaction.animation = Animation.easeIn(duration: 2.0) + } + .onEnded { finished in + completedLongPress = finished + } + ) + Text( + isDetectingLongPress ? "detecting" : + (completedLongPress ? "completed" : "unknow") + ) + } + + HStack { + Rectangle() + .fill(Color.orange) + .frame(width: 100, height: 100) + .onLongPressGesture(minimumDuration: 0) { + countLongpress += 1 + } + .onTapGesture { + fatalError("onTapGesture, should not be called") + } + Text("Long Pressed: \(countLongpress)") + } } - - var tapGestures: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Tap Gestures") - HStack { - Rectangle() - .fill(Color.white) - .frame(width: 100, height: 100) - .onTapGesture { - count += 1 - print("⚪️ gesture") - } - Text("Tap: \(count)") - } - HStack { - Rectangle() - .fill(Color.green) - .frame(width: 100, height: 100) - .onTapGesture(count: 2) { - countDouble += 1 - print("🟢 double gesture") - } - Text("double tap: \(countDouble)") - } - HStack { - Rectangle() - .fill(Color.blue) - .frame(width: 100, height: 100) - .onTapGesture() { - print("🔵 1st gesture") - } - .onTapGesture() { - fatalError("should not be called") - } - Text("1st tap gesture") - } - HStack { - Rectangle() - .fill(Color.pink) - .frame(width: 100, height: 100) - .simultaneousGesture( - TapGesture() - .onEnded({ _ in - print("🩷 simultaneousGesture gesture") - }) - ) - .onTapGesture() { - fatalError("should not be called") - } - .onTapGesture() { - fatalError("should not be called") - } - .simultaneousGesture( - TapGesture() - .onEnded({ _ in - print("🩷 simultaneousGesture 2 gesture") - }) - ) - Text("simultaneousGesture") - } - HStack { - Rectangle() - .fill(Color.purple) - .frame(width: 100, height: 100) - .simultaneousGesture( - TapGesture() - .onEnded({ _ in - fatalError("should not be called") - }) - ) - .onTapGesture() { - fatalError("should not be called") - } - .highPriorityGesture( - TapGesture() - .onEnded({ _ in - fatalError("should not be called") - }) - ) - .highPriorityGesture( - TapGesture() - .onEnded({ _ in - print("🟣 highPriorityGesture 3 gesture") - }) - ) - Text("highPriorityGesture") - } - } + } + + var tapGestures: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Tap Gestures") + HStack { + Rectangle() + .fill(Color.white) + .frame(width: 100, height: 100) + .onTapGesture { + count += 1 + print("⚪️ gesture") + } + Text("Tap: \(count)") + } + HStack { + Rectangle() + .fill(Color.green) + .frame(width: 100, height: 100) + .onTapGesture(count: 2) { + countDouble += 1 + print("🟢 double gesture") + } + Text("double tap: \(countDouble)") + } + HStack { + Rectangle() + .fill(Color.blue) + .frame(width: 100, height: 100) + .onTapGesture { + print("🔵 1st gesture") + } + .onTapGesture { + fatalError("should not be called") + } + Text("1st tap gesture") + } + HStack { + Rectangle() + .fill(Color.pink) + .frame(width: 100, height: 100) + .simultaneousGesture( + TapGesture() + .onEnded { _ in + print("🩷 simultaneousGesture gesture") + } + ) + .onTapGesture { + fatalError("should not be called") + } + .onTapGesture { + fatalError("should not be called") + } + .simultaneousGesture( + TapGesture() + .onEnded { _ in + print("🩷 simultaneousGesture 2 gesture") + } + ) + Text("simultaneousGesture") + } + HStack { + Rectangle() + .fill(Color.purple) + .frame(width: 100, height: 100) + .simultaneousGesture( + TapGesture() + .onEnded { _ in + fatalError("should not be called") + } + ) + .onTapGesture { + fatalError("should not be called") + } + .highPriorityGesture( + TapGesture() + .onEnded { _ in + fatalError("should not be called") + } + ) + .highPriorityGesture( + TapGesture() + .onEnded { _ in + print("🟣 highPriorityGesture 3 gesture") + } + ) + Text("highPriorityGesture") + } } + } } diff --git a/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift b/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift index 5128d3dac..3614b7920 100644 --- a/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift +++ b/Sources/TokamakStaticHTML/Modifiers/_BackgroundStyleModifier.swift @@ -36,9 +36,13 @@ extension _BackgroundStyleModifier: DOMViewModifier { return [ "style": """ - background-color: rgba(\(color.red * 255), \(color.green * 255), \(color - .blue * 255), \(blur - .opacity)); + background-color: rgba(\(color.red * 255), \(color.green * 255), \( + color + .blue * 255 + ), \( + blur + .opacity + )); -webkit-backdrop-filter: blur(\(blur.radius)px); backdrop-filter: blur(\(blur.radius)px); """, diff --git a/Sources/TokamakStaticHTML/Sanitizer.swift b/Sources/TokamakStaticHTML/Sanitizer.swift index 5b5088531..ca52c7537 100644 --- a/Sources/TokamakStaticHTML/Sanitizer.swift +++ b/Sources/TokamakStaticHTML/Sanitizer.swift @@ -112,7 +112,8 @@ public enum Sanitizers { Parsers.string1.matches(input) ? Parsers.string1Content.filter(input) : Parsers.string2Content.filter(input) - .replacingOccurrences(of: "\"", with: """))' + .replacingOccurrences(of: "\"", with: """) + )' """ } } diff --git a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift index fa50df5a8..b2bb2ba34 100644 --- a/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift +++ b/Sources/TokamakStaticHTML/StaticHTMLFiberRenderer.swift @@ -80,8 +80,10 @@ public final class HTMLElement: FiberElement, CustomStringConvertible { public var description: String { """ - <\(content.tag)\(content.attributes.map { " \($0.key.value)=\"\($0.value)\"" } - .joined(separator: ""))>\(content.innerHTML != nil ? "\(content.innerHTML!)" : "")\(!content + <\(content.tag)\( + content.attributes.map { " \($0.key.value)=\"\($0.value)\"" } + .joined(separator: "") + )>\(content.innerHTML != nil ? "\(content.innerHTML!)" : "")\(!content .children .isEmpty ? "\n" : "")\(content.children.map(\.description).joined(separator: "\n"))\(!content .children diff --git a/Sources/TokamakStaticHTML/Views/HTML.swift b/Sources/TokamakStaticHTML/Views/HTML.swift index 974fb7120..1b9acb0e5 100644 --- a/Sources/TokamakStaticHTML/Views/HTML.swift +++ b/Sources/TokamakStaticHTML/Views/HTML.swift @@ -80,8 +80,10 @@ public extension AnyHTML { <\(tag)\(attributes.isEmpty ? "" : " ")\ \(renderedAttributes)>\ \(innerHTML(shouldSortAttributes: shouldSortAttributes) ?? "")\ - \(children.map { $0.outerHTML(shouldSortAttributes: shouldSortAttributes) } - .joined(separator: "\n"))\ + \( + children.map { $0.outerHTML(shouldSortAttributes: shouldSortAttributes) } + .joined(separator: "\n") + )\ """ } diff --git a/Sources/TokamakStaticHTML/Views/Layout/LazyHGrid.swift b/Sources/TokamakStaticHTML/Views/Layout/LazyHGrid.swift index 3b216dabd..6da6cf263 100644 --- a/Sources/TokamakStaticHTML/Views/Layout/LazyHGrid.swift +++ b/Sources/TokamakStaticHTML/Views/Layout/LazyHGrid.swift @@ -40,10 +40,12 @@ extension LazyHGrid: _HTMLPrimitive { public var renderedBody: AnyView { var styles = """ display: grid; - grid-template-rows: \(_LazyHGridProxy(self) - .rows - .map(\.description) - .joined(separator: " ")); + grid-template-rows: \( + _LazyHGridProxy(self) + .rows + .map(\.description) + .joined(separator: " ") + ); grid-auto-flow: column; """ if fillCrossAxis { diff --git a/Sources/TokamakStaticHTML/Views/Layout/LazyVGrid.swift b/Sources/TokamakStaticHTML/Views/Layout/LazyVGrid.swift index dc54b21e1..f32aa3f10 100644 --- a/Sources/TokamakStaticHTML/Views/Layout/LazyVGrid.swift +++ b/Sources/TokamakStaticHTML/Views/Layout/LazyVGrid.swift @@ -40,10 +40,12 @@ extension LazyVGrid: _HTMLPrimitive { public var renderedBody: AnyView { var styles = """ display: grid; - grid-template-columns: \(_LazyVGridProxy(self) - .columns - .map(\.description) - .joined(separator: " ")); + grid-template-columns: \( + _LazyVGridProxy(self) + .columns + .map(\.description) + .joined(separator: " ") + ); grid-auto-flow: row; """ if fillCrossAxis { diff --git a/Sources/TokamakStaticHTML/Views/Text/Text.swift b/Sources/TokamakStaticHTML/Views/Text/Text.swift index cfb9b86ba..4ad9b15ae 100644 --- a/Sources/TokamakStaticHTML/Views/Text/Text.swift +++ b/Sources/TokamakStaticHTML/Views/Text/Text.swift @@ -233,11 +233,15 @@ extension Text { return [ "style": """ - \(fontPathEnv._fontPath.first?.styles(in: fontPathEnv) - .filter { weight != nil ? $0.key != "font-weight" : true } - .inlineStyles(shouldSortDeclarations: true) ?? "") - \(fontPathEnv._fontPath - .isEmpty ? "font-family: \(Font.Design.default.families.joined(separator: ", "));" : "") + \( + fontPathEnv._fontPath.first?.styles(in: fontPathEnv) + .filter { weight != nil ? $0.key != "font-weight" : true } + .inlineStyles(shouldSortDeclarations: true) ?? "" + ) + \( + fontPathEnv._fontPath + .isEmpty ? "font-family: \(Font.Design.default.families.joined(separator: ", "));" : "" + ) color: \((color ?? .primary).cssValue(environment)); font-style: \(italic ? "italic" : "normal"); font-weight: \(weight?.value ?? resolvedFont?._weight.value ?? 400); diff --git a/Tests/TokamakTests/SpaceCoordinatesTests.swift b/Tests/TokamakTests/SpaceCoordinatesTests.swift index 1bee067d0..e1c0930bc 100644 --- a/Tests/TokamakTests/SpaceCoordinatesTests.swift +++ b/Tests/TokamakTests/SpaceCoordinatesTests.swift @@ -1,3 +1,4 @@ +@testable import TokamakCore // Copyright 2021 Tokamak contributors // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,59 +13,61 @@ // See the License for the specific language governing permissions and // limitations under the License. import XCTest -@testable import TokamakCore final class SpaceCoordinatesTests: XCTestCase { - func testCoordinateSpaceEquatable() { - XCTAssertTrue(CoordinateSpace.global == CoordinateSpace.global) - XCTAssertTrue(CoordinateSpace.local == CoordinateSpace.local) - XCTAssertFalse(CoordinateSpace.global == CoordinateSpace.local) - } + func testCoordinateSpaceEquatable() { + XCTAssertTrue(CoordinateSpace.global == CoordinateSpace.global) + XCTAssertTrue(CoordinateSpace.local == CoordinateSpace.local) + XCTAssertFalse(CoordinateSpace.global == CoordinateSpace.local) + } + + func testCoordinateSpaceHashable() { + let set: Set = [.global, .local, .named("custom")] + XCTAssertEqual(set.count, 3) + } + + func testIsGlobal() { + XCTAssertTrue(CoordinateSpace.global.isGlobal) + XCTAssertFalse(CoordinateSpace.local.isGlobal) + XCTAssertFalse(CoordinateSpace.named("custom").isGlobal) + } + + func testIsLocal() { + XCTAssertTrue(CoordinateSpace.local.isLocal) + XCTAssertFalse(CoordinateSpace.global.isLocal) + XCTAssertFalse(CoordinateSpace.named("custom").isLocal) + } - func testCoordinateSpaceHashable() { - let set: Set = [.global, .local, .named("custom")] - XCTAssertEqual(set.count, 3) - } + func testActiveCoordinateSpaceInitialization() { + let context = CoordinateSpaceContext() + XCTAssertTrue(context.activeCoordinateSpace.isEmpty) + } - func testIsGlobal() { - XCTAssertTrue(CoordinateSpace.global.isGlobal) - XCTAssertFalse(CoordinateSpace.local.isGlobal) - XCTAssertFalse(CoordinateSpace.named("custom").isGlobal) - } + func testActiveCoordinateSpaceUpdate() { + var context = CoordinateSpaceContext() + let origin = CGPoint(x: 10, y: 20) + context.activeCoordinateSpace[.global] = origin - func testIsLocal() { - XCTAssertTrue(CoordinateSpace.local.isLocal) - XCTAssertFalse(CoordinateSpace.global.isLocal) - XCTAssertFalse(CoordinateSpace.named("custom").isLocal) - } + XCTAssertEqual(context.activeCoordinateSpace[.global], origin) + } - func testActiveCoordinateSpaceInitialization() { - let context = CoordinateSpaceContext() - XCTAssertTrue(context.activeCoordinateSpace.isEmpty) - } + func testConvertGlobalSpaceCoordinates() { + let rect = CGRect(x: 10, y: 20, width: 30, height: 40) + let namedOrigin = CGPoint(x: 5, y: 10) + let translatedRect = CoordinateSpace.convertGlobalSpaceCoordinates( + rect: rect, + toNamedOrigin: namedOrigin + ) - func testActiveCoordinateSpaceUpdate() { - var context = CoordinateSpaceContext() - let origin = CGPoint(x: 10, y: 20) - context.activeCoordinateSpace[.global] = origin - - XCTAssertEqual(context.activeCoordinateSpace[.global], origin) - } + XCTAssertEqual(translatedRect.origin, CGPoint(x: 5, y: 10)) + XCTAssertEqual(translatedRect.size, CGSize(width: 30, height: 40)) + } - func testConvertGlobalSpaceCoordinates() { - let rect = CGRect(x: 10, y: 20, width: 30, height: 40) - let namedOrigin = CGPoint(x: 5, y: 10) - let translatedRect = CoordinateSpace.convertGlobalSpaceCoordinates(rect: rect, toNamedOrigin: namedOrigin) - - XCTAssertEqual(translatedRect.origin, CGPoint(x: 5, y: 10)) - XCTAssertEqual(translatedRect.size, CGSize(width: 30, height: 40)) - } + func testConvertPointToNamedOrigin() { + let point = CGPoint(x: 20, y: 30) + let namedOrigin = CGPoint(x: 5, y: 10) + let translatedPoint = CoordinateSpace.convert(point, toNamedOrigin: namedOrigin) - func testConvertPointToNamedOrigin() { - let point = CGPoint(x: 20, y: 30) - let namedOrigin = CGPoint(x: 5, y: 10) - let translatedPoint = CoordinateSpace.convert(point, toNamedOrigin: namedOrigin) - - XCTAssertEqual(translatedPoint, CGPoint(x: 15, y: 20)) - } + XCTAssertEqual(translatedPoint, CGPoint(x: 15, y: 20)) + } } diff --git a/Tests/TokamakTests/ViewReactToDataChangesTests.swift b/Tests/TokamakTests/ViewReactToDataChangesTests.swift index 6bc83ff6e..388206325 100644 --- a/Tests/TokamakTests/ViewReactToDataChangesTests.swift +++ b/Tests/TokamakTests/ViewReactToDataChangesTests.swift @@ -12,98 +12,98 @@ // See the License for the specific language governing permissions and // limitations under the License. -import XCTest -import TokamakTestRenderer import OpenCombineShim +import TokamakTestRenderer +import XCTest @_spi(TokamakCore) @testable import TokamakCore class ViewModifierTests: XCTestCase { - func testOnReceive() { - let publisher = PassthroughSubject() - var receivedValue = "" - - let contentView = Text("Hello, world!") - .onReceive(publisher) { value in - receivedValue = value - } - - XCTAssertEqual(receivedValue, "") - - // Simulate publisher emitting a value - publisher.send("Testing onReceive") - - // Re-evaluate the view - let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) - - XCTAssertEqual(receivedValue, "Testing onReceive") - } - - func testOnChangeWithValue() { - var count = 0 - var oldCount = 0 - - let contentView = Text("Count: \(count)") - .onChange(of: count) { newValue, newOldValue in - count = newValue - oldCount = newOldValue - } - - XCTAssertEqual(count, 0) - XCTAssertEqual(oldCount, 0) - - // Simulate a change in value - count = 5 - - // Re-evaluate the view - let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) - - XCTAssertEqual(count, 5) - XCTAssertEqual(oldCount, 0) - } - - func testOnChangeWithInitialValue() { - var count = 0 - var actionFired = false - - let contentView = Text("Hello, world!") - .onChange(of: count, initial: true) { - actionFired = true - } - - XCTAssertFalse(actionFired) - - // Re-evaluate the view - let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) - - XCTAssertTrue(actionFired) - } - - func testModifierComposition() { - let publisher = PassthroughSubject() - var receivedValue = 0 - var count = 0 - - let contentView = Text("Count: \(count)") - .onChange(of: count) { newValue, newOldValue in - count = newValue - } - .onReceive(publisher) { value in - receivedValue = value - } - - XCTAssertEqual(count, 0) - XCTAssertEqual(receivedValue, 0) - - // Simulate publisher emitting a value - publisher.send(10) - // Simulate a change in value - count = 5 - - // Re-evaluate the view - let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) - - XCTAssertEqual(count, 5) - XCTAssertEqual(receivedValue, 10) - } + func testOnReceive() { + let publisher = PassthroughSubject() + var receivedValue = "" + + let contentView = Text("Hello, world!") + .onReceive(publisher) { value in + receivedValue = value + } + + XCTAssertEqual(receivedValue, "") + + // Simulate publisher emitting a value + publisher.send("Testing onReceive") + + // Re-evaluate the view + let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) + + XCTAssertEqual(receivedValue, "Testing onReceive") + } + + func testOnChangeWithValue() { + var count = 0 + var oldCount = 0 + + let contentView = Text("Count: \(count)") + .onChange(of: count) { newValue, newOldValue in + count = newValue + oldCount = newOldValue + } + + XCTAssertEqual(count, 0) + XCTAssertEqual(oldCount, 0) + + // Simulate a change in value + count = 5 + + // Re-evaluate the view + let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) + + XCTAssertEqual(count, 5) + XCTAssertEqual(oldCount, 0) + } + + func testOnChangeWithInitialValue() { + var count = 0 + var actionFired = false + + let contentView = Text("Hello, world!") + .onChange(of: count, initial: true) { + actionFired = true + } + + XCTAssertFalse(actionFired) + + // Re-evaluate the view + let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) + + XCTAssertTrue(actionFired) + } + + func testModifierComposition() { + let publisher = PassthroughSubject() + var receivedValue = 0 + var count = 0 + + let contentView = Text("Count: \(count)") + .onChange(of: count) { newValue, _ in + count = newValue + } + .onReceive(publisher) { value in + receivedValue = value + } + + XCTAssertEqual(count, 0) + XCTAssertEqual(receivedValue, 0) + + // Simulate publisher emitting a value + publisher.send(10) + // Simulate a change in value + count = 5 + + // Re-evaluate the view + let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) + + XCTAssertEqual(count, 5) + XCTAssertEqual(receivedValue, 10) + } } From 64af12aa5642bfc347179aa5b4b3a1a5984d357d Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Thu, 7 Sep 2023 17:46:17 +1000 Subject: [PATCH 29/33] Update change modifiers --- Sources/TokamakCore/Modifiers/OnChangeModifier.swift | 11 +++++++++++ Sources/TokamakCore/Modifiers/OnReceiveModifier.swift | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/TokamakCore/Modifiers/OnChangeModifier.swift b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift index b049efdc8..b226bf0fd 100644 --- a/Sources/TokamakCore/Modifiers/OnChangeModifier.swift +++ b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift @@ -25,6 +25,17 @@ struct OnChangeModifier: ViewModifier { let initial: Bool let action: (V, V) -> () + init(value: V, initial: Bool, action: @escaping (V, V) -> ()) { + self.value = value + self.initial = initial + self.action = action + + if value != oldValue { + action(oldValue ?? value, value) + oldValue = value + } + } + func body(content: Content) -> some View { content .task { diff --git a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift index 637c179a4..bc2bc8e9f 100644 --- a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift +++ b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift @@ -27,7 +27,7 @@ struct OnReceiveModifier: ViewModifier where P.Failure == Never { } func body(content: Content) -> some View { - content._onUnmount { + content.onDisappear { cancellable.cancel() } } From cb0818a315a865af732ed2acd7b21cc98f87db0b Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Thu, 7 Sep 2023 18:02:29 +1000 Subject: [PATCH 30/33] due to the use of Task in the code, we bump min version --- Package.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.swift b/Package.swift index c062c573c..e45a06630 100644 --- a/Package.swift +++ b/Package.swift @@ -1,12 +1,12 @@ -// swift-tools-version:5.6 +// swift-tools-version:5.7 import PackageDescription let package = Package( name: "Tokamak", platforms: [ - .macOS(.v11), - .iOS(.v13), + .macOS(.v13), + .iOS(.v16), ], products: [ // Products define the executables and libraries produced by a package, From cf86cbf50f42a0eace14503a4d340d7bf07ae0f7 Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 10 Sep 2023 18:49:47 +1000 Subject: [PATCH 31/33] Remove _onUpdate from on change modifier --- .../Modifiers/OnChangeModifier.swift | 29 +++++++------------ .../Modifiers/OnReceiveModifier.swift | 24 +++++++++++---- .../ViewReactToDataChangesTests.swift | 8 ++--- 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/Sources/TokamakCore/Modifiers/OnChangeModifier.swift b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift index b226bf0fd..a11d0ed9b 100644 --- a/Sources/TokamakCore/Modifiers/OnChangeModifier.swift +++ b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift @@ -16,39 +16,30 @@ // import Foundation +import OpenCombineShim struct OnChangeModifier: ViewModifier { @State - var oldValue: V? = nil + private var oldValue: V? let value: V let initial: Bool let action: (V, V) -> () - init(value: V, initial: Bool, action: @escaping (V, V) -> ()) { - self.value = value - self.initial = initial - self.action = action - - if value != oldValue { - action(oldValue ?? value, value) - oldValue = value - } - } - func body(content: Content) -> some View { content - .task { - if initial { - action(value, value) + .onReceive(Just(value)) { newValue in + // TODO: Fix, when @State if working with in a ViewModifier + // ignore first call when oldValue == nil. For now old value is always nil + if newValue != oldValue { + action(oldValue ?? value, newValue) } oldValue = value } - ._onUpdate { - if value != oldValue { - action(oldValue ?? value, value) + .onAppear { + if initial { + action(value, value) } - oldValue = value } } } diff --git a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift index bc2bc8e9f..3a48dec68 100644 --- a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift +++ b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift @@ -18,17 +18,29 @@ import Foundation import OpenCombineShim -struct OnReceiveModifier: ViewModifier where P.Failure == Never { - @State - var cancellable: AnyCancellable +private struct OnReceiveModifier: ViewModifier where P.Failure == Never { + @ObservedObject + var cancellableHolder = CancellableHolder() init(publisher: P, action: @escaping (P.Output) -> ()) { - _cancellable = State(initialValue: publisher.sink(receiveValue: action)) + cancellableHolder.cancellable = publisher.sink(receiveValue: action) } func body(content: Content) -> some View { - content.onDisappear { - cancellable.cancel() + content + } + + // MARK: Types + + final class CancellableHolder: ObservableObject { + var cancellable: AnyCancellable? { + didSet { + oldValue?.cancel() + } + } + + deinit { + cancellable?.cancel() } } } diff --git a/Tests/TokamakTests/ViewReactToDataChangesTests.swift b/Tests/TokamakTests/ViewReactToDataChangesTests.swift index 388206325..ccd11196c 100644 --- a/Tests/TokamakTests/ViewReactToDataChangesTests.swift +++ b/Tests/TokamakTests/ViewReactToDataChangesTests.swift @@ -32,11 +32,11 @@ class ViewModifierTests: XCTestCase { // Simulate publisher emitting a value publisher.send("Testing onReceive") - - // Re-evaluate the view - let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) - XCTAssertEqual(receivedValue, "Testing onReceive") + + // Simulate publisher emitting a value + publisher.send("Second onReceive") + XCTAssertEqual(receivedValue, "Second onReceive") } func testOnChangeWithValue() { From 29998d7bbe024f76800109d1c1d8bf627a3f03cd Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Fri, 15 Sep 2023 18:02:16 +1000 Subject: [PATCH 32/33] Fix change & receive --- .../Modifiers/OnChangeModifier.swift | 14 +++--- .../Modifiers/OnReceiveModifier.swift | 25 +++------- .../Modifiers/ReceiveChangeDemo.swift | 49 +++++++++++++++++++ Sources/TokamakDemo/TokamakDemo.swift | 1 + .../ViewReactToDataChangesTests.swift | 36 +++++++++----- 5 files changed, 86 insertions(+), 39 deletions(-) create mode 100644 Sources/TokamakDemo/Modifiers/ReceiveChangeDemo.swift diff --git a/Sources/TokamakCore/Modifiers/OnChangeModifier.swift b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift index a11d0ed9b..b9bad3632 100644 --- a/Sources/TokamakCore/Modifiers/OnChangeModifier.swift +++ b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift @@ -28,19 +28,17 @@ struct OnChangeModifier: ViewModifier { func body(content: Content) -> some View { content - .onReceive(Just(value)) { newValue in - // TODO: Fix, when @State if working with in a ViewModifier - // ignore first call when oldValue == nil. For now old value is always nil - if newValue != oldValue { - action(oldValue ?? value, newValue) - } - oldValue = value - } .onAppear { if initial { action(value, value) } } + .onReceive(Just(value)) { newValue in + if let oldValue, newValue != oldValue { + action(oldValue, newValue) + } + oldValue = value + } } } diff --git a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift index 3a48dec68..4e2757fbe 100644 --- a/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift +++ b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift @@ -18,31 +18,18 @@ import Foundation import OpenCombineShim -private struct OnReceiveModifier: ViewModifier where P.Failure == Never { - @ObservedObject - var cancellableHolder = CancellableHolder() - +struct OnReceiveModifier: ViewModifier where P.Failure == Never { init(publisher: P, action: @escaping (P.Output) -> ()) { - cancellableHolder.cancellable = publisher.sink(receiveValue: action) + Task { + for await value in publisher.values { + action(value) + } + } } func body(content: Content) -> some View { content } - - // MARK: Types - - final class CancellableHolder: ObservableObject { - var cancellable: AnyCancellable? { - didSet { - oldValue?.cancel() - } - } - - deinit { - cancellable?.cancel() - } - } } public extension View { diff --git a/Sources/TokamakDemo/Modifiers/ReceiveChangeDemo.swift b/Sources/TokamakDemo/Modifiers/ReceiveChangeDemo.swift new file mode 100644 index 000000000..8a8f3a335 --- /dev/null +++ b/Sources/TokamakDemo/Modifiers/ReceiveChangeDemo.swift @@ -0,0 +1,49 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// Created by Szymon on 24/8/2023. +// + +#if os(WASI) && compiler(>=5.5) && (canImport(Concurrency) || canImport(_Concurrency)) +import TokamakDOM + +struct ReceiveChangeDemo: View { + @State + private var count = 0 + @State + private var count2 = 0 + + var body: some View { + VStack { + Text("Count: \(count)") + Text("Count2: \(count2)") + + HStack { + Button("Increment") { + count += 1 + } + Button("Increment2") { + count2 += 1 + } + } + } + .onChange(of: count) { oldValue, newValue in + print("🚺 changed \(oldValue) - \(newValue)") + } + .onChange(of: count2, initial: true) { oldValue, newValue in + print("▶️ init, changed \(oldValue) - \(newValue)") + } + } +} +#endif diff --git a/Sources/TokamakDemo/TokamakDemo.swift b/Sources/TokamakDemo/TokamakDemo.swift index c9c796c4f..9ae24a74e 100644 --- a/Sources/TokamakDemo/TokamakDemo.swift +++ b/Sources/TokamakDemo/TokamakDemo.swift @@ -134,6 +134,7 @@ struct TokamakDemoView: View { Section(header: Text("Modifiers")) { NavItem("Shadow", destination: ShadowDemo()) #if os(WASI) && compiler(>=5.5) && (canImport(Concurrency) || canImport(_Concurrency)) + NavItem("Receive Change", destination: ReceiveChangeDemo()) NavItem("Task", destination: TaskDemo()) #endif } diff --git a/Tests/TokamakTests/ViewReactToDataChangesTests.swift b/Tests/TokamakTests/ViewReactToDataChangesTests.swift index ccd11196c..fc0a04ba0 100644 --- a/Tests/TokamakTests/ViewReactToDataChangesTests.swift +++ b/Tests/TokamakTests/ViewReactToDataChangesTests.swift @@ -23,20 +23,31 @@ class ViewModifierTests: XCTestCase { let publisher = PassthroughSubject() var receivedValue = "" + let firstExpectation = XCTestExpectation(description: "First value received from publisher") + let secondExpectation = XCTestExpectation(description: "Second value received from publisher") + let contentView = Text("Hello, world!") .onReceive(publisher) { value in receivedValue = value + if receivedValue == "Simulate publisher emitting a first value" { + firstExpectation.fulfill() + } else if receivedValue == "Simulate publisher emitting a next value" { + secondExpectation.fulfill() + } } XCTAssertEqual(receivedValue, "") + _ = TestFiberRenderer(.root, size: .zero).render(contentView) - // Simulate publisher emitting a value - publisher.send("Testing onReceive") - XCTAssertEqual(receivedValue, "Testing onReceive") + let fisrPush = "Simulate publisher emitting a first value" + publisher.send(fisrPush) + wait(for: [firstExpectation], timeout: 1.0) + XCTAssertEqual(receivedValue, fisrPush) - // Simulate publisher emitting a value - publisher.send("Second onReceive") - XCTAssertEqual(receivedValue, "Second onReceive") + let secondPush = "Simulate publisher emitting a next value" + publisher.send(secondPush) + wait(for: [secondExpectation], timeout: 1.0) + XCTAssertEqual(receivedValue, secondPush) } func testOnChangeWithValue() { @@ -56,14 +67,14 @@ class ViewModifierTests: XCTestCase { count = 5 // Re-evaluate the view - let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) + _ = TestFiberRenderer(.root, size: .zero).render(contentView) XCTAssertEqual(count, 5) XCTAssertEqual(oldCount, 0) } func testOnChangeWithInitialValue() { - var count = 0 + let count = 0 var actionFired = false let contentView = Text("Hello, world!") @@ -74,12 +85,13 @@ class ViewModifierTests: XCTestCase { XCTAssertFalse(actionFired) // Re-evaluate the view - let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) + _ = TestFiberRenderer(.root, size: .zero).render(contentView) XCTAssertTrue(actionFired) } func testModifierComposition() { + let expectation = XCTestExpectation(description: "") let publisher = PassthroughSubject() var receivedValue = 0 var count = 0 @@ -90,19 +102,19 @@ class ViewModifierTests: XCTestCase { } .onReceive(publisher) { value in receivedValue = value + expectation.fulfill() } XCTAssertEqual(count, 0) XCTAssertEqual(receivedValue, 0) + _ = TestFiberRenderer(.root, size: .zero).render(contentView) // Simulate publisher emitting a value publisher.send(10) // Simulate a change in value count = 5 - // Re-evaluate the view - let reconciler = TestFiberRenderer(.root, size: .zero).render(contentView) - + wait(for: [expectation], timeout: 1.0) XCTAssertEqual(count, 5) XCTAssertEqual(receivedValue, 10) } From 1ef34f86c3eb5596bc792bc95a68216955859b1d Mon Sep 17 00:00:00 2001 From: Szymon Lorenz Date: Sun, 17 Sep 2023 13:08:23 +1000 Subject: [PATCH 33/33] Add gesture tests --- .../Gestures/Recognizers/DragGesture.swift | 13 +- .../Recognizers/LongPressGesture.swift | 2 +- Tests/TokamakTests/GestureTests.swift | 191 ++++++++++++++++++ 3 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 Tests/TokamakTests/GestureTests.swift diff --git a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift index 268cc3d66..c361408e9 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -16,19 +16,18 @@ // import Foundation -import OpenCombine public struct DragGesture: Gesture { @Environment(\._coordinateSpace) private var coordinates - private var globalOrigin: CGPoint? = nil - private var startLocation: CGPoint? = nil - private var previousTimestamp: Date? - private var velocity: CGSize = .zero + private(set) var globalOrigin: CGPoint? = nil + private(set) var startLocation: CGPoint? = nil + private(set) var previousTimestamp: Date? + private(set) var velocity: CGSize = .zero private var onEndedAction: ((Value) -> ())? = nil private var onChangedAction: ((Value) -> ())? = nil - private var minimumDistance: Double - private var coordinateSpace: CoordinateSpace + private(set) var minimumDistance: Double + private(set) var coordinateSpace: CoordinateSpace public var body: DragGesture { self diff --git a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift index 334c954f0..13732f998 100644 --- a/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -20,7 +20,7 @@ import Foundation public struct LongPressGesture: Gesture { public typealias Value = Bool - private var startLocation: CGPoint? = nil + private(set) var startLocation: CGPoint? = nil private var touchStartTime = Date(timeIntervalSince1970: 0) private var maximumDistance: Double private var onEndedAction: ((Value) -> ())? = nil diff --git a/Tests/TokamakTests/GestureTests.swift b/Tests/TokamakTests/GestureTests.swift new file mode 100644 index 000000000..4a45695a0 --- /dev/null +++ b/Tests/TokamakTests/GestureTests.swift @@ -0,0 +1,191 @@ +// Copyright 2020 Tokamak contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import OpenCombineShim +import XCTest + +@_spi(TokamakCore) @testable import TokamakCore + +class GestureTests: XCTestCase { + func testDragGestureBehavior() { + var gesture = DragGesture() + var valueDuringChanged: DragGesture.Value? + var valueDuringEnded: DragGesture.Value? + + // Set onChanged and onEnded actions + gesture = gesture + ._onChanged { value in + valueDuringChanged = value + }.onEnded { value in + valueDuringEnded = value + }.body + + // Simulate a drag gesture + let startLocation = CGPoint(x: 0, y: 0) + let changedLocation = CGPoint(x: 50, y: 50) + let endLocation = CGPoint(x: 100, y: 100) + + var mockContext = _GesturePhaseContext(location: startLocation) + + // Simulate .began phase + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertEqual(gesture.startLocation, startLocation) + XCTAssertNil(valueDuringChanged) + XCTAssertNil(valueDuringEnded) + + // Simulate .changed phase + mockContext = _GesturePhaseContext(location: changedLocation) + XCTAssertTrue(gesture._onPhaseChange(.changed(mockContext))) + XCTAssertEqual(valueDuringChanged?.startLocation, startLocation) + XCTAssertEqual(valueDuringChanged?.location, changedLocation) + XCTAssertNil(valueDuringEnded) + + // Simulate .ended phase + mockContext = _GesturePhaseContext(location: endLocation) + XCTAssertTrue(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertNil(gesture.startLocation) + XCTAssertEqual(valueDuringEnded?.startLocation, startLocation) + XCTAssertEqual(valueDuringEnded?.location, endLocation) + } + + func testLongPressGestureBehavior() async throws { + var gesture = LongPressGesture() + var valueDuringChanged: Bool? + var valueDuringEnded: Bool? + + // Set onChanged and onEnded actions + gesture = gesture + ._onChanged { value in + valueDuringChanged = value + }.onEnded { value in + valueDuringEnded = value + }.body + + // Simulate a long press gesture + let startLocation = CGPoint(x: 0, y: 0) + let changedLocation = CGPoint(x: 50, y: 50) + let endLocation = CGPoint(x: 100, y: 100) + + var mockContext = _GesturePhaseContext(location: startLocation) + + // Simulate .began phase + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertEqual(gesture.startLocation, startLocation) + XCTAssertNotNil(valueDuringChanged) + XCTAssertNil(valueDuringEnded) + + // Simulate .changed phase + mockContext = _GesturePhaseContext(location: changedLocation) + XCTAssertFalse(gesture._onPhaseChange(.changed(mockContext))) + + let minimumDuration = gesture.minimumDuration + 0.2 + try await Task.sleep(for: .seconds(minimumDuration)) + + XCTAssertFalse(gesture._onPhaseChange(.changed(mockContext))) + XCTAssertTrue(valueDuringChanged == true) + XCTAssertNil(valueDuringEnded) + + // Simulate .ended phase + mockContext = _GesturePhaseContext(location: endLocation) + XCTAssertFalse(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertNil(gesture.startLocation) + } + + func testSingleTapGesture() { + var gesture = TapGesture(count: 1) + var valueDuringEnded = false + gesture = gesture + .onEnded { + valueDuringEnded = true + }.body + + let mockContext = _GesturePhaseContext() + // Simulate a single tap gesture + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertTrue(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertTrue(valueDuringEnded) + + // Simulate a cancelled tap gesture + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertFalse(gesture._onPhaseChange(.cancelled)) + } + + func testDoubleTapGesture() async throws { + var gesture = TapGesture(count: 2) + var valueDuringEnded = 0 + gesture = gesture + .onEnded { + valueDuringEnded += 1 + }.body + + let mockContext = _GesturePhaseContext() + // Simulate a double tap gesture + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertFalse(gesture._onPhaseChange(.ended(mockContext))) + + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertTrue(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertEqual(valueDuringEnded, 1) // Double tap completed + + try await Task.sleep(for: .seconds(0.4)) + // Simulate a triple tap gesture + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertFalse(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertEqual(valueDuringEnded, 1) + + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertTrue(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertEqual(valueDuringEnded, 2) + + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertFalse(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertEqual(valueDuringEnded, 2) // Triple tap completed + } + + func testCancelledTapGesture() async throws { + var gesture = TapGesture(count: 2) + var valueDuringEnded = 0 + gesture = gesture + .onEnded { + valueDuringEnded += 1 + }.body + + let mockContext = _GesturePhaseContext() + + // Simulate a double tap gesture + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertFalse(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertEqual(valueDuringEnded, 0) // Double tap not completed yet + + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertFalse(gesture._onPhaseChange(.cancelled)) + XCTAssertEqual(valueDuringEnded, 0) // Double tap cancelled + + try await Task.sleep(for: .seconds(0.4)) + // Simulate a single tap gesture after cancellation + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertFalse(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertEqual(valueDuringEnded, 0) // Single tap after cancellation + + try await Task.sleep(for: .seconds(0.4)) + // Simulate a double tap gesture again + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertFalse(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertEqual(valueDuringEnded, 0) // Double tap not completed yet + + XCTAssertFalse(gesture._onPhaseChange(.began(mockContext))) + XCTAssertFalse(gesture._onPhaseChange(.ended(mockContext))) + XCTAssertEqual(valueDuringEnded, 0) // Double tap completed + } +}