Highlight
In macOS 27, Apple brings gesture recognizers to AppKit as a unified input model, adds control events as an alternative to
mouseDowntracking loops, simplifies state restoration withNSWindowRestoration, and introducesNSViewCornerConfigurationfor concentric corners, aligning AppKit apps with SwiftUI and UIKit in interaction, state management, and visual style.
Core Ideas
AppKit is the oldest UI framework on macOS, and many of its core APIs date back to the NeXT era. If you maintain an older Mac app, you have probably seen code that overrides mouseDown:, implements its own tracking loop, subclasses NSButton just to detect whether the pointer left the button area, or forces users to find the same image again after closing and reopening a window.
That code made sense at the time, but now it is a burden. SwiftUI and UIKit use gesture recognizers for interaction and declarative state for interfaces, while AppKit often still uses patterns from 30 years ago. The more practical issue appears when you embed a SwiftUI view inside an AppKit window: the two event models can fight each other. Mouse events get swallowed by AppKit tracking loops, and SwiftUI gesture recognizers never receive them.
Apple is not replacing AppKit this year. Instead, it does three things: brings over patterns that SwiftUI and UIKit have already proven, lets existing code migrate gradually, and preserves AppKit’s flexibility.
First: a unified input model. Gesture Recognizers, borrowed from iOS, are now first-class citizens in AppKit. You no longer need to override mouseDown:, maintain drag state, or handle menu popups for a simple click. NSClickGestureRecognizer and NSPanGestureRecognizer can be attached directly to an NSView, and they share the same underlying model as SwiftUI’s onTapGesture. This means AppKit and SwiftUI no longer intercept events from each other as easily when mixed.
Second: control events. The UIKit-style addTarget(_:action:for:) comes to AppKit. Previously, detecting that the user pressed a button and then released outside its bounds required subclassing NSButton, overriding three mouse methods, and maintaining mouseDownInside state yourself. Now it is one line: .trackingEndedOutside, with no subclass required. More importantly, the API signature matches UIKit exactly, so iOS developers moving to macOS have almost no extra learning cost.
Third: state restoration. macOS users expect apps to remember their state: windows in the same positions after a restart, sidebars still expanded, and the image they were viewing still open. This used to require a lot of boilerplate, so many developers skipped it. macOS 27 simplifies the NSWindowRestoration flow into three steps: give the window an identifier, encode the essential state before termination, and decode it at launch. When the system restarts overnight for an update, users do not lose their context.
Together, these changes help AppKit apps stay competitive in the modern macOS ecosystem. You can still draw custom content with NSView and build complex animations with CALayer, but interaction, state management, and visual style finally line up with the rest of the system.
Details
From mouseDown to a modern input model
Many AppKit developers still use mouseDown: and tracking loops to handle clicks, drags, and selection. That pattern has existed since the beginning of the Mac, but it has a fundamental problem: it is not compatible with SwiftUI and UIKit Gesture Recognizers.
On the Mac, AppKit, SwiftUI, and Mac Catalyst (UIKit) can all run together. The three frameworks need a shared language for event handling. Gesture recognizers are that bridge.
(01:27) The presenter notes that if you are still overriding mouseDown: to track selection, you should switch to the selected property on NSCollectionViewItem and NSTableRowView, or observe selection change callbacks from NSTableViewDelegate and NSOutlineViewDelegate.
Right-click menus also have built-in options. NSView.defaultMenu gives every instance a shared menu, NSResponder.menu assigns a menu to each responder, and NSView.menuForEvent creates a menu dynamically from the event. These cover all three scenarios without manually calculating coordinates.
(03:35) Drag and drop follows the same pattern. Return an NSPasteboardItem from tableView(_:pasteboardWriterForRow:), and the system handles the rest. NSCollectionView, NSOutlineView, and NSBrowser all provide corresponding modern drag delegate methods.
func tableView(_ tableView: NSTableView,
pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setString(..., forType: .string)
return pasteboardItem
}
Control events come to AppKit
UIKit developers already know control events well. macOS 27 brings them to AppKit.
(04:55) Previously, detecting that a user pressed a button and then moved the mouse outside the button area required subclassing NSButton, overriding mouseDown:, mouseDragged:, and mouseUp:, then maintaining state manually. Now you can add a .trackingEndedOutside control event directly to the button without subclassing.
let button = NSButton()
button.addTarget(
self,
action: #selector(trackingEndedOutsideHandler),
for: .trackingEndedOutside
)
Key points:
addTarget(_:action:for:)matches the UIKit API signature, so the learning cost for cross-framework migration is low.trackingEndedOutsidemeans the user pressed the mouse on the button but released it outside the button area- You do not need to subclass
NSButton; register the target and action directly on the instance - Most control events have been available since macOS 10.11
Gesture recognizers and event pass-through
Gesture recognizers run on the view hierarchy. If a sibling view covers a button, it can silently consume mouse events.
(05:44) If an overlay is purely visual and does not need to receive events, override hitTest(_:) and return nil, allowing events to pass through to the content view underneath.
override func hitTest(_ point: NSPoint) -> NSView? {
return nil
}
Key points:
hitTest(_:)decides which view receives mouse events- Returning
nilmeans “there is no view here that should handle this event” - The event continues down to the covered view
- The overlay itself cannot receive any mouse events
Keyboard navigation and status item expanded interfaces
Many Mac users rely heavily on the keyboard. The focus order that Tab follows between controls is called the key view loop.
(06:24) Set autorecalculatesKeyViewLoop = true on a window so the system automatically recomputes focus order whenever the view hierarchy changes. Without it, you have to maintain the entire loop manually.
window.autorecalculatesKeyViewLoop = true
Status bar icons, or status items, can also participate in keyboard navigation. If a status item opens a custom window, you need to tell AppKit about the lifecycle of that UI, otherwise keyboard focus can jump unpredictably.
(07:37) Set expandedInterfaceDelegate and implement three methods: show the window when the session begins, hide it when the session ends, and cancel the session after the user chooses an action.
@main class LightAppDelegate: NSObject, NSApplicationDelegate {
lazy var lightStatusItem: NSStatusItem = { ... }()
func applicationDidFinishLaunching(_ notification: Notification) {
lightStatusItem.expandedInterfaceDelegate = self
}
}
extension LightAppDelegate: NSStatusItemExpandedInterfaceDelegate {
func statusItem(_ statusItem: NSStatusItem, didBegin session:
NSStatusItemExpandedInterfaceSession) {
// Show the window
}
func statusItemDidEndExpandedInterfaceSession(
_ statusItem: NSStatusItem, animated: Bool) {
// Hide the window
}
func selectedAction() {
// Perform the selected action
lightStatusItem.expandedInterfaceSession?.cancel()
}
}
Key points:
- Set
expandedInterfaceDelegatewhen creating the status item - Show the custom window in
didBegin, and remove it indidEnd cancel()asks the system to close the expanded interface session- If focus naturally moves elsewhere, the system cancels the session automatically
Graceful termination and state restoration
Users should be able to quit an app at any time. When the system restarts overnight for an update, the app should not block shutdown.
(09:45) When an app presents a sheet, the window may be unable to close, which can prevent the app from quitting. preventsApplicationTerminationWhenModal defaults to true to prevent data loss. For sheets that do not require user intervention, set it to false.
window.preventsApplicationTerminationWhenModal = false
State restoration has three steps: enable restoration, encode state, and decode state.
(10:18) First, set a window identifier and autosave name, enable isRestorable, and specify a restorationClass.
@MainActor class MainWindowController: NSWindowController, NSWindowDelegate {
convenience init() {
let window = NSWindow( ... )
window.identifier = NSUserInterfaceItemIdentifier(WindowIdentifiers.mainWindow)
window.setFrameAutosaveName(WindowIdentifiers.mainWindow)
window.isRestorable = true
window.restorationClass = WindowRestorationHandler.self
}
}
Key points:
identifieruniquely identifies the window and is used for matching during restorationsetFrameAutosaveNamelets the window remember its position and size on screen- Document windows do not need an autosave name
restorationClassspecifies the class responsible for restoring the window
(11:04) Second, save the minimum information needed to reconstruct the UI in encodeRestorableState. Do not save document data or database contents; save only UI state.
override func encodeRestorableState(with coder: NSCoder) {
super.encodeRestorableState(with: coder)
coder.encode(selectedProduct?.identifier.uuid,
forKey: RestorationKeys.productIdentifier)
}
(11:50) When the view hierarchy changes, call invalidateRestorableState() to tell the system that the state is stale and should be encoded again before the next termination.
splitViewController.onProductSelected = { [weak self] product in
self?.invalidateRestorableState()
}
(12:26) Third, restore the window in WindowRestorationHandler.
class WindowRestorationHandler: NSObject, NSWindowRestoration {
static func restoreWindow(
withIdentifier identifier: NSUserInterfaceItemIdentifier,
state: NSCoder,
completionHandler: @escaping (NSWindow?, Error?) -> Void
) {
if identifier == .mainWindow, let window = appDelegate.mainWindowController?.window {
completionHandler(window, nil)
} else if identifier == .imageWindow {
let controller = ImageWindowController()
appDelegate.imageWindowControllers.append(controller)
completionHandler(controller.window, nil)
} else {
completionHandler(nil, error)
}
}
}
Key points:
- This method is called once for every window that needs restoration
- You must call
completionHandler; AppKit waits for all windows to finish restoring - The main window usually already exists, so pass its
windowdirectly - Other windows need a new controller, then pass that controller’s
window - If restoration fails, still call
completionHandlerand pass an error
(13:29) Finally, decode state in the window controller’s restoreState and restore the UI.
override func restoreState(with coder: NSCoder) {
super.restoreState(with: coder)
if let productId = coder.decodeObject(
of: [NSString.self],
forKey: RestorationKeys.productIdentifier) as? String {
splitViewController?.selectedProductId = productId
}
}
Key points:
codercontains every key-value pair previously written byencodeRestorableState- After decoding the identifier, hand it to the corresponding view controller
- Read the data itself from the database or file system; do not encode it into restoration state
Visual updates: Liquid Glass and concentric corners
The Liquid Glass material introduced in macOS 26 continues to evolve in macOS 27. Many updates happen automatically: sidebars extend to the window edge, selected items use semibold text, and content still flows behind the material.
(15:06) macOS 27 adds an interaction effect: glass subtly bounces when clicked, giving the user feedback. This effect is meant for controls and buttons; do not overuse it.
(16:11) Concentricity is a new macOS 27 API. When a child view is near the corner of a parent container, its corner radius automatically matches the parent container’s curve.
class LocalWeatherView: NSView {
override var cornerConfiguration: NSViewCornerConfiguration? {
let radius: NSViewCornerRadius = .containerConcentric(minimumCornerRadius)
return .uniformCorners(radius: radius)
}
}
Key points:
cornerConfigurationis an overridable property onNSView.containerConcentricautomatically calculates the radius from the container viewminimumCornerRadiusensures a minimum radius even when the view is far from a corner.uniformCornersapplies the same radius to all four corners- The system automatically computes the distance between the view and the container corner; the closer it is, the more closely the curves match
Key Takeaways
1. Clean up mouseDown across old apps
Search globally for mouseDown:, mouseDragged:, and mouseUp:. If the code is only handling clicks, drags, or selection, replace it with gesture recognizers or control events. This reduces code and prevents embedded SwiftUI views from fighting the AppKit event path.
Entry points: NSClickGestureRecognizer, NSPanGestureRecognizer, NSControl.addTarget(_:action:for:)
2. Add state restoration to every window
After users restart their Mac, the app should return to how it looked before quitting. Add an NSWindowRestoration implementation for the main window, preferences window, and image viewer window. Store only UI state, such as the selected item or scroll position, not document data.
Entry points: window.isRestorable = true, encodeRestorableState(with:), restoreWindow(withIdentifier:state:completionHandler:)
3. Use concentric corners to fix visual mismatches
Inspect every custom view with rounded corners. If it sits near the corner of another rounded container, replace manually calculated cornerRadius values with NSViewCornerConfiguration. Maps’ local weather view is the typical example.
Entry point: override the cornerConfiguration property and return .uniformCorners(radius: .containerConcentric(minimum))
4. Make status bar icons support keyboard navigation
If your app has a menu bar icon, check whether it can receive focus with Tab when Keyboard Navigation is enabled. If clicking it opens a custom window, implement NSStatusItemExpandedInterfaceDelegate so keyboard focus enters and exits correctly.
Entry point: statusItem.expandedInterfaceDelegate = self
5. Add native text selection to non-text views
The new NSTextSelectionManager in macOS 27 can bring classic macOS bidirectional selection, dragging, and switching behavior to any custom view. If your app has a custom text rendering view, you no longer need to implement the entire selection system yourself.
Entry point: NSTextSelectionManager, attached to the view with a text selection data source
Related Sessions
- SwiftUI + AppKit interoperability - Embed SwiftUI views in AppKit windows and understand how the event models become unified
- UIKit modernization - Modernization work for UIKit; many of the concepts map directly to AppKit
- Xcode 27 - New Xcode improvements for the development experience of AppKit projects
- SwiftUI - The design ideas behind declarative frameworks, useful for understanding why AppKit is moving toward gesture recognizers
- Swift - New Swift language features and how many APIs are used in Swift 6 mode
Comments
GitHub Issues · utterances