diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 6a34dc461ff..b808d392fe1 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -1095,7 +1095,11 @@ public final class AppKitBackend: AppBackend { } } - public func createTapGestureTarget(wrapping child: Widget) -> Widget { + public func createTapGestureTarget(wrapping child: Widget, gesture _: TapGesture) -> Widget { + if child.subviews.count >= 2 && child.subviews[1] is NSCustomTapGestureTarget { + return child + } + let container = NSView() container.addSubview(child) @@ -1122,19 +1126,79 @@ public final class AppKitBackend: AppBackend { public func updateTapGestureTarget( _ container: Widget, + gesture: TapGesture, action: @escaping () -> Void ) { let tapGestureTarget = container.subviews[1] as! NSCustomTapGestureTarget - tapGestureTarget.leftClickHandler = action + switch gesture.kind { + case .primary: + tapGestureTarget.leftClickHandler = action + case .secondary: + tapGestureTarget.rightClickHandler = action + case .longPress: + tapGestureTarget.longPressHandler = action + } } } final class NSCustomTapGestureTarget: NSView { - var leftClickHandler: (() -> Void)? + var leftClickHandler: (() -> Void)? { + didSet { + if leftClickHandler != nil && leftClickRecognizer == nil { + let gestureRecognizer = NSClickGestureRecognizer( + target: self, action: #selector(leftClick)) + addGestureRecognizer(gestureRecognizer) + leftClickRecognizer = gestureRecognizer + } + } + } + var rightClickHandler: (() -> Void)? { + didSet { + if rightClickHandler != nil && rightClickRecognizer == nil { + let gestureRecognizer = NSClickGestureRecognizer( + target: self, action: #selector(rightClick)) + gestureRecognizer.buttonMask = 1 << 1 + addGestureRecognizer(gestureRecognizer) + rightClickRecognizer = gestureRecognizer + } + } + } + var longPressHandler: (() -> Void)? { + didSet { + if longPressHandler != nil && longPressRecognizer == nil { + let gestureRecognizer = NSPressGestureRecognizer( + target: self, action: #selector(longPress)) + // Both GTK and UIKit default to half a second for long presses + gestureRecognizer.minimumPressDuration = 0.5 + addGestureRecognizer(gestureRecognizer) + longPressRecognizer = gestureRecognizer + } + } + } + + private var leftClickRecognizer: NSClickGestureRecognizer? + private var rightClickRecognizer: NSClickGestureRecognizer? + private var longPressRecognizer: NSPressGestureRecognizer? - override func mouseDown(with event: NSEvent) { + @objc + func leftClick() { leftClickHandler?() } + + @objc + func rightClick() { + rightClickHandler?() + } + + @objc + func longPress(sender: NSPressGestureRecognizer) { + // GTK emits the event once as soon as the gesture is recognized. + // AppKit emits it twice, once when it's recognized and once when you release the mouse button. + // For consistency, ignore the second event. + if sender.state != .ended { + longPressHandler?() + } + } } final class NSCustomMenuItem: NSMenuItem { diff --git a/Sources/Gtk/Generated/GestureLongPress.swift b/Sources/Gtk/Generated/GestureLongPress.swift new file mode 100644 index 00000000000..ae44aaac12a --- /dev/null +++ b/Sources/Gtk/Generated/GestureLongPress.swift @@ -0,0 +1,72 @@ +import CGtk + +/// `GtkGestureLongPress` is a `GtkGesture` for long presses. +/// +/// This gesture is also known as “Press and Hold”. +/// +/// When the timeout is exceeded, the gesture is triggering the +/// [signal@Gtk.GestureLongPress::pressed] signal. +/// +/// If the touchpoint is lifted before the timeout passes, or if +/// it drifts too far of the initial press point, the +/// [signal@Gtk.GestureLongPress::cancelled] signal will be emitted. +/// +/// How long the timeout is before the ::pressed signal gets emitted is +/// determined by the [property@Gtk.Settings:gtk-long-press-time] setting. +/// It can be modified by the [property@Gtk.GestureLongPress:delay-factor] +/// property. +public class GestureLongPress: GestureSingle { + /// Returns a newly created `GtkGesture` that recognizes long presses. + public convenience init() { + self.init( + gtk_gesture_long_press_new() + ) + } + + public override func registerSignals() { + super.registerSignals() + + addSignal(name: "cancelled") { [weak self] () in + guard let self = self else { return } + self.cancelled?(self) + } + + let handler1: + @convention(c) (UnsafeMutableRawPointer, Double, Double, UnsafeMutableRawPointer) -> + Void = + { _, value1, value2, data in + SignalBox2.run(data, value1, value2) + } + + addSignal(name: "pressed", handler: gCallback(handler1)) { + [weak self] (param0: Double, param1: Double) in + guard let self = self else { return } + self.pressed?(self, param0, param1) + } + + let handler2: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::delay-factor", handler: gCallback(handler2)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyDelayFactor?(self, param0) + } + } + + /// Factor by which to modify the default timeout. + @GObjectProperty(named: "delay-factor") public var delayFactor: Double + + /// Emitted whenever a press moved too far, or was released + /// before [signal@Gtk.GestureLongPress::pressed] happened. + public var cancelled: ((GestureLongPress) -> Void)? + + /// Emitted whenever a press goes unmoved/unreleased longer than + /// what the GTK defaults tell. + public var pressed: ((GestureLongPress, Double, Double) -> Void)? + + public var notifyDelayFactor: ((GestureLongPress, OpaquePointer) -> Void)? +} diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index 0c90437aa30..900000f412b 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -934,7 +934,10 @@ public final class Gtk3Backend: AppBackend { gtk_native_dialog_show(chooser.gobjectPointer.cast()) } - public func createTapGestureTarget(wrapping child: Widget) -> Widget { + public func createTapGestureTarget(wrapping child: Widget, gesture: TapGesture) -> Widget { + if gesture != .primary { + fatalError("Unsupported gesture type \(gesture)") + } let eventBox = Gtk3.EventBox() eventBox.setChild(to: child) eventBox.aboveChild = true @@ -943,8 +946,12 @@ public final class Gtk3Backend: AppBackend { public func updateTapGestureTarget( _ tapGestureTarget: Widget, + gesture: TapGesture, action: @escaping () -> Void ) { + if gesture != .primary { + fatalError("Unsupported gesture type \(gesture)") + } tapGestureTarget.onButtonPress = { _, buttonEvent in let eventType = buttonEvent.type guard diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index c8776ac3e48..5149078814f 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -954,22 +954,59 @@ public final class GtkBackend: AppBackend { gtk_native_dialog_show(chooser.gobjectPointer.cast()) } - public func createTapGestureTarget(wrapping child: Widget) -> Widget { - let gesture = Gtk.GestureClick() - child.addEventController(gesture) + public func createTapGestureTarget(wrapping child: Widget, gesture: TapGesture) -> Widget { + var gtkGesture: GestureSingle + switch gesture.kind { + case .primary: + gtkGesture = GestureClick() + case .secondary: + gtkGesture = GestureClick() + gtk_gesture_single_set_button(gtkGesture.opaquePointer, guint(GDK_BUTTON_SECONDARY)) + case .longPress: + gtkGesture = GestureLongPress() + } + child.addEventController(gtkGesture) return child } public func updateTapGestureTarget( _ tapGestureTarget: Widget, + gesture: TapGesture, action: @escaping () -> Void ) { - let gesture = tapGestureTarget.eventControllers[0] as! Gtk.GestureClick - gesture.pressed = { _, nPress, _, _ in - guard nPress == 1 else { - return - } - action() + switch gesture.kind { + case .primary: + let gesture = + tapGestureTarget.eventControllers.first { + $0 is GestureClick + && gtk_gesture_single_get_button($0.opaquePointer) == GDK_BUTTON_PRIMARY + } as! GestureClick + gesture.pressed = { _, nPress, _, _ in + guard nPress == 1 else { + return + } + action() + } + case .secondary: + let gesture = + tapGestureTarget.eventControllers.first { + $0 is GestureClick + && gtk_gesture_single_get_button($0.opaquePointer) + == GDK_BUTTON_SECONDARY + } as! GestureClick + gesture.pressed = { _, nPress, _, _ in + guard nPress == 1 else { + return + } + action() + } + case .longPress: + let gesture = + tapGestureTarget.eventControllers.lazy.compactMap { $0 as? GestureLongPress } + .first! + gesture.pressed = { _, _, _ in + action() + } } } diff --git a/Sources/GtkCodeGen/GtkCodeGen.swift b/Sources/GtkCodeGen/GtkCodeGen.swift index 44ddef9f6d8..da7b0b62be0 100644 --- a/Sources/GtkCodeGen/GtkCodeGen.swift +++ b/Sources/GtkCodeGen/GtkCodeGen.swift @@ -86,7 +86,7 @@ struct GtkCodeGen { let allowListedClasses = [ "Button", "Entry", "Label", "TextView", "Range", "Scale", "Image", "Switch", "Spinner", "ProgressBar", "FileChooserNative", "NativeDialog", "GestureClick", "GestureSingle", - "Gesture", "EventController", + "Gesture", "EventController", "GestureLongPress", ] let gtk3AllowListedClasses = ["MenuShell", "EventBox"] let gtk4AllowListedClasses = ["Picture", "DropDown", "Popover"] diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 5ccdd066825..a3b79d078d8 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -508,11 +508,12 @@ public protocol AppBackend { /// Wraps a view in a container that can receive tap gestures. Some /// backends may not have to wrap the child, in which case they may /// just return the child as is. - func createTapGestureTarget(wrapping child: Widget) -> Widget + func createTapGestureTarget(wrapping child: Widget, gesture: TapGesture) -> Widget /// Update the tap gesture target with a new action. Replaces the old /// action. func updateTapGestureTarget( _ tapGestureTarget: Widget, + gesture: TapGesture, action: @escaping () -> Void ) } @@ -813,11 +814,12 @@ extension AppBackend { todo() } - public func createTapGestureTarget(wrapping child: Widget) -> Widget { + public func createTapGestureTarget(wrapping child: Widget, gesture: TapGesture) -> Widget { todo() } public func updateTapGestureTarget( _ clickTarget: Widget, + gesture: TapGesture, action: @escaping () -> Void ) { todo() diff --git a/Sources/SwiftCrossUI/Modifiers/OnTapGestureModifier.swift b/Sources/SwiftCrossUI/Modifiers/OnTapGestureModifier.swift index 60b412f5c8e..860502e9a15 100644 --- a/Sources/SwiftCrossUI/Modifiers/OnTapGestureModifier.swift +++ b/Sources/SwiftCrossUI/Modifiers/OnTapGestureModifier.swift @@ -1,14 +1,35 @@ +public struct TapGesture: Sendable, Hashable { + package var kind: TapGestureKind + + /// The idiomatic "primary" interaction for the device, such as a left-click with the mouse + /// or normal tap on a touch screen. + public static let primary = TapGesture(kind: .primary) + /// The idiomatic "secondary" interaction for the device, such as a right-click with the + /// mouse or long press on a touch screen. + public static let secondary = TapGesture(kind: .secondary) + /// A long press of the same interaction type as ``primary``. May be equivalent to + /// ``secondary`` on some backends, particularly on mobile devices. + public static let longPress = TapGesture(kind: .longPress) + + package enum TapGestureKind { + case primary, secondary, longPress + } +} + extension View { /// Adds an action to perform when the user taps or clicks this view. /// - /// Any tappable elements within the view will no longer be tappable. - public func onTapGesture(perform action: @escaping () -> Void) -> some View { - OnTapGestureModifier(body: TupleView1(self), action: action) + /// Any tappable elements within the view will no longer be tappable with the same gesture + /// type. + public func onTapGesture(gesture: TapGesture = .primary, perform action: @escaping () -> Void) + -> some View + { + OnTapGestureModifier(body: TupleView1(self), gesture: gesture, action: action) } /// Adds an action to run when this view is clicked. Any clickable elements /// within the view will no longer be clickable. - @available(*, deprecated, renamed: "onTapGesture(perform:)") + @available(*, deprecated, renamed: "onTapGesture(gesture:perform:)") public func onClick(perform action: @escaping () -> Void) -> some View { onTapGesture(perform: action) } @@ -18,6 +39,7 @@ struct OnTapGestureModifier: TypeSafeView { typealias Children = TupleView1.Children var body: TupleView1 + var gesture: TapGesture var action: () -> Void func children( @@ -36,7 +58,7 @@ struct OnTapGestureModifier: TypeSafeView { _ children: Children, backend: Backend ) -> Backend.Widget { - backend.createTapGestureTarget(wrapping: children.child0.widget.into()) + backend.createTapGestureTarget(wrapping: children.child0.widget.into(), gesture: gesture) } func update( @@ -55,7 +77,7 @@ struct OnTapGestureModifier: TypeSafeView { ) if !dryRun { backend.setSize(of: widget, to: childResult.size.size) - backend.updateTapGestureTarget(widget, action: action) + backend.updateTapGestureTarget(widget, gesture: gesture, action: action) } return childResult } diff --git a/Sources/UIKitBackend/UIKitBackend+Control.swift b/Sources/UIKitBackend/UIKitBackend+Control.swift index c5447485b88..79e53576869 100644 --- a/Sources/UIKitBackend/UIKitBackend+Control.swift +++ b/Sources/UIKitBackend/UIKitBackend+Control.swift @@ -104,21 +104,45 @@ final class TextFieldWidget: WrapperWidget, UITextFieldDelegate { #endif final class TappableWidget: ContainerWidget { - private var gestureRecognizer: UITapGestureRecognizer! - var onTap: (() -> Void)? - - override init(child: some WidgetProtocol) { - super.init(child: child) + private var tapGestureRecognizer: UITapGestureRecognizer? + private var longPressGestureRecognizer: UILongPressGestureRecognizer? + + var onTap: (() -> Void)? { + didSet { + if onTap != nil && tapGestureRecognizer == nil { + let gestureRecognizer = UITapGestureRecognizer( + target: self, action: #selector(viewTouched)) + child.view.addGestureRecognizer(gestureRecognizer) + self.tapGestureRecognizer = gestureRecognizer + } + } + } - gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(viewTouched)) - gestureRecognizer.cancelsTouchesInView = true - child.view.addGestureRecognizer(gestureRecognizer) + var onLongPress: (() -> Void)? { + didSet { + if onLongPress != nil && longPressGestureRecognizer == nil { + let gestureRecognizer = UILongPressGestureRecognizer( + target: self, action: #selector(viewLongPressed(sender:))) + child.view.addGestureRecognizer(gestureRecognizer) + self.longPressGestureRecognizer = gestureRecognizer + } + } } @objc func viewTouched() { onTap?() } + + @objc + func viewLongPressed(sender: UILongPressGestureRecognizer) { + // GTK emits the event once as soon as the gesture is recognized. + // UIKit emits it twice, once when it's recognized and once when you lift your finger. + // For consistency, ignore the second event. + if sender.state != .ended { + onLongPress?() + } + } } @available(tvOS, unavailable) @@ -238,16 +262,25 @@ extension UIKitBackend { wrapper.setOn(state) } - public func createTapGestureTarget(wrapping child: Widget) -> Widget { - TappableWidget(child: child) + public func createTapGestureTarget(wrapping child: Widget, gesture _: TapGesture) -> Widget { + if child is TappableWidget { + child + } else { + TappableWidget(child: child) + } } public func updateTapGestureTarget( _ tapGestureTarget: Widget, + gesture: TapGesture, action: @escaping () -> Void ) { let wrapper = tapGestureTarget as! TappableWidget - wrapper.onTap = action + if gesture == .primary { + wrapper.onTap = action + } else { + wrapper.onLongPress = action + } } #if os(iOS) diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 50564bbae19..9dfd047217b 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -1014,7 +1014,10 @@ public final class WinUIBackend: AppBackend { // ) { // } - public func createTapGestureTarget(wrapping child: Widget) -> Widget { + public func createTapGestureTarget(wrapping child: Widget, gesture: TapGesture) -> Widget { + if gesture != .primary { + fatalError("Unsupported gesture type \(gesture)") + } let tapGestureTarget = TapGestureTarget() addChild(child, to: tapGestureTarget) tapGestureTarget.child = child @@ -1037,8 +1040,12 @@ public final class WinUIBackend: AppBackend { public func updateTapGestureTarget( _ tapGestureTarget: Widget, + gesture: TapGesture, action: @escaping () -> Void ) { + if gesture != .primary { + fatalError("Unsupported gesture type \(gesture)") + } let tapGestureTarget = tapGestureTarget as! TapGestureTarget tapGestureTarget.clickHandler = action tapGestureTarget.width = tapGestureTarget.child!.width