For a Mac app with horizontal shelves inside a vertical list, give each gesture a clear destination. Start with SwiftUI’s axis configuration; if you need explicit routing, own the nested AppKit scroll views through a SwiftUI bridge.
The concrete task here is browsing synthetic cards: scrolling vertically over any shelf should move the whole list, while scrolling horizontally should move that shelf independently. This requires separate horizontal offsets for each row. It differs from a single canvas that moves horizontally and vertically together.
Choose the scrolling structure that matches the task
Apple documents that a SwiftUI ScrollView can scroll horizontally, vertically, or on both axes. For one large document, use ScrollView([.horizontal, .vertical]) and give its content dimensions that exceed the viewport. This is the simplest structure when all content shares one horizontal and one vertical position. ScrollView
Independent shelves require separate scroll containers. The natural SwiftUI structure is a vertical ScrollView containing horizontal ScrollView instances, each with a bounded height. However, configuring those axes does not itself specify how an event received over an inner container should reach the outer container. Apple’s ScrollView documentation describes supported axes without establishing a general event-routing contract for this nested case. ScrollView
| Required behavior | Appropriate starting point |
|---|---|
| One document moves in both directions | One two-axis SwiftUI ScrollView |
| Each shelf retains its own horizontal position | Nested horizontal shelves inside a vertical container |
| Vertical gestures over shelves need an explicit destination | App-owned NSScrollView subclasses exposed through NSViewRepresentable |
| A diagonal gesture must move the entire document freely | One two-axis container; AppKit also exposes usesPredominantAxisScrolling |
AppKit distinguishes content that predominantly scrolls along one axis at a time from content, such as a canvas, that should move freely along both axes. Its usesPredominantAxisScrolling property expresses that distinction; its documented default is true. That property concerns a scroll view’s behavior, rather than selecting a different nested scroll view as the event destination. usesPredominantAxisScrolling
Understand the documented forwarding boundary
The name wantsForwardedScrollEvents(for:) can suggest a universal nested-scroll fix. Apple documents a narrower condition: views performing elastic scrolling can forward gesture events when the content is already at the relevant edge at the beginning of the gesture. That documentation does not establish unconditional forwarding of ordinary wheel events or arbitrary axis splitting. wantsForwardedScrollEvents(for:)
Similarly, the default NSResponder.scrollWheel(with:) implementation passes the message to the next responder, while NSScrollView implements the method to perform scrolling. You should therefore distinguish a responder passing an unhandled event onward from a scroll container consuming it. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)
For an interface where you need explicit control, the supported building blocks are NSViewRepresentable, which embeds an AppKit view in SwiftUI, and NSHostingView, which embeds SwiftUI content in AppKit. The implementation below uses those bridges to own both scroll containers and keep the cards in SwiftUI. Its routing policy is application code derived from the documented APIs, rather than an Apple-provided nested-scrolling recipe. NSViewRepresentable, NSHostingView
Build an app-owned nested scrolling example
Create a macOS app project in Xcode using SwiftUI and Swift. Set its deployment target to macOS 11 or later. Replace the generated app entry with the first file below, then add the second file to the same target. Together, these two files form one complete demonstration program with synthetic labels and no additional assets.
The sample uses the SwiftUI App lifecycle, available from macOS 11. Keep exactly one @main declaration in the target: both Apple’s lifecycle documentation and Swift’s language reference require one program entry point. App, Attributes
Create the outer list and SwiftUI cards
The first file creates eight shelves. Each shelf has sixteen cards and its own horizontal document. The outer document tracks the viewport width and has a fixed demonstration height, so only the outer container needs vertical scrolling.
SwiftUI controls the represented outer view’s frame. The custom layout changes only the AppKit document and shelf frames inside it, respecting Apple’s warning against changing the layout properties of the view managed directly by NSViewRepresentable. NSViewRepresentable
Save this as NestedScrollDemoApp.swift:
import SwiftUI
import AppKit
@main
struct NestedScrollDemoApp: App {
var body: some Scene {
WindowGroup {
ShelfBoard()
.frame(minWidth: 420, minHeight: 320)
}
}
}
struct ShelfBoard: NSViewRepresentable {
func makeNSView(context: Context) -> BoardScrollView {
BoardScrollView(frame: .zero)
}
func updateNSView(_ nsView: BoardScrollView, context: Context) {
// The demonstration content is static.
}
}
struct ShelfCards: View {
let row: Int
var body: some View {
HStack(spacing: 12) {
ForEach(1...16, id: \.self) { column in
Text("Shelf \(row) · Card \(column)")
.frame(width: 140, height: 96)
.background(Color.gray.opacity(0.15))
.cornerRadius(8)
}
}
.padding(12)
.frame(width: 2444, height: 120)
}
}
final class TopDocument: NSView {
override var isFlipped: Bool { true }
}
final class BoardScrollView: NSScrollView {
private let board = TopDocument(frame: .zero)
private var shelves: [ShelfScrollView] = []
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
hasVerticalScroller = true
hasHorizontalScroller = false
autohidesScrollers = true
horizontalScrollElasticity = .none
documentView = board
for row in 1...8 {
let shelf = ShelfScrollView(frame: .zero)
shelf.verticalOwner = self
shelf.hasHorizontalScroller = true
shelf.hasVerticalScroller = false
shelf.autohidesScrollers = true
shelf.verticalScrollElasticity = .none
let cards = NSHostingView(rootView: ShelfCards(row: row))
cards.frame = NSRect(x: 0, y: 0, width: 2444, height: 120)
shelf.documentView = cards
board.addSubview(shelf)
shelves.append(shelf)
}
}
required init?(coder: NSCoder) {
fatalError("Use init(frame:)")
}
override func layout() {
super.layout()
let width = contentSize.width
board.frame = NSRect(x: 0, y: 0, width: width, height: 1184)
for (index, shelf) in shelves.enumerated() {
shelf.frame = NSRect(
x: 0, y: 8 + CGFloat(index) * 146,
width: width, height: 140
)
}
}
}
NSScrollView coordinates its document view, clipping view and scrollers. Here, the outer document contains the shelf scroll views; each shelf’s document is an NSHostingView containing the SwiftUI cards. The width of 2444 points is illustrative layout arithmetic: sixteen cards at 140, fifteen gaps at 12, and 24 points of horizontal padding. NSScrollView, NSHostingView
Route a gesture once and retain its destination
The second file supplies the routing policy. A gesture whose first nonzero movement is predominantly vertical goes to the outer list. Otherwise, it stays with the shelf. A tie belongs to the shelf. These are explicit design choices for this demonstration.
Apple provides separate phase and momentumPhase properties. Legacy wheel events have neither phase, while momentum events have no ordinary gesture phase. The sample therefore treats an unphased wheel event independently and retains a phased gesture’s destination through its momentum sequence. phase, momentumPhase
Save this as ShelfScrollView.swift:
import AppKit
final class ShelfScrollView: NSScrollView {
weak var verticalOwner: NSScrollView?
private var routesVertically: Bool?
private var pending: [NSEvent] = []
override func scrollWheel(with event: NSEvent) {
let phase = event.phase
let momentum = event.momentumPhase
let x = event.scrollingDeltaX
let y = event.scrollingDeltaY
// A legacy wheel event has no gesture or momentum phase.
if phase.isEmpty && momentum.isEmpty {
routesVertically = nil
pending.removeAll()
deliver(event, vertically: abs(y) > abs(x))
return
}
if phase.contains(.mayBegin) {
routesVertically = nil
pending = [event]
return
}
if phase.contains(.began) {
routesVertically = nil
if pending.last?.phase.contains(.mayBegin) != true {
pending.removeAll()
}
}
let finishes = phase.contains(.cancelled)
|| momentum.contains(.ended)
|| momentum.contains(.cancelled)
if let vertical = routesVertically {
deliver(event, vertically: vertical)
} else {
// Keep zero-delta opening events until direction is known.
pending.append(event)
if x != 0 || y != 0 {
let vertical = abs(y) > abs(x)
routesVertically = vertical
let openingEvents = pending
pending.removeAll()
for openingEvent in openingEvents {
deliver(openingEvent, vertically: vertical)
}
} else if phase.contains(.ended) || finishes {
pending.removeAll()
}
}
if finishes {
routesVertically = nil
pending.removeAll()
}
// Retain direction after phase.ended for possible momentum.
}
private func deliver(_ event: NSEvent, vertically: Bool) {
if vertically, let owner = verticalOwner {
owner.scrollWheel(with: event)
} else {
super.scrollWheel(with: event)
}
}
}
The code reads scrollingDeltaX and scrollingDeltaY only to choose a destination. It passes the original event to one scroll view, allowing AppKit to perform the scrolling. It does not reinterpret the delta as a point offset. This matters because Apple documents different handling of precise and nonprecise deltas; for example, nonprecise vertical deltas require line-height scaling when an application implements movement itself. scrollingDeltaX, scrollingDeltaY
The weak verticalOwner reference points directly to the outer scroll view created by this example. This avoids depending on SwiftUI’s internal view hierarchy. The sample does not add an event monitor; its override handles events delivered to the app’s shelf views.
Check both directions, momentum and resizing
This example was inspected against the retrieved API documentation, but it was not compiled or run: the research environment has no Swift compiler or macOS runtime. Build it in Xcode and perform the following checks before adopting the routing policy in an existing interface.
| Check | Intended result |
|---|---|
| Scroll vertically with the pointer over a card | The outer list moves |
| Scroll horizontally over the first shelf | Only that shelf moves horizontally |
| Move another shelf horizontally | Its offset changes independently |
| Begin vertically, then introduce diagonal movement | The gesture remains assigned to the outer list |
| Begin horizontally, then introduce diagonal movement | The gesture remains assigned to the shelf |
| Release a trackpad gesture into momentum | Momentum retains the selected destination |
| Use a conventional vertical wheel | Each wheel event routes to the outer list |
| Resize the window | Shelves follow the outer viewport width |
Also test zero-delta gesture openings, cancellation, rapid direction changes and both overlay and always-visible scrollers. The buffering in ShelfScrollView exists so that a zero-delta opening does not prematurely select horizontal scrolling. Its behavior with your supported devices and macOS versions still needs runtime verification.
The diagonal policy deliberately selects one destination for a phased gesture. It does not promise simultaneous movement of the outer list and shelf. At a horizontal shelf’s end, this sample also retains that shelf as the destination rather than handing the gesture to the vertical list. If your task requires continuous diagonal panning, use a single two-axis document instead.
The example builds all cards immediately and uses fixed dimensions. Treat those dimensions as demonstration inputs. For changing content, implement updateNSView to update the existing document and hosting views while retaining their scroll positions; Apple documents that method as the place to apply new SwiftUI state to a represented AppKit view. NSViewRepresentable
Rule out disabled scrolling and text-layout problems
If neither direction works, inspect modifiers before changing routing. On macOS 13 and later, scrollDisabled(_:) passes its setting through SwiftUI’s environment and can affect both an outer SwiftUI scroll view and nested scroll views. It is a scrolling enablement control, rather than an axis-forwarding mechanism. scrollDisabled(_:)
If your eventual shelf contains an AppKit text view, check text geometry separately. TextKit’s NSTextContainer.widthTracksTextView controls whether the text container’s width follows the text view’s width. It does not choose which scroll view receives a wheel gesture. A text-layout change can alter whether content overflows horizontally, but it should not be presented as an event-routing fix. The card demonstration avoids that additional variable. widthTracksTextView
Start with a single two-axis SwiftUI scroll view when the interface is one document. For independent shelves that need explicit vertical routing, the app-owned bridge gives you a defined destination for each gesture while retaining SwiftUI card content. Validate the gesture policy on the Macs and input devices your app supports before extending the example to dynamic content.
Sources
- ScrollView — Apple
- usesPredominantAxisScrolling — Apple
- wantsForwardedScrollEvents(for:) — Apple
- scrollWheel(with:) — NSResponder — Apple
- scrollWheel(with:) — NSScrollView — Apple
- NSViewRepresentable — Apple
- NSHostingView — Apple
- NSScrollView — Apple
- App — Apple
- Attributes — Swift.org
- phase — Apple
- momentumPhase — Apple
- scrollingDeltaX — Apple
- scrollingDeltaY — Apple
- scrollDisabled(_:) — Apple
- widthTracksTextView — Apple