Skip to content

Commit 1da7e29

Browse files
authored
Support secondary tap and long press gestures (#117)
* Initial work for Gtk and UIKit * Get right-click and long press fully working in GTK 4 * Fix UIKit implementation * Get other press types working in AppKitBackend * Fix tap gesture test * Add parameter to Gtk3 and WinUI, and remove extraneous override in UIKit Gtk3 and WinUI still only actually support .primary, but at least now they'll compile. * address PR comments
1 parent c1a2343 commit 1da7e29

File tree

9 files changed

+279
-35
lines changed

9 files changed

+279
-35
lines changed

Sources/AppKitBackend/AppKitBackend.swift

+68-4
Original file line numberDiff line numberDiff line change
@@ -1095,7 +1095,11 @@ public final class AppKitBackend: AppBackend {
10951095
}
10961096
}
10971097

1098-
public func createTapGestureTarget(wrapping child: Widget) -> Widget {
1098+
public func createTapGestureTarget(wrapping child: Widget, gesture _: TapGesture) -> Widget {
1099+
if child.subviews.count >= 2 && child.subviews[1] is NSCustomTapGestureTarget {
1100+
return child
1101+
}
1102+
10991103
let container = NSView()
11001104

11011105
container.addSubview(child)
@@ -1122,19 +1126,79 @@ public final class AppKitBackend: AppBackend {
11221126

11231127
public func updateTapGestureTarget(
11241128
_ container: Widget,
1129+
gesture: TapGesture,
11251130
action: @escaping () -> Void
11261131
) {
11271132
let tapGestureTarget = container.subviews[1] as! NSCustomTapGestureTarget
1128-
tapGestureTarget.leftClickHandler = action
1133+
switch gesture.kind {
1134+
case .primary:
1135+
tapGestureTarget.leftClickHandler = action
1136+
case .secondary:
1137+
tapGestureTarget.rightClickHandler = action
1138+
case .longPress:
1139+
tapGestureTarget.longPressHandler = action
1140+
}
11291141
}
11301142
}
11311143

11321144
final class NSCustomTapGestureTarget: NSView {
1133-
var leftClickHandler: (() -> Void)?
1145+
var leftClickHandler: (() -> Void)? {
1146+
didSet {
1147+
if leftClickHandler != nil && leftClickRecognizer == nil {
1148+
let gestureRecognizer = NSClickGestureRecognizer(
1149+
target: self, action: #selector(leftClick))
1150+
addGestureRecognizer(gestureRecognizer)
1151+
leftClickRecognizer = gestureRecognizer
1152+
}
1153+
}
1154+
}
1155+
var rightClickHandler: (() -> Void)? {
1156+
didSet {
1157+
if rightClickHandler != nil && rightClickRecognizer == nil {
1158+
let gestureRecognizer = NSClickGestureRecognizer(
1159+
target: self, action: #selector(rightClick))
1160+
gestureRecognizer.buttonMask = 1 << 1
1161+
addGestureRecognizer(gestureRecognizer)
1162+
rightClickRecognizer = gestureRecognizer
1163+
}
1164+
}
1165+
}
1166+
var longPressHandler: (() -> Void)? {
1167+
didSet {
1168+
if longPressHandler != nil && longPressRecognizer == nil {
1169+
let gestureRecognizer = NSPressGestureRecognizer(
1170+
target: self, action: #selector(longPress))
1171+
// Both GTK and UIKit default to half a second for long presses
1172+
gestureRecognizer.minimumPressDuration = 0.5
1173+
addGestureRecognizer(gestureRecognizer)
1174+
longPressRecognizer = gestureRecognizer
1175+
}
1176+
}
1177+
}
1178+
1179+
private var leftClickRecognizer: NSClickGestureRecognizer?
1180+
private var rightClickRecognizer: NSClickGestureRecognizer?
1181+
private var longPressRecognizer: NSPressGestureRecognizer?
11341182

1135-
override func mouseDown(with event: NSEvent) {
1183+
@objc
1184+
func leftClick() {
11361185
leftClickHandler?()
11371186
}
1187+
1188+
@objc
1189+
func rightClick() {
1190+
rightClickHandler?()
1191+
}
1192+
1193+
@objc
1194+
func longPress(sender: NSPressGestureRecognizer) {
1195+
// GTK emits the event once as soon as the gesture is recognized.
1196+
// AppKit emits it twice, once when it's recognized and once when you release the mouse button.
1197+
// For consistency, ignore the second event.
1198+
if sender.state != .ended {
1199+
longPressHandler?()
1200+
}
1201+
}
11381202
}
11391203

11401204
final class NSCustomMenuItem: NSMenuItem {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import CGtk
2+
3+
/// `GtkGestureLongPress` is a `GtkGesture` for long presses.
4+
///
5+
/// This gesture is also known as “Press and Hold”.
6+
///
7+
/// When the timeout is exceeded, the gesture is triggering the
8+
/// [[email protected]::pressed] signal.
9+
///
10+
/// If the touchpoint is lifted before the timeout passes, or if
11+
/// it drifts too far of the initial press point, the
12+
/// [[email protected]::cancelled] signal will be emitted.
13+
///
14+
/// How long the timeout is before the ::pressed signal gets emitted is
15+
/// determined by the [[email protected]:gtk-long-press-time] setting.
16+
/// It can be modified by the [[email protected]:delay-factor]
17+
/// property.
18+
public class GestureLongPress: GestureSingle {
19+
/// Returns a newly created `GtkGesture` that recognizes long presses.
20+
public convenience init() {
21+
self.init(
22+
gtk_gesture_long_press_new()
23+
)
24+
}
25+
26+
public override func registerSignals() {
27+
super.registerSignals()
28+
29+
addSignal(name: "cancelled") { [weak self] () in
30+
guard let self = self else { return }
31+
self.cancelled?(self)
32+
}
33+
34+
let handler1:
35+
@convention(c) (UnsafeMutableRawPointer, Double, Double, UnsafeMutableRawPointer) ->
36+
Void =
37+
{ _, value1, value2, data in
38+
SignalBox2<Double, Double>.run(data, value1, value2)
39+
}
40+
41+
addSignal(name: "pressed", handler: gCallback(handler1)) {
42+
[weak self] (param0: Double, param1: Double) in
43+
guard let self = self else { return }
44+
self.pressed?(self, param0, param1)
45+
}
46+
47+
let handler2:
48+
@convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void =
49+
{ _, value1, data in
50+
SignalBox1<OpaquePointer>.run(data, value1)
51+
}
52+
53+
addSignal(name: "notify::delay-factor", handler: gCallback(handler2)) {
54+
[weak self] (param0: OpaquePointer) in
55+
guard let self = self else { return }
56+
self.notifyDelayFactor?(self, param0)
57+
}
58+
}
59+
60+
/// Factor by which to modify the default timeout.
61+
@GObjectProperty(named: "delay-factor") public var delayFactor: Double
62+
63+
/// Emitted whenever a press moved too far, or was released
64+
/// before [[email protected]::pressed] happened.
65+
public var cancelled: ((GestureLongPress) -> Void)?
66+
67+
/// Emitted whenever a press goes unmoved/unreleased longer than
68+
/// what the GTK defaults tell.
69+
public var pressed: ((GestureLongPress, Double, Double) -> Void)?
70+
71+
public var notifyDelayFactor: ((GestureLongPress, OpaquePointer) -> Void)?
72+
}

Sources/Gtk3Backend/Gtk3Backend.swift

+8-1
Original file line numberDiff line numberDiff line change
@@ -934,7 +934,10 @@ public final class Gtk3Backend: AppBackend {
934934
gtk_native_dialog_show(chooser.gobjectPointer.cast())
935935
}
936936

937-
public func createTapGestureTarget(wrapping child: Widget) -> Widget {
937+
public func createTapGestureTarget(wrapping child: Widget, gesture: TapGesture) -> Widget {
938+
if gesture != .primary {
939+
fatalError("Unsupported gesture type \(gesture)")
940+
}
938941
let eventBox = Gtk3.EventBox()
939942
eventBox.setChild(to: child)
940943
eventBox.aboveChild = true
@@ -943,8 +946,12 @@ public final class Gtk3Backend: AppBackend {
943946

944947
public func updateTapGestureTarget(
945948
_ tapGestureTarget: Widget,
949+
gesture: TapGesture,
946950
action: @escaping () -> Void
947951
) {
952+
if gesture != .primary {
953+
fatalError("Unsupported gesture type \(gesture)")
954+
}
948955
tapGestureTarget.onButtonPress = { _, buttonEvent in
949956
let eventType = buttonEvent.type
950957
guard

Sources/GtkBackend/GtkBackend.swift

+46-9
Original file line numberDiff line numberDiff line change
@@ -954,22 +954,59 @@ public final class GtkBackend: AppBackend {
954954
gtk_native_dialog_show(chooser.gobjectPointer.cast())
955955
}
956956

957-
public func createTapGestureTarget(wrapping child: Widget) -> Widget {
958-
let gesture = Gtk.GestureClick()
959-
child.addEventController(gesture)
957+
public func createTapGestureTarget(wrapping child: Widget, gesture: TapGesture) -> Widget {
958+
var gtkGesture: GestureSingle
959+
switch gesture.kind {
960+
case .primary:
961+
gtkGesture = GestureClick()
962+
case .secondary:
963+
gtkGesture = GestureClick()
964+
gtk_gesture_single_set_button(gtkGesture.opaquePointer, guint(GDK_BUTTON_SECONDARY))
965+
case .longPress:
966+
gtkGesture = GestureLongPress()
967+
}
968+
child.addEventController(gtkGesture)
960969
return child
961970
}
962971

963972
public func updateTapGestureTarget(
964973
_ tapGestureTarget: Widget,
974+
gesture: TapGesture,
965975
action: @escaping () -> Void
966976
) {
967-
let gesture = tapGestureTarget.eventControllers[0] as! Gtk.GestureClick
968-
gesture.pressed = { _, nPress, _, _ in
969-
guard nPress == 1 else {
970-
return
971-
}
972-
action()
977+
switch gesture.kind {
978+
case .primary:
979+
let gesture =
980+
tapGestureTarget.eventControllers.first {
981+
$0 is GestureClick
982+
&& gtk_gesture_single_get_button($0.opaquePointer) == GDK_BUTTON_PRIMARY
983+
} as! GestureClick
984+
gesture.pressed = { _, nPress, _, _ in
985+
guard nPress == 1 else {
986+
return
987+
}
988+
action()
989+
}
990+
case .secondary:
991+
let gesture =
992+
tapGestureTarget.eventControllers.first {
993+
$0 is GestureClick
994+
&& gtk_gesture_single_get_button($0.opaquePointer)
995+
== GDK_BUTTON_SECONDARY
996+
} as! GestureClick
997+
gesture.pressed = { _, nPress, _, _ in
998+
guard nPress == 1 else {
999+
return
1000+
}
1001+
action()
1002+
}
1003+
case .longPress:
1004+
let gesture =
1005+
tapGestureTarget.eventControllers.lazy.compactMap { $0 as? GestureLongPress }
1006+
.first!
1007+
gesture.pressed = { _, _, _ in
1008+
action()
1009+
}
9731010
}
9741011
}
9751012

Sources/GtkCodeGen/GtkCodeGen.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ struct GtkCodeGen {
8686
let allowListedClasses = [
8787
"Button", "Entry", "Label", "TextView", "Range", "Scale", "Image", "Switch", "Spinner",
8888
"ProgressBar", "FileChooserNative", "NativeDialog", "GestureClick", "GestureSingle",
89-
"Gesture", "EventController",
89+
"Gesture", "EventController", "GestureLongPress",
9090
]
9191
let gtk3AllowListedClasses = ["MenuShell", "EventBox"]
9292
let gtk4AllowListedClasses = ["Picture", "DropDown", "Popover"]

Sources/SwiftCrossUI/Backend/AppBackend.swift

+4-2
Original file line numberDiff line numberDiff line change
@@ -508,11 +508,12 @@ public protocol AppBackend {
508508
/// Wraps a view in a container that can receive tap gestures. Some
509509
/// backends may not have to wrap the child, in which case they may
510510
/// just return the child as is.
511-
func createTapGestureTarget(wrapping child: Widget) -> Widget
511+
func createTapGestureTarget(wrapping child: Widget, gesture: TapGesture) -> Widget
512512
/// Update the tap gesture target with a new action. Replaces the old
513513
/// action.
514514
func updateTapGestureTarget(
515515
_ tapGestureTarget: Widget,
516+
gesture: TapGesture,
516517
action: @escaping () -> Void
517518
)
518519
}
@@ -813,11 +814,12 @@ extension AppBackend {
813814
todo()
814815
}
815816

816-
public func createTapGestureTarget(wrapping child: Widget) -> Widget {
817+
public func createTapGestureTarget(wrapping child: Widget, gesture: TapGesture) -> Widget {
817818
todo()
818819
}
819820
public func updateTapGestureTarget(
820821
_ clickTarget: Widget,
822+
gesture: TapGesture,
821823
action: @escaping () -> Void
822824
) {
823825
todo()

Sources/SwiftCrossUI/Modifiers/OnTapGestureModifier.swift

+28-6
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,35 @@
1+
public struct TapGesture: Sendable, Hashable {
2+
package var kind: TapGestureKind
3+
4+
/// The idiomatic "primary" interaction for the device, such as a left-click with the mouse
5+
/// or normal tap on a touch screen.
6+
public static let primary = TapGesture(kind: .primary)
7+
/// The idiomatic "secondary" interaction for the device, such as a right-click with the
8+
/// mouse or long press on a touch screen.
9+
public static let secondary = TapGesture(kind: .secondary)
10+
/// A long press of the same interaction type as ``primary``. May be equivalent to
11+
/// ``secondary`` on some backends, particularly on mobile devices.
12+
public static let longPress = TapGesture(kind: .longPress)
13+
14+
package enum TapGestureKind {
15+
case primary, secondary, longPress
16+
}
17+
}
18+
119
extension View {
220
/// Adds an action to perform when the user taps or clicks this view.
321
///
4-
/// Any tappable elements within the view will no longer be tappable.
5-
public func onTapGesture(perform action: @escaping () -> Void) -> some View {
6-
OnTapGestureModifier(body: TupleView1(self), action: action)
22+
/// Any tappable elements within the view will no longer be tappable with the same gesture
23+
/// type.
24+
public func onTapGesture(gesture: TapGesture = .primary, perform action: @escaping () -> Void)
25+
-> some View
26+
{
27+
OnTapGestureModifier(body: TupleView1(self), gesture: gesture, action: action)
728
}
829

930
/// Adds an action to run when this view is clicked. Any clickable elements
1031
/// within the view will no longer be clickable.
11-
@available(*, deprecated, renamed: "onTapGesture(perform:)")
32+
@available(*, deprecated, renamed: "onTapGesture(gesture:perform:)")
1233
public func onClick(perform action: @escaping () -> Void) -> some View {
1334
onTapGesture(perform: action)
1435
}
@@ -18,6 +39,7 @@ struct OnTapGestureModifier<Content: View>: TypeSafeView {
1839
typealias Children = TupleView1<Content>.Children
1940

2041
var body: TupleView1<Content>
42+
var gesture: TapGesture
2143
var action: () -> Void
2244

2345
func children<Backend: AppBackend>(
@@ -36,7 +58,7 @@ struct OnTapGestureModifier<Content: View>: TypeSafeView {
3658
_ children: Children,
3759
backend: Backend
3860
) -> Backend.Widget {
39-
backend.createTapGestureTarget(wrapping: children.child0.widget.into())
61+
backend.createTapGestureTarget(wrapping: children.child0.widget.into(), gesture: gesture)
4062
}
4163

4264
func update<Backend: AppBackend>(
@@ -55,7 +77,7 @@ struct OnTapGestureModifier<Content: View>: TypeSafeView {
5577
)
5678
if !dryRun {
5779
backend.setSize(of: widget, to: childResult.size.size)
58-
backend.updateTapGestureTarget(widget, action: action)
80+
backend.updateTapGestureTarget(widget, gesture: gesture, action: action)
5981
}
6082
return childResult
6183
}

0 commit comments

Comments
 (0)