Adopting gesture recognizers in your app
Replace mouse-event overrides and tracking loops with gesture recognizers to support both existing mouse behavior and touch input on a Mac with a touch display.
Overview
Gesture recognizers are a shared input model across AppKit, SwiftUI, and Mac Catalyst, so adopting them in your AppKit code makes your event handling behave consistently regardless of which framework the input passes through. This applies whether someone interacts with your app using a mouse, a Sidecar-connected iPad, or a Mac with a touch display.
A gesture recognizer interprets a sequence of low-level events — mouse clicks, touches, or trackpad gestures — as a single, well-defined interaction, such as a click, a press-and-hold, a pan, a pinch, or a rotation, so you don’t have to track events yourself. Attach a gesture recognizer to a view, and it calls your action method when it recognizes that interaction. AppKit provides a standard recognizer for each of these interactions: NSClickGestureRecognizer, NSPressGestureRecognizer, NSPanGestureRecognizer, NSMagnificationGestureRecognizer, and NSRotationGestureRecognizer. For more information, see Handling AppKit gestures.
If your app relies on tracking loops or overrides like mouseDown(with:), mouseDragged(with:), and mouseUp(with:) for mouse-event handling, migrate to gesture recognizers to support touch input. These existing tracking loops and overrides can still run on a Mac with a touch display or a Sidecar-connected iPad, because AppKit emulates mouse events from a single finger tapping or dragging. Relying on this emulation, however, means you miss out on multi-finger gestures, like pinch and rotate, that gesture recognizers handle.
Prepare your app for a Mac with a touch display
On a Mac with a touch display, a mouse and trackpad work the same way they do on any other Mac, so the main task is making sure your app also handles touch input. Each approach to handling input behaves differently with touch, as shown below:
Input handling | Behavior with touch | Next step |
|---|---|---|
Standard AppKit controls and gesture recognizers | Works with touch, with no changes needed. | Test your app, as described below. |
Mousedown(with:) overrides and tracking loops | AppKit emulates a single-finger tap or drag, but supports multi-finger gestures and individual touches only through gesture recognizers. | Replace them with gesture recognizers. For more information, see Adopting Gesture Recognizers In Your App and Adopting Gesture Recognizers In Your App. |
Subclasses of Nsbutton, Nsslider, and other AppKit views that override mouse-handling methods | AppKit falls back to a tracking loop. | Replace the overrides with target-action pairs for tracking events. For more information, see Adopting Gesture Recognizers In Your App. |
Custom Nsgesturerecognizer subclasses | A recognizer that never reaches a terminal state keeps its exclusive view, which can make your app appear unresponsive. | Handle cancellation in the recognizer, and make sure it reaches a terminal state. For more information, see Adopting Gesture Recognizers In Your App. |
To test your app, use touch in every window, sheet, and popover, and confirm that:
Controls respond to a tap, and buttons highlight and activate as expected.
Dragging a view, selecting text, and scrolling work with one finger.
Pinch and rotate work where your app supports them.
Small targets, such as resize grips and grab handles, are easy to hit with a finger.
To find code that needs attention, search your project for nextEvent(matching:until:inMode:dequeue:) and trackEvents(matching:timeout:mode:handler:), because tracking loops typically call these methods. Also search for mouseDown(with:) and mouseDragged(with:) overrides, and for uses of currentEvent in control action methods.
Finally, confirm that your app stays responsive after a touch interaction ends, and that every gesture recognizer action method handles the NSGestureRecognizer.State.cancelled state. For more information, see Diagnose unresponsive gesture recognizers.
Filter gesture recognizer candidates
Mouse events travel through the responder chain, where each view or controller gets a chance to handle an event before AppKit passes it to the next one. Gesture recognizers don’t use the responder chain. Instead, AppKit gathers gesture recognizers differently, which affects how you migrate existing responder-based code. For information about gesture recognizer gathering, see Handling AppKit gestures.
When AppKit gathers gesture recognizers at mouse-down or touch-began, not every recognizer it finds is actually applicable to the interaction — only your app knows that. Gesture recognizers handle this themselves in certain situations, but in many cases you need to remove inapplicable ones from the gathered set yourself. Use gestureRecognizer(_:shouldAttemptToRecognizeWith:) to inspect the initiating NSEvent for pointer-based input, such as mouse clicks and trackpad gestures (pan, magnify, and rotate), and gestureRecognizer(_:shouldReceive:) to inspect each new NSTouch for touch input. Either method gives you the initial input, something the recognizer doesn’t otherwise expose, and lets you stop the recognizer from handling that event stream or touch.
For example, if your view has a resize grip — a small square in the corner that someone drags to resize it — you can attach the resize gesture recognizer to the whole view instead of creating a separate, tiny view just for the grip. But because the recognizer now covers the entire view, not just the grip, it tries to respond to touches anywhere in it unless you filter it using those delegate methods. In the example below, each delegate method filters by location — it checks whether the touch or click happened inside the grip’s frame (resizeGripFrame, in the view’s coordinate space), and returns true only to give the gesture recognizer a chance to attempt recognition, not to guarantee it succeeds:
func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer,
shouldAttemptToRecognizeWith event: NSEvent) -> Bool {
guard let view = gestureRecognizer.view else { return false }
return resizeGripFrame.contains(view.convert(event.locationInWindow, from: nil))
}
func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer,
shouldReceive touch: NSTouch) -> Bool {
guard let view = gestureRecognizer.view else { return false }
return resizeGripFrame.contains(touch.location(in: view))
}If a custom control expects a touch to land inside its visible area, update it to handle touches that land outside that area. For example, a finger aiming for a small resize grip might land just outside the grip’s visible frame.
You can also decline to attempt a gesture based on where it happens. For example, if a timeline view displays many clips, you don’t need to install a gesture recognizer on every clip. Install one on the timeline instead, and use gestureRecognizer(_:shouldAttemptToRecognizeWith:) to hit-test the clip under the event. When there’s a clip, store it so your action method knows which clip the person interacted with. When there isn’t, return false, and the gesture recognizer is done for that event stream. In the example below, clip(at:) is your own helper that returns the clip at a point in the timeline’s coordinate space:
func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer,
shouldAttemptToRecognizeWith event: NSEvent) -> Bool {
guard let view = gestureRecognizer.view else { return false }
activeClip = clip(at: view.convert(event.locationInWindow, from: nil))
return activeClip != nil
}Direct touches don’t call this method. To filter them, implement gestureRecognizer(_:shouldReceive:) instead, as shown above.
Once you’ve filtered which recognizers can attempt to recognize, you can also prioritize between the ones that remain by making one recognizer wait for another to fail. Use this sparingly, because the waiting recognizer responds more slowly. For example, making a single click wait for a double click to fail makes clicks feel sluggish. For more information, see Prioritize gesture recognizers.
Handle touch input
AppKit emulates mouse events from a single finger tapping or dragging, but it doesn’t deliver direct touches (NSTouch) through the responder chain or through event monitors. Gesture recognizers are the only way to respond to multi-finger gestures or to receive individual touches from a Mac with a touch display or a Sidecar-connected iPad. For more on how gesture recognizers give you one consistent model across these different kinds of input, see Handling AppKit gestures.
In most cases, instead of checking whether a device supports touch, write your event-handling code to work the same way regardless of input source, and decide on alternate presentations based on the interaction type. For example, you might make small drag targets — like resize or grab handles — easier to hit with a finger by expanding their touchable area beyond what’s visible, without changing how they respond to a mouse.
If you do need to determine whether a screen has touch capability, for example to offer a touch-specific option such as capturing a signature on a touch display, check touchCapabilities:
if let screen = NSScreen.main, screen.touchCapabilities.contains(.multiTouch) {
// Offer a touch-specific affordance.
}AppKit doesn’t translate touch swipes for you. If your app currently uses trackSwipeEvent(options:dampenAmountThresholdMin:max:usingHandler:), implement your own swipe handling. For full-page navigation swipes, such as going back and forward, check isTouchSwipeNavigationEnabled first, because the person can turn off swipe navigation in System Settings.
Update your event-handling code
Because AppKit routes NSControl interactions through gesture recognizers instead of the responder chain, the timing of when it delivers control action messages relative to event processing also changes, so currentEvent no longer reliably returns the event that initiated an action. If you were using it to check modifier keys or pressed mouse buttons, use modifierFlags and pressedMouseButtons instead. These report the state at the time you query them, not at the time of the original event. A touch doesn’t register in pressedMouseButtons:
Keyboard events and scroll-wheel events behave the same on every input source: AppKit delivers them through the responder chain. Hover tracking, which relies on mouseMoved(with:) and NSTrackingArea, applies to pointer input only, because a touch display has no hover.
Replace common mouse-down overrides
Most mouseDown(with:) overrides do one of two things: mark an item as selected when someone clicks it, or show a menu when someone Control-clicks.
For the first case, marking an item as selected in a table view or collection view, you don’t need to handle the click yourself. AppKit already updates the item’s isSelected property when someone selects it, so observe that property instead. Use isSelected for collection view items, or isSelected for table rows. Because this observer also runs when your code changes the selection programmatically, make sure the work in didSet is safe in both cases.
If you need to allow or prevent a selection before it happens, implement tableView(_:shouldSelectRow:) or collectionView(_:shouldSelectItemsAt:) instead.
For the second case, showing a menu on Control-click, let AppKit present it instead of presenting it from a mouseDown(with:) override. Override menu(for:) to return a menu based on where someone clicked, as shown below:
If the menu doesn’t depend on the click, set menu to give an individual view its own menu, or override defaultMenu to give every instance of the view the same menu.
Handle tracking-loop compatibility
With tracking loops, after you start interacting with a control (like dragging a slider), nothing else in your app can respond to input until that interaction is finished. AppKit still falls back to this same behavior in certain situations, like when you override a control’s own mouse-handling methods.
For example, if you subclass NSButton to add a highlight animation when someone presses it, and you override mouseDown(with:) to start that animation, AppKit treats your override as a replacement for its own click handling, rather than as a side effect, and applies that same fallback to the button instead of dispatching through gesture recognizers.
AppKit decides based on whether your subclass overrides the method, not on what the override does, so the fallback still applies if your override calls super.mouseDown(with:). Overriding mouseDragged(with:) or mouseUp(with:) has the same effect.
In macOS 27 and later, instead of overriding a mouse-handling method to add a side effect like this, add a target and action for the matching tracking events in NSControl.Events, as shown below. The action methods must be exposed to Objective-C with @objc:
// Highlight the button while someone presses it.
openButton.addTarget(self, action: #selector(showHighlight), for: [.trackingBegan, .trackingEntered])
// Remove the highlight when the press ends, leaves the button, or the system cancels it.
openButton.addTarget(self, action: #selector(hideHighlight), for: [.trackingExited, .trackingEndedInside, .trackingEndedOutside, .trackingCancelled])If you only need to know when the press results in an action — regardless of whether someone used a mouse, trackpad, or touch to produce it — add a target and action for primaryActionTriggered instead:
// Respond when someone activates the button, whether by clicking or tapping.
openButton.addTarget(self, action: #selector(openDocument), for: .primaryActionTriggered)Container views fall back to tracking-loop behavior in the same way when you override their mouse-handling methods. If you start a drag from mouseDragged(with:) in an NSTableView, NSCollectionView, NSOutlineView, or NSBrowser, AppKit falls back to a tracking loop for the interaction. Instead, implement the dragging methods in NSTableViewDataSource, NSCollectionViewDelegate, NSOutlineViewDataSource, or NSBrowserDelegate. For example, in a table view, implement tableView(_:pasteboardWriterForRow:) to create a pasteboard item, set its data, and return it.
Replace your custom tracking loops with gesture recognizers
If your code still relies on an event-tracking loop that retrieves mouse events directly until an interaction ends, replace it with gesture recognizers. These loops typically call nextEvent(matching:until:inMode:dequeue:) or trackEvents(matching:timeout:mode:handler:) with the .eventTracking run loop mode. For more information, see Replace event-tracking loops.
For example, if your app has a photo editing view where someone drags a caption around an image, use a pan gesture recognizer instead of a tracking loop, as shown below:
AppKit calls the action method each time the gesture’s state changes. If the system cancels the gesture, use the .cancelled state to undo any changes you’ve made.
If your tracking loop starts a drag-and-drop session, pass the gesture recognizer to beginDraggingSession(items:gesture:source:) instead of the event:
If you’d rather keep your tracking loop and only add touch support, see the next section.
Add touch support to your custom tracking loop
If you have an existing custom tracking loop and want to add touch support without rewriting it, add a touch-only gesture recognizer alongside it. Refactor your tracking loop’s logic into separate functions for each phase (start, continue, end, and cancel), then call those same functions from both the tracking loop and a gesture recognizer scoped to touch only:
As shown above, setting buttonMask to 0 means the gesture recognizer only recognizes touches, not mouse or trackpad input, so your tracking loop keeps handling the mouse and trackpad exactly as before. If you leave buttonMask at its default, the recognizer also responds to the primary mouse button. A mouse drag then begins the gesture recognizer, and AppKit doesn’t call your mouseDown(with:) override, so the tracking loop doesn’t run. A click that doesn’t move still reaches your tracking loop. Direct touches are already the default for allowedTouchTypes, and setting it to .direct states that intent explicitly. This is often the fastest, lowest-risk way to add touch support to code with a tracking loop you’d rather not rewrite all at once.
Write a custom gesture recognizer
AppKit provides a number of standard gesture recognizers, but if none of them provides the functionality you need, subclass one of them or create your own custom NSGestureRecognizer subclass. Prefer the built-in recognizers when possible.
When you implement a custom NSGestureRecognizer subclass, keep the following guidelines in mind:
Handle cancellation by implementing mouseCancelled(with:) and touchesCancelled(with:). To learn more, see Implement a custom gesture recognizer.
In your event-processing methods, such as mouseDown(with:) or touchesBegan(with:), only update your subclass’s own private tracking properties, and set state to NSGestureRecognizer.State.began, NSGestureRecognizer.State.changed, or NSGestureRecognizer.State.ended as appropriate.
Respond to the input from custom recognizers in your action method, not in the event-processing callback.
If you need the originating NSEvent or NSTouch objects, cache them during event processing and retrieve the cached objects in your action method.
Override reset() to clear your cached state, so it doesn’t leak from one gesture into the next.
Always end an active gesture recognizer by transitioning to NSGestureRecognizer.State.ended, NSGestureRecognizer.State.failed, or NSGestureRecognizer.State.cancelled. Otherwise, other recognizers can’t activate. For more information, see Diagnose unresponsive gesture recognizers.
Subclasses are responsible for overriding location(in:) and returning an appropriate value based on the type of gesture. If you skip this override and build your app with the macOS 27 SDK and later, AppKit returns NSZeroPoint and logs a console error.
The following example shows the structure of a custom drag-tracking recognizer that applies the guidelines above:
class DragTrackingRecognizer: NSGestureRecognizer {
// Cache what your action method needs, like the location (in window coordinates) and the originating event.
private(set) var trackedLocation: NSPoint = .zero
private(set) var lastEvent: NSEvent?
// Update the tracking properties and `state` in one place.
private func track(_ location: NSPoint, event: NSEvent, state newState: State) {
trackedLocation = location
lastEvent = event
state = newState
}
override func mouseDown(with event: NSEvent) {
// Call `track(_:event:state:)` with `event.locationInWindow` and `.began`.
}
override func mouseDragged(with event: NSEvent) {
// Call `track(_:event:state:)` with `event.locationInWindow` and `.changed`.
}
override func mouseUp(with event: NSEvent) {
// Call `track(_:event:state:)` with `event.locationInWindow` and `.ended`.
}
override func mouseCancelled(with event: NSEvent) {
// Set `state` to `.cancelled`.
}
// Implement `touchesBegan`, `touchesMoved`, `touchesEnded`, and `touchesCancelled` the same way, caching the relevant `NSTouch` objects instead of an `NSEvent`.
override func reset() {
super.reset()
trackedLocation = .zero
lastEvent = nil
}
override func location(in view: NSView?) -> NSPoint {
guard let view else { return trackedLocation }
return view.convert(trackedLocation, from: nil)
}
}Set it up like any other gesture recognizer, and read the cached state back in your action method instead of the event-processing methods:
let recognizer = DragTrackingRecognizer(target: self, action: #selector(handleDrag))
view.addGestureRecognizer(recognizer)
@objc func handleDrag(_ recognizer: DragTrackingRecognizer) {
let location = recognizer.location(in: view)
let extendsSelection = recognizer.lastEvent?.modifierFlags.contains(.shift) == true
switch recognizer.state {
case .began, .changed:
moveHandle(to: location, extendingSelection: extendsSelection)
case .ended:
commitHandleMove()
case .cancelled:
revertHandleMove()
default:
break
}
}For more information, see Handling AppKit gestures.
Account for gesture recognizer behavior changes
Gesture recognizers replace tracking loops, but AppKit preserves the same one-view-at-a-time behavior that tracking loops implicitly provide, and gives you a way to opt out when you’re ready.
Exclusive views are on by default: while one view’s gesture recognizers are handling an interaction, AppKit won’t gather gesture recognizers from a different part of your view hierarchy until that interaction ends. Xcode adds the NSViewGestureRecognizerIsExclusive key with a value of NO to new macOS app projects, so those apps don’t use exclusive views unless you change the value to YES. For the full mechanism, including how to opt out with exclusiveGestureBehavior or that property list key, see Configure exclusive views.
Two properties help your gesture recognizers coexist with a scroll view’s own gestures. Use scrollGestureForRelationships when your own recognizers compete with the scroll view’s, and use isCancellableByScrollGesture when a control’s recognizer needs to yield to scrolling.
As you migrate, watch for two scenarios. First, when someone touches down on a control (like a button) that sits inside a scroll view, and then drags to scroll or pinches to zoom the content instead of clicking the control, set isCancellableByScrollGesture to true on the control’s gesture recognizer to let AppKit cancel it once the scroll view’s pan or magnification gesture starts. This means scrolling or zooming takes precedence over the control’s own gesture, the same way it already takes precedence over unrelated exclusive views elsewhere in the hierarchy.
Second, in macOS 27.3 and later, even with exclusivity turned off, the system can still claim three or more simultaneous touches for a system-level Multi-Touch gesture before they reach your gesture recognizers. Set disablesDirectTouchSystemGestures to true to let your window’s own gesture recognizers claim those touches instead. Use this sparingly — only when the interaction it unlocks is critical to your app — because disabling system gestures removes a capability people may expect to work everywhere.
These settings don’t override each other. Each one controls a different part of how AppKit handles gestures:
exclusiveGestureBehavior and the NSViewGestureRecognizerIsExclusive key control whether a view’s gesture recognizers become the exclusive set, which affects which gesture recognizers AppKit gathers. The view’s setting applies to that view and its subviews, and takes precedence over the app-wide key. A view that isn’t exclusive never becomes the exclusive view, but an exclusive view that’s already active still blocks AppKit from gathering gesture recognizers outside its hierarchy until the interaction ends.
isCancellableByScrollGesture applies to an individual gesture recognizer and is independent of exclusivity. It lets a scroll gesture cancel that gesture recognizer even if it has already begun.
For example, a button in a non-exclusive container inside a scroll view still yields to scrolling if its gesture recognizer’s isCancellableByScrollGesture is true, because exclusivity doesn’t affect that behavior.
Beyond these behavior changes, NSScrollView and NSPanGestureRecognizer also provide several touch-related properties, including pull-to-refresh support and control over how many simultaneous touches scroll a view.
Custom text engines that adopt NSTextSelectionManager to support standard text-selection gestures run into the same kind of competition. The selection manager creates its gesture recognizers itself, so you don’t have a reference to them. To make your own gesture recognizer wait for them to fail, return true from gestureRecognizer(_:shouldRequireFailureOf:) when gesturesForFailureRequirements contains otherGestureRecognizer, as shown below:
func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer,
shouldRequireFailureOf otherGestureRecognizer: NSGestureRecognizer) -> Bool {
gestureRecognizer === myRecognizer
&& selectionManager.gesturesForFailureRequirements.contains(otherGestureRecognizer)
}For more information, see Prioritize gesture recognizers.
Support non-AppKit UI frameworks
If your app embeds a non-AppKit UI framework that doesn’t yet handle touch input directly, AppKit provides automatic mouse emulation to help bridge the gap:
Tapping emulates a mouse-down followed immediately by a mouse-up.
Touching and then immediately panning emulates a trackpad scroll, even if the UI doesn’t otherwise handle Multi-Touch input directly.
Touching, briefly holding, and then moving emulates mouseDown(with:), followed by mouseDragged(with:) as it moves, and mouseUp(with:) on lift.
Touching and holding without movement emulates rightMouseDown(with:), and rightMouseUp(with:) on lift.
Pinching or rotating with two fingers emulates the trackpad magnify and rotate gestures.
This emulation covers interactions with one or two fingers. If another finger touches down during a pinch or rotation, the emulation ignores it and keeps tracking the original two fingers. To respond to interactions that involve three or more fingers, use gesture recognizers.
Once the framework adds its own direct touch handling, set the NSIsTouchNative property list key to YES to disable the extra mouse emulation.