Cover: a laptop screen with a small control inside a dashed 36 point touch target, with the headline Touch just hit your Mac app


Apple's first Mac with a touchscreen reached customers on November 6. The MacBook Pro with PROLED XDR Display starts at $2,899 for the 14-inch M5 Pro model, went on pre-order in 30 countries and regions on October 28, and runs macOS 27. Somebody is going to reach out and poke your app, whether you planned for that or not.

The good news is that a lot of Mac software will work on day one. Apple's gesture recognizer guide says standard AppKit controls handle touch with no changes, and AppKit turns a single finger's tap or drag into mouse events for older code. The bad news sits in the exceptions: custom views, hover-only buttons, subclassed controls, canvases, and anything built on the assumption that a pointer moves in a continuous line.

This guide puts those exceptions in the order I would handle them, with the search commands and code to do it. It comes from Apple's documentation, technotes and tech talk listings. Nothing here was run on the new hardware, and Apple's written docs are still filling in, so treat the version notes as a snapshot.

What actually changed for developers

AppKit has been moving toward gesture recognizers for a while, and TN3212 makes the direction plain: in macOS 27 they are the primary mechanism for input handling. Touch forces the issue because direct touches never enter the path most Mac code listens to. The technote says AppKit does not deliver them through the responder chain or through event monitors, and local and global monitors do not receive touch events at all. They also skip NSApp and sendEvent(_:).

So there are now two ways for a finger to reach your code. A gesture recognizer gets the real touch. Everything else gets an imitation of a mouse, limited to what one finger can express. Multi-finger pinch and rotate, plus individual touches, only arrive through recognizers.

The design rules changed too, and they are probably not what an iOS developer expects. Apple's Gestures guidelines call touch on macOS secondary to the keyboard and pointing device, and they tell you to keep the layout and size of interface elements the same when you support touch. Designing for macOS explains why: people reach forward to the screen, so they use touch for brief, intentional actions and return to the keyboard. This is the point to hold on to if your first instinct is to fatten every toolbar. Apple wants the tap target to grow while the control stays the size it is.

Step 1: Find out how exposed you are

Apple gives you the search list in the adoption guide. Run it from the root of your project and count the hits per file. That count is your real estimate, and it takes a minute.

# Tracking loops
rg -n "nextEvent\(matching|trackEvents\(matching" --type swift

# Mouse overrides
rg -n "override func (mouseDown|mouseDragged|mouseUp)" --type swift

# Code that trusts the current event inside an action
rg -n "currentEvent" --type swift

# Things touch will not reach
rg -n "NSEvent\.addLocalMonitor|NSEvent\.addGlobalMonitor|override func sendEvent" --type swift

# Hover-only UI
rg -n "NSTrackingArea|mouseEntered|mouseMoved" --type swift

# Swipes AppKit will not translate
rg -n "trackSwipeEvent" --type swift

Objective-C projects need the same search with nextEventMatchingMask and mouseDown:. If an AI tool wrote most of your Mac app, run these anyway, because those are often the files nobody has read closely.

Sort the results into the table below. It follows Apple's own breakdown, with the priority column added.

What you foundWhat happens with touchPriority
Standard controls and existing gesture recognizersWorks with no changesTest only
mouseDown(with:) override on a plain viewOne finger emulated, no multi-finger gesturesMedium
Tracking loop on a canvas or editorOne finger emulated, no multi-touchMedium, high if drawing is your product
Subclass of NSButton, NSSlider or similar overriding mouse methodsAppKit falls back to a tracking loopHigh, the fix is small
Custom NSGestureRecognizer subclassMay never reach a terminal state, which can freeze interactionHigh
Actions that exist only on hoverUnreachable, a touch display has no hoverHigh
Event monitors used for shortcuts or dismissalNever see a touchCheck each one

Step 2: Test before you change anything

You do not need the new laptop. Apple's guide says to connect an iPad with Sidecar, and AppKit then routes touch on the iPad's screen the same way it does on a Mac with a touch display. TN3212 specifies an iPad running iPadOS 27.

There is one catch. Developer Steve Troughton-Smith reported in comments collected on Michael Tsai's blog that full touch behavior arrives in macOS 27.3, with many behaviors missing on 27.2 over Sidecar. So test on the macOS 27.3 beta if you can, and do not write off a bug or a missing gesture until you have seen it there. Apple released the first Xcode 27.3 beta on November 3, and MacRumors lists touchscreen MacBook Pro APIs among its additions.

Apple's test list is short and worth following literally. Use touch in every window, sheet and popover, then confirm these:

  • Controls respond to a tap, and buttons highlight and activate as expected.
  • Dragging a view, selecting text and scrolling all 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.
  • The app stays responsive after a touch ends, which is the check that catches stuck custom recognizers.

Keep a plain text list of failures as you go. A list of twelve real failures is a better plan than a rewrite of your input layer.

If you use Device Hub in Xcode to drive a simulator from a touch screen, the Xcode 27.3 beta notes list a known limit: only two touches reach the target device, because macOS treats three or more as a system gesture. The listed workaround is turning off touch gestures in Settings under Touch Display.

Step 3: Make the cheap fixes first

Stop subclassing controls to catch a press

This is the fix with the best return. Say you overrode mouseDown(with:) on an NSButton to run an animation, then called super. AppKit decides on whether your subclass overrides the method, ignoring what the override does, so calling super does not save you. The control falls back to a tracking loop. macOS 27 gives NSControl target-action tracking events instead. This is the pattern from Apple's Bring Touch to your Mac app talk and adoption guide, using a button named openButton:

openButton.addTarget(self, action: #selector(showHighlight),
                     for: [.trackingBegan, .trackingEntered])

openButton.addTarget(self, action: #selector(hideHighlight),
                     for: [.trackingExited, .trackingEndedInside,
                           .trackingEndedOutside, .trackingCancelled])

If all you need is to know the button fired, regardless of input, use the semantic event. The action methods must be exposed with @objc.

openButton.addTarget(self, action: #selector(openDocument),
                     for: .primaryActionTriggered)

Replace currentEvent checks

Control actions now arrive at a different moment relative to event processing, so NSApp.currentEvent no longer reliably returns the event that caused the action. TN3212 tells you to read the class properties on NSEvent instead. They report state at query time, and a touch is never part of pressedMouseButtons.

// In a control action
if NSEvent.modifierFlags.contains(.shift) {
    extendSelection()
}

Think about what a person does on this laptop: left hand on Shift, right finger on the screen. Apple's pointing device guidance asks for the same result either way, so an Option-drag that duplicates an object should behave the same by touch and by pointer.

Delete selection and menu overrides

Most mouseDown(with:) overrides in table and collection cells do one of two jobs. For selection, observe isSelected, and use tableView(_:shouldSelectRow:) when you need to allow or block a selection. For a right-click menu, override menu(for:) and let AppKit present it. Touch and hold opens a context menu on touch, so this one change should give you a working long press with no extra code.

override func menu(for event: NSEvent) -> NSMenu? {
    let location = convert(event.locationInWindow, from: nil)
    return itemView(at: location) != nil ? itemMenu : canvasMenu
}

One warning for tables and outlines. If you start a drag from mouseDragged(with:) in an NSTableView, NSCollectionView, NSOutlineView or NSBrowser, AppKit falls back to a tracking loop. Use the data source or delegate drag methods, such as tableView(_:pasteboardWriterForRow:).

Step 4: Add touch to custom views without a rewrite

Canvases, timelines and node editors are where the real work lives, and a full rewrite is rarely worth doing this month. Apple documents a lower-risk route. Split your tracking loop into four functions, one each for start, continue, end and cancel, and call them from both the existing loop and a new touch-only recognizer.

func setUpTouch() {
    let touch = NSPanGestureRecognizer(target: self,
                                       action: #selector(handleTouchPan))
    touch.buttonMask = 0            // touches only, no mouse
    touch.allowedTouchTypes = .direct
    addGestureRecognizer(touch)
}

@objc func handleTouchPan(_ r: NSPanGestureRecognizer) {
    switch r.state {
    case .began:     startTracking(at: r.location(in: self))
    case .changed:   continueTracking(at: r.location(in: self))
    case .ended:     endTracking(at: r.location(in: self))
    case .cancelled: cancelTracking()
    default:         break
    }
}

The buttonMask = 0 line matters more than it looks. Leave it at the default and the recognizer also answers the primary mouse button, so a mouse drag begins the gesture and your mouseDown(with:) override never runs. A click without movement still reaches the loop, which gives you a confusing half-broken mouse.

Handle .cancelled properly, and undo whatever the gesture changed. The system can cancel a touch, and a drag that leaves an object stranded halfway is a bug a mouse user never saw. If your tracking loop started a drag session, hand the recognizer to beginDraggingSession(items:gesture:source:) in place of the event.

Grow the target, keep the drawing

Apple's talk listing says to expand custom hit areas to a minimum of 36 points on each axis, and its sample leaves the actual math as a comment. Here is one way to fill it in. It keeps mouse hit testing exact, which matters on dense Mac interfaces.

func point(_ point: NSPoint, in rect: NSRect,
           expandForTouch: Bool = false) -> Bool {
    guard expandForTouch else { return NSPointInRect(point, rect) }
    let dx = max(0, (36 - rect.width) / 2)
    let dy = max(0, (36 - rect.height) / 2)
    return NSPointInRect(point, rect.insetBy(dx: -dx, dy: -dy))
}

Pass expandForTouch: true only from the touch path. When handles sit close together their expanded rectangles overlap, so check the handle nearest the touch first, instead of the first one in your array. Also make sure a touch that lands just outside the visible frame still works, because a finger aiming at a small grip often does.

Filter with the delegate methods

You can attach one recognizer to a whole timeline and filter there, which beats installing one on every clip. Pointer input goes through gestureRecognizer(_:shouldAttemptToRecognizeWith:), and direct touches go through gestureRecognizer(_:shouldReceive:). If you only implement the first, touches slip past your filter, and that bug would only show up on a touch display.

func gestureRecognizer(_ g: NSGestureRecognizer,
                       shouldReceive touch: NSTouch) -> Bool {
    guard let view = g.view else { return false }
    return resizeGripFrame.contains(touch.location(in: view))
}

Step 5: Rescue everything that hides behind hover

Hover is the classic Mac habit that touch breaks. The adoption guide says hover tracking applies to pointer input only, because a touch display has no hover. Row actions, tab close buttons and toolbar reveals that appear on mouseEntered simply do not exist for a touch user.

The Pointing devices guidelines now have a section on this, and the advice is more restrained than most people would guess:

  • Reserve touch support for essential controls. Tooltips and other static information do not need it.
  • Do not keep hidden controls visible all the time just to accommodate touch.
  • Reveal a hidden control when someone taps its general location, then hide it when they finish or after a few seconds.
  • Put essential hidden controls in a context menu too, so there is always a dependable path.
  • In a sidebar, reveal the controls for the tapped row and keep them until touch leaves the sidebar or a pointing device is used.
  • In a tabbed window, reveal the selected tab's close button when someone taps anywhere in the window.

A fair first pass is to list every NSTrackingArea hit from Step 1 and answer one question for each: can a touch user still do this? If the answer is no, the context menu is the fastest correct fix, and the tap-to-reveal can follow later.

Step 6: Check the traps that fail silently

These are the changes most likely to pass a code review and still misbehave.

  • Transparent overlays. Recognizers are gathered from the hit-tested top view upward, so a clear view on top of a control blocks it. Rearrange the siblings, or return nil from the overlay's hitTest(_:).
  • Custom recognizer subclasses. In apps built with the macOS 27 SDK, location(in:) returns NSZeroPoint and logs an error if your subclass does not override it. Implement it, along with touchesCancelled(with:) and reset().
  • Stuck gestures. A recognizer that never reaches ended, failed or cancelled keeps blocking the others, and the app looks frozen.
  • Swipe navigation. AppKit does not translate touch swipes, so trackSwipeEvent code needs its own handling, and you should check isTouchSwipeNavigationEnabled first because people can turn it off.
  • Touch detection. NSScreen.touchCapabilities reports true for every display when a touch-capable Sidecar display is connected. Apple's advice is to handle input the same way everywhere and to branch on the interaction type, keeping capability checks for a rare touch-specific option such as capturing a signature.
  • Scroll conflicts. If a button sits inside a scroll view, set isCancellableByScrollGesture to true on your recognizer so a drag can turn into a scroll.

Exclusivity deserves its own note. AppKit keeps the old one-interaction-at-a-time behavior by default, so a second finger on a different view does nothing while the first is active. That protects code that was written for a single mouse. To allow two sliders at once, set exclusiveGestureBehavior = .notExclusive on the container, or set NSViewGestureRecognizerIsExclusive in Info.plist for the whole app. Xcode adds that key with a value of NO to new macOS projects, so a new app and an old app can behave differently here.

In macOS 27.3 and later, the system can still claim three or more simultaneous touches for a system gesture even with exclusivity off. disablesDirectTouchSystemGestures on NSWindow hands them back to your window. Apple says to use it sparingly, since it removes something people expect to work everywhere, so keep it for cases such as a mixing console or a multi-finger instrument. The talk listing also shows prefersDirectTouchSystemGesturesDisabled for Mac Catalyst and directTouchSystemGesturesDisabled() for SwiftUI. One developer's SDK check did not find those names in the SDKs available at the time, so confirm them against the SDK you actually build with.

If your app is not plain AppKit

SwiftUI and Mac Catalyst apps start in a better place, since their standard controls are covered. In SwiftUI, TN3212 points to GestureInputKinds for limiting a gesture to particular input types. Your custom DragGesture canvases and hover-driven views still need the same test pass.

If you embed a cross-platform or custom UI framework, AppKit's emulation carries more of the load. A tap becomes mouse down and up, a touch followed by an immediate pan becomes a trackpad scroll, a touch and brief hold followed by movement becomes a mouse drag, a held touch becomes a right click, and a two-finger pinch or rotate becomes the trackpad gestures. Emulation covers one or two fingers. Once your framework handles touch directly, set NSIsTouchNative to YES so the extra emulation stops.

<key>NSIsTouchNative</key>
<true/>

Games have their own technote, TN3216, published the same day as the announcement. The trap here is pointer math. A player can tap two distant spots in a row, so a game that adds up movement deltas drifts away from the real touch location. Read the location from each event. For apps in a games category, AppKit sends a mouseMoved to the new location before each mouseDown so a cached position can refresh. Do not use the emulated GCMouse deltas to track an absolute position either. Apple's design advice for games is one-handed, brief interactions such as inventory, map zoom and settings, with the other hand staying on the keyboard, mouse or controller.

How much of this should you do now

Apple's pitch and developers' reactions do not line up, and it is worth being realistic. On the Michael Tsai thread, one commenter predicted a repeat of the Touch Bar, and others worried about denser apps getting worse. MacStories also reports that macOS is adding an Accessibility setting to turn touch off, so some owners may never use it.

My suggestion is a budget based on what your app does:

App typeReasonable effort now
Standard controls, little custom UIHalf a day on Sidecar, fix hover-only actions
Dense productivity appFix subclassed controls and hover, add context menus, leave the canvas for later
Drawing, music, video, node or design toolsTouch-only recognizers on the main canvas, since this is where touch can win users
GamesFix pointer drift, then add touch for menus and maps
Cross-platform shellTest emulation, track your framework's touch support

The Step 3 fixes are worth doing even if you never expect many touch users. They remove old tracking-loop paths that Apple has been steering away from, and they move your code onto the input model AppKit is standardizing on.

Apple is also running two online Touch on Mac Group Labs on November 18 from 9 to 10 a.m. PT and November 19 from 6 to 7 p.m. PT. Bring your failure list from Step 2. A concrete question about one stuck recognizer will get more out of an engineer than a general one.

Using an agent for this migration

This job suits a coding agent if you scope it tightly. Give it the search output from Step 1, ask for one file at a time, and require it to keep mouse behavior identical. Then test each change by hand, because no compiler will tell you that a touch path feels wrong, and recognizer bugs usually show up only as an app that stops responding.

If you do that work with Claude Code, Claude Code + Xcode: The Production iOS Workflow Kit is a paid kit from my store aimed at one specific problem: the tool not knowing your project's architecture and state ownership, so you stop re-explaining your codebase before every edit. Its focus is iOS, so read the product page and decide whether it suits your Mac project before buying.

Sources