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/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, 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 new file mode 100644 index 000000000..be4b174e8 --- /dev/null +++ b/Sources/TokamakCore/CoordinateSpace/CoordinateSpace.swift @@ -0,0 +1,48 @@ +// 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 +} + +public extension CoordinateSpace { + var isGlobal: Bool { + switch self { + case .global: + 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 new file mode 100644 index 000000000..de17286a3 --- /dev/null +++ b/Sources/TokamakCore/CoordinateSpace/CoordinateSpaceEnviroment.swift @@ -0,0 +1,48 @@ +// 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 + +private struct CoordinateSpaceEnvironmentKey: EnvironmentKey { + static let defaultValue: CoordinateSpaceContext = .init() +} + +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 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 { + 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 new file mode 100644 index 000000000..c7a5d5396 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Composing/ExclusiveGesture.swift @@ -0,0 +1,39 @@ +// 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..94483fb07 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Composing/SequenceGesture.swift @@ -0,0 +1,39 @@ +// 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..21ac858b0 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Composing/SimultaneousGesture.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 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..5721da9c8 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Gesture.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 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) -> ()) -> Self + /// Adds an action to perform when the gesture ends. + func _onEnded(perform action: @escaping (Value) -> ()) -> Self +} + +// MARK: Performing the gesture + +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 + +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 + +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 + +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) + } +} diff --git a/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift new file mode 100644 index 000000000..5a3582b80 --- /dev/null +++ b/Sources/TokamakCore/Gestures/GestureEnvironmentKey.swift @@ -0,0 +1,117 @@ +// 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: 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 } + } +} + +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) + } + } + + /// 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 + } + + /// 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: 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 { + 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 new file mode 100644 index 000000000..e39f55fee --- /dev/null +++ b/Sources/TokamakCore/Gestures/GestureMask.swift @@ -0,0 +1,46 @@ +// 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: OptionSet, Sendable { + public typealias RawValue = Int8 + public var rawValue: Int8 + + // MARK: - OptionSet + + public init(rawValue: Int8) { + self.rawValue = 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] + + /// 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) + + /// 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 new file mode 100644 index 000000000..9f7496af0 --- /dev/null +++ b/Sources/TokamakCore/Gestures/GestureState.swift @@ -0,0 +1,46 @@ +// 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..317109110 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Performing/GestureStateGesture.swift @@ -0,0 +1,65 @@ +// 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) -> () + private var onEnded: ((Value) -> ())? + + 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 new file mode 100644 index 000000000..69e6c8ead --- /dev/null +++ b/Sources/TokamakCore/Gestures/Performing/_ChangedGesture.swift @@ -0,0 +1,55 @@ +// 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) -> () + private var onEnded: ((Value) -> ())? + + 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 new file mode 100644 index 000000000..2cbac375b --- /dev/null +++ b/Sources/TokamakCore/Gestures/Performing/_EndedGesture.swift @@ -0,0 +1,55 @@ +// 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) -> () + private var onChanged: ((Value) -> ())? + + 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 new file mode 100644 index 000000000..c361408e9 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Recognizers/DragGesture.swift @@ -0,0 +1,180 @@ +// 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 { + @Environment(\._coordinateSpace) + private var coordinates + 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(set) var minimumDistance: Double + private(set) 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 + } + 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 new file mode 100644 index 000000000..13732f998 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Recognizers/LongPressGesture.swift @@ -0,0 +1,170 @@ +// 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(set) 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 + } + + 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 + +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) -> ())? = 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 new file mode 100644 index 000000000..6bb176c96 --- /dev/null +++ b/Sources/TokamakCore/Gestures/Recognizers/TapGesture.swift @@ -0,0 +1,103 @@ +// 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 phase: _GesturePhase = .cancelled + private var onEndedAction: ((Value) -> ())? = nil + + private var isActive: Bool { + switch phase { + case .began, .changed: + return true + default: + return false + } + } + + 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 + } + } + // 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 + +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/_GesturePhase.swift b/Sources/TokamakCore/Gestures/_GesturePhase.swift new file mode 100644 index 000000000..cddb55734 --- /dev/null +++ b/Sources/TokamakCore/Gestures/_GesturePhase.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 23/7/2023. +// + +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 +} + +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 + } +} diff --git a/Sources/TokamakCore/Gestures/_GesturePriority.swift b/Sources/TokamakCore/Gestures/_GesturePriority.swift new file mode 100644 index 000000000..231ae96f5 --- /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/Modifiers/CoordinateSpaceModifier.swift b/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.swift new file mode 100644 index 000000000..b6adc5616 --- /dev/null +++ b/Sources/TokamakCore/Modifiers/CoordinateSpaceModifier.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 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)) + } + } + } + } +} + +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 new file mode 100644 index 000000000..b9bad3632 --- /dev/null +++ b/Sources/TokamakCore/Modifiers/OnChangeModifier.swift @@ -0,0 +1,76 @@ +// 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 OnChangeModifier: ViewModifier { + @State + private var oldValue: V? + + let value: V + let initial: Bool + let action: (V, V) -> () + + func body(content: Content) -> some View { + content + .onAppear { + if initial { + action(value, value) + } + } + .onReceive(Just(value)) { newValue in + if let oldValue, newValue != oldValue { + action(oldValue, newValue) + } + oldValue = value + } + } +} + +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 new file mode 100644 index 000000000..4e2757fbe --- /dev/null +++ b/Sources/TokamakCore/Modifiers/OnReceiveModifier.swift @@ -0,0 +1,48 @@ +// 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 { + init(publisher: P, action: @escaping (P.Output) -> ()) { + Task { + for await value in publisher.values { + action(value) + } + } + } + + func body(content: Content) -> some View { + content + } +} + +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 8d851b0ae..90e0baa6b 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() {} @@ -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 ea033cf9b..437f89a51 100644 --- a/Sources/TokamakCore/State/State.swift +++ b/Sources/TokamakCore/State/State.swift @@ -32,10 +32,22 @@ 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 } nonmutating set { setter?(newValue, Transaction._active ?? .init(animation: nil)) } 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 new file mode 100644 index 000000000..3508850b6 --- /dev/null +++ b/Sources/TokamakCore/Views/Gestures/_GestureView.swift @@ -0,0 +1,177 @@ +// 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 { + 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 + } + + var minimumDuration: Double? { + guard let longPressGesture = coordinator.gesture as? LongPressGesture else { + return nil + } + 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 + } + + 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) + } + } + + 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 + +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 + 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 + 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..0c1885057 100644 --- a/Sources/TokamakCore/Views/Layout/GeometryReader.swift +++ b/Sources/TokamakCore/Views/Layout/GeometryReader.swift @@ -15,19 +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 makeProxy(from size: CGSize) -> GeometryProxy { - .init(size: size) + 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) + } + } } -// FIXME: to be implemented -// public enum CoordinateSpace { -// case global -// case local -// case named(AnyHashable) -// } +public func makeProxy(from rect: CGRect) -> GeometryProxy { + .init(globalRect: rect) +} // public struct Anchor { // let box: AnchorValueBoxBase @@ -38,7 +59,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/TokamakDOM/App/App.swift b/Sources/TokamakDOM/App/App.swift index da786ee19..8372e2677 100644 --- a/Sources/TokamakDOM/App/App.swift +++ b/Sources/TokamakDOM/App/App.swift @@ -56,6 +56,7 @@ public extension App { _ = body.appendChild!(div) ScenePhaseObserver.observe() + GestureEventsObserver.observe(div) ColorSchemeObserver.observe(div) } diff --git a/Sources/TokamakDOM/App/GestureEventsObserver.swift b/Sources/TokamakDOM/App/GestureEventsObserver.swift new file mode 100644 index 000000000..79876e7b2 --- /dev/null +++ b/Sources/TokamakDOM/App/GestureEventsObserver.swift @@ -0,0 +1,89 @@ +// 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(_ 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 + } + + 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/Core.swift b/Sources/TokamakDOM/Core.swift index 30abdc7e1..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 @@ -176,6 +177,15 @@ public typealias Toggle = TokamakCore.Toggle public typealias VStack = TokamakCore.VStack 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 +public typealias DragGesture = TokamakCore.DragGesture +public typealias LongPressGesture = TokamakCore.LongPressGesture + // MARK: Special Views public typealias View = TokamakCore.View diff --git a/Sources/TokamakDOM/DOMFiberRenderer.swift b/Sources/TokamakDOM/DOMFiberRenderer.swift index ccd7095c5..670d856e9 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(reference) } 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 new file mode 100644 index 000000000..deff12080 --- /dev/null +++ b/Sources/TokamakDOM/Views/Gestures/_GestureView.swift @@ -0,0 +1,55 @@ +// 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 + +@_spi(TokamakStaticHTML) +import TokamakStaticHTML + +extension TokamakCore._GestureView: DOMPrimitive { + 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) + } + ) + } + } +} diff --git a/Sources/TokamakDOM/Views/Layout/GeometryReader.swift b/Sources/TokamakDOM/Views/Layout/GeometryReader.swift index 68eb0e281..0401ec0d3 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 { + { $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 @@ -38,9 +53,9 @@ 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 size: CGSize? + var rect: CGRect? } let content: (GeometryProxy) -> Content @@ -50,8 +65,8 @@ struct _GeometryReader: View { var body: some View { HTML("div", ["class": "_tokamak-geometryreader"]) { - if let size = state.size { - content(makeProxy(from: size)) + if let rect = state.rect { + content(makeProxy(from: rect)) } else { EmptyView() } @@ -61,19 +76,27 @@ 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?.contentRect.object, - let width = rect.width.number, - let height = rect.height.number - else { return .undefined } - state?.size = .init(width: width, height: height) + 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/Gestures/GestureCoordinateSpaceDemo.swift b/Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift new file mode 100644 index 000000000..63c03ceb3 --- /dev/null +++ b/Sources/TokamakDemo/Gestures/GestureCoordinateSpaceDemo.swift @@ -0,0 +1,71 @@ +// 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..4b97f4387 --- /dev/null +++ b/Sources/TokamakDemo/Gestures/GesturesDemo.swift @@ -0,0 +1,211 @@ +// 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 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() + } + + 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, _ 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 + countDragLongPress += 1 + } + ) + Text("Drag Count: \(countDragLongPress)") + } + } + } + + var longPressGestures: some View { + VStack(alignment: .leading, spacing: 8) { + Text("LongPress Gestures") + + 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") + } + } + } +} 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 2a6452acd..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 } @@ -148,6 +149,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()) 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/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 + } +} diff --git a/Tests/TokamakTests/SpaceCoordinatesTests.swift b/Tests/TokamakTests/SpaceCoordinatesTests.swift new file mode 100644 index 000000000..e1c0930bc --- /dev/null +++ b/Tests/TokamakTests/SpaceCoordinatesTests.swift @@ -0,0 +1,73 @@ +@testable import TokamakCore +// 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 + +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)) + } +} diff --git a/Tests/TokamakTests/ViewReactToDataChangesTests.swift b/Tests/TokamakTests/ViewReactToDataChangesTests.swift new file mode 100644 index 000000000..fc0a04ba0 --- /dev/null +++ b/Tests/TokamakTests/ViewReactToDataChangesTests.swift @@ -0,0 +1,121 @@ +// 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 TokamakTestRenderer +import XCTest + +@_spi(TokamakCore) @testable import TokamakCore + +class ViewModifierTests: XCTestCase { + func testOnReceive() { + 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) + + let fisrPush = "Simulate publisher emitting a first value" + publisher.send(fisrPush) + wait(for: [firstExpectation], timeout: 1.0) + XCTAssertEqual(receivedValue, fisrPush) + + let secondPush = "Simulate publisher emitting a next value" + publisher.send(secondPush) + wait(for: [secondExpectation], timeout: 1.0) + XCTAssertEqual(receivedValue, secondPush) + } + + 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 + _ = TestFiberRenderer(.root, size: .zero).render(contentView) + + XCTAssertEqual(count, 5) + XCTAssertEqual(oldCount, 0) + } + + func testOnChangeWithInitialValue() { + let count = 0 + var actionFired = false + + let contentView = Text("Hello, world!") + .onChange(of: count, initial: true) { + actionFired = true + } + + XCTAssertFalse(actionFired) + + // Re-evaluate the view + _ = TestFiberRenderer(.root, size: .zero).render(contentView) + + XCTAssertTrue(actionFired) + } + + func testModifierComposition() { + let expectation = XCTestExpectation(description: "") + 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 + 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 + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(count, 5) + XCTAssertEqual(receivedValue, 10) + } +}