Gib jeder Geste ein klares Ziel, wenn deine Mac-App horizontale Bereiche in einer vertikalen Liste enthält. Beginne mit der Achsenkonfiguration von SwiftUI. Wenn du eine explizite Weiterleitung brauchst, verwalte die verschachtelten AppKit-Scrollansichten über eine SwiftUI-Brücke.
Die konkrete Aufgabe hier ist das Durchstöbern synthetischer Karten: Vertikales Scrollen über einem beliebigen Bereich soll die gesamte Liste bewegen, horizontales Scrollen dagegen nur diesen Bereich. Dafür braucht jede Zeile einen eigenen horizontalen Versatz. Das unterscheidet sich von einer einzelnen Fläche, die sich gemeinsam horizontal und vertikal bewegt.
Wähle eine Scrollstruktur, die zur Aufgabe passt
Apple dokumentiert, dass eine SwiftUI-ScrollView horizontal, vertikal oder auf beiden Achsen scrollen kann. Verwende für ein einzelnes großes Dokument ScrollView([.horizontal, .vertical]) und gib seinem Inhalt Abmessungen, die größer als der sichtbare Bereich sind. Das ist die einfachste Struktur, wenn alle Inhalte dieselbe horizontale und vertikale Position teilen. ScrollView
Unabhängige Bereiche benötigen separate Scrollcontainer. Die naheliegende SwiftUI-Struktur ist eine vertikale ScrollView mit horizontalen ScrollView-Instanzen, von denen jede eine begrenzte Höhe hat. Die Konfiguration dieser Achsen legt jedoch nicht fest, wie ein Ereignis, das über einem inneren Container empfangen wird, den äußeren Container erreicht. Apples Dokumentation zu ScrollView beschreibt die unterstützten Achsen, legt aber keinen allgemeinen Vertrag zur Ereignisweiterleitung für diesen Verschachtelungsfall fest. ScrollView
| Erforderliches Verhalten | Geeigneter Ausgangspunkt |
|---|---|
| Ein Dokument bewegt sich in beide Richtungen | Eine zweiachsige SwiftUI-ScrollView |
| Jeder Bereich behält seine eigene horizontale Position | Verschachtelte horizontale Bereiche in einem vertikalen Container |
| Vertikale Gesten über den Bereichen brauchen ein explizites Ziel | Eigene NSScrollView-Unterklassen der App, eingebunden über NSViewRepresentable |
| Eine diagonale Geste muss das gesamte Dokument frei bewegen | Ein zweiachsiger Container; AppKit stellt außerdem usesPredominantAxisScrolling bereit |
AppKit unterscheidet zwischen Inhalten, die überwiegend jeweils entlang einer Achse scrollen, und Inhalten wie einer Fläche, die sich frei entlang beider Achsen bewegen sollen. Die Eigenschaft usesPredominantAxisScrolling drückt diesen Unterschied aus; ihr dokumentierter Standardwert ist true. Diese Eigenschaft betrifft das Verhalten einer Scrollansicht. Sie wählt nicht eine andere verschachtelte Scrollansicht als Ziel des Ereignisses aus. usesPredominantAxisScrolling
Verstehe die dokumentierte Grenze der Weiterleitung
Der Name wantsForwardedScrollEvents(for:) kann wie eine universelle Lösung für verschachteltes Scrollen klingen. Apple dokumentiert eine engere Bedingung: Ansichten, die elastisch scrollen, können Gestenereignisse weiterleiten, wenn sich der Inhalt zu Beginn der Geste bereits am entsprechenden Rand befindet. Diese Dokumentation belegt keine bedingungslose Weiterleitung gewöhnlicher Scrollradereignisse oder eine beliebige Aufteilung auf Achsen. wantsForwardedScrollEvents(for:)
Auch die Standardimplementierung von NSResponder.scrollWheel(with:) gibt die Nachricht an den nächsten Responder weiter, während NSScrollView die Methode zum Scrollen implementiert. Unterscheide daher zwischen einem Responder, der ein nicht verarbeitetes Ereignis weitergibt, und einem Scrollcontainer, der es verarbeitet. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)
Für eine Oberfläche, die explizite Kontrolle erfordert, sind NSViewRepresentable, das eine AppKit-Ansicht in SwiftUI einbettet, und NSHostingView, das SwiftUI-Inhalte in AppKit einbettet, die unterstützten Bausteine. Die folgende Implementierung nutzt diese Brücken, um beide Scrollcontainer selbst zu verwalten und die Karten in SwiftUI zu belassen. Die Weiterleitungsrichtlinie ist Anwendungscode, der aus den dokumentierten APIs abgeleitet ist, und kein von Apple bereitgestelltes Rezept für verschachteltes Scrollen. NSViewRepresentable, NSHostingView
Erstelle ein Beispiel für verschachteltes Scrollen unter Kontrolle der App
Erstelle in Xcode ein macOS-App-Projekt mit SwiftUI und Swift. Lege als Bereitstellungsziel macOS 11 oder neuer fest. Ersetze den generierten App-Einstiegspunkt durch die erste Datei unten und füge dann die zweite Datei demselben Target hinzu. Zusammen bilden diese beiden Dateien ein vollständiges Demonstrationsprogramm mit synthetischen Beschriftungen und ohne zusätzliche Ressourcen.
Das Beispiel verwendet den SwiftUI-App-Lebenszyklus, der ab macOS 11 verfügbar ist. Das Target muss genau eine @main-Deklaration enthalten: Sowohl Apples Dokumentation zum App-Lebenszyklus als auch die Swift-Sprachreferenz verlangen einen Programmeinstiegspunkt. App, Attributes
Erstelle die äußere Liste und die SwiftUI-Karten
Die erste Datei erstellt acht Bereiche. Jeder Bereich enthält sechzehn Karten und ein eigenes horizontales Dokument. Das äußere Dokument erfasst die Breite des sichtbaren Bereichs und hat eine feste Demonstrationshöhe. Daher muss nur der äußere Container vertikal scrollen.
SwiftUI steuert den Frame der dargestellten äußeren Ansicht. Das benutzerdefinierte Layout ändert nur die Frames des AppKit-Dokuments und der darin enthaltenen Bereiche und beachtet damit Apples Warnung, die Layouteigenschaften der Ansicht nicht zu ändern, die direkt von NSViewRepresentable verwaltet wird. NSViewRepresentable
Speichere dies als 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 koordiniert seine Dokumentansicht, Clipping-Ansicht und Scrollleisten. Hier enthält das äußere Dokument die Scrollansichten der Bereiche; das Dokument jedes Bereichs ist eine NSHostingView mit den SwiftUI-Karten. Die Breite von 2444 ist eine beispielhafte Layoutberechnung: sechzehn Karten mit 140, fünfzehn Abstände mit 12 und 24 Punkte horizontaler Innenabstand. NSScrollView, NSHostingView
Leite eine Geste einmal weiter und behalte ihr Ziel bei
Die zweite Datei legt die Weiterleitungsrichtlinie fest. Eine Geste, deren erste Bewegung ungleich null überwiegend vertikal ist, wird an die äußere Liste geleitet. Andernfalls bleibt sie beim Bereich. Bei Gleichstand erhält der Bereich die Geste. Dies sind ausdrückliche Entwurfsentscheidungen für diese Demonstration.
Apple stellt die separaten Eigenschaften phase und momentumPhase bereit. Ältere Scrollradereignisse haben keine Phase, während Momentumereignisse keine gewöhnliche Gestenphase haben. Daher behandelt das Beispiel phasenlose Scrollradereignisse unabhängig und behält das Ziel einer phasenbehafteten Geste während der zugehörigen Momentumsequenz bei. phase, momentumPhase
Speichere dies als 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)
}
}
}
Der Code liest scrollingDeltaX und scrollingDeltaY nur aus, um ein Ziel auszuwählen. Er leitet das ursprüngliche Ereignis an genau eine Scrollansicht weiter, sodass AppKit das Scrollen übernimmt. Der Delta-Wert wird nicht als Punktversatz interpretiert. Das ist wichtig, weil Apple eine unterschiedliche Behandlung präziser und nicht präziser Delta-Werte dokumentiert. So müssen beispielsweise nicht präzise vertikale Delta-Werte mit der Zeilenhöhe skaliert werden, wenn eine Anwendung die Bewegung selbst implementiert. scrollingDeltaX, scrollingDeltaY
Die schwache Referenz verticalOwner zeigt direkt auf die äußere Scrollansicht, die dieses Beispiel erstellt. So ist das Beispiel nicht von der internen Ansichtshierarchie von SwiftUI abhängig. Es fügt keinen Ereignismonitor hinzu; die Überschreibung verarbeitet Ereignisse, die bei den Bereichsansichten der App eingehen.
Prüfe beide Richtungen, Momentum und Größenänderungen
Dieses Beispiel wurde anhand der abgerufenen API-Dokumentation geprüft, aber nicht kompiliert oder ausgeführt: In der Rechercheumgebung gibt es weder einen Swift-Compiler noch eine macOS-Laufzeitumgebung. Erstelle es in Xcode und führe die folgenden Prüfungen durch, bevor du die Weiterleitungsrichtlinie in einer bestehenden Oberfläche verwendest.
| Prüfung | Erwartetes Ergebnis |
|---|---|
| Vertikales Scrollen, während sich der Zeiger über einer Karte befindet | Die äußere Liste bewegt sich |
| Horizontal über den ersten Bereich scrollen | Nur dieser Bereich bewegt sich horizontal |
| Einen anderen Bereich horizontal bewegen | Sein Versatz ändert sich unabhängig |
| Vertikal beginnen und dann eine diagonale Bewegung einführen | Die Geste bleibt der äußeren Liste zugeordnet |
| Horizontal beginnen und dann eine diagonale Bewegung einführen | Die Geste bleibt dem Bereich zugeordnet |
| Eine Trackpad-Geste in Momentum übergehen lassen | Das Momentum behält das ausgewählte Ziel bei |
| Ein herkömmliches vertikales Scrollrad verwenden | Jedes Scrollradereignis wird an die äußere Liste geleitet |
| Fenstergröße ändern | Die Bereiche passen sich der Breite des äußeren sichtbaren Bereichs an |
Teste außerdem Gestenanfänge mit Delta-Wert null, Abbrüche, schnelle Richtungswechsel und sowohl eingeblendete als auch ständig sichtbare Scrollleisten. Die Zwischenspeicherung in ShelfScrollView verhindert, dass ein Gestenanfang mit Delta-Wert null vorzeitig horizontales Scrollen auswählt. Das Verhalten mit den unterstützten Geräten und macOS-Versionen muss weiterhin zur Laufzeit überprüft werden.
Die Richtlinie für diagonale Bewegungen wählt für eine phasenbehaftete Geste bewusst ein einzelnes Ziel aus. Sie verspricht nicht, die äußere Liste und den Bereich gleichzeitig zu bewegen. Am Ende eines horizontalen Bereichs behält dieses Beispiel den Bereich ebenfalls als Ziel bei, anstatt die Geste an die vertikale Liste weiterzugeben. Wenn deine Aufgabe kontinuierliches diagonales Schwenken erfordert, verwende stattdessen ein einzelnes zweiachsiges Dokument.
Das Beispiel erstellt alle Karten sofort und verwendet feste Abmessungen. Betrachte diese Abmessungen als Eingaben für die Demonstration. Bei wechselnden Inhalten implementiere updateNSView, um das vorhandene Dokument und die Hosting-Ansichten zu aktualisieren und dabei ihre Scrollpositionen beizubehalten. Apple dokumentiert diese Methode als den Ort, an dem neuer SwiftUI-Zustand auf eine dargestellte AppKit-Ansicht angewendet wird. NSViewRepresentable
Schließe deaktiviertes Scrollen und Probleme mit dem Textlayout aus
Wenn keine der beiden Richtungen funktioniert, prüfe die Modifikatoren, bevor du die Weiterleitung änderst. Ab macOS 13 reicht scrollDisabled(_:) seine Einstellung über die SwiftUI-Umgebung weiter und kann sowohl eine äußere SwiftUI-Scrollansicht als auch verschachtelte Scrollansichten beeinflussen. Es steuert, ob Scrollen aktiviert ist, und leitet keine Achsen weiter. scrollDisabled(_:)
Falls dein späterer Bereich eine AppKit-Textansicht enthält, prüfe die Textgeometrie separat. Die TextKit-Eigenschaft NSTextContainer.widthTracksTextView steuert, ob die Breite des Textcontainers der Breite der Textansicht folgt. Sie entscheidet nicht, welche Scrollansicht ein Scrollradereignis empfängt. Eine Änderung am Textlayout kann beeinflussen, ob der Inhalt horizontal über den sichtbaren Bereich hinausgeht, sollte aber nicht als Lösung für die Ereignisweiterleitung dargestellt werden. Die Kartendemonstration vermeidet diese zusätzliche Variable. widthTracksTextView
Verwende eine einzelne zweiachsige SwiftUI-Scrollansicht, wenn die Oberfläche aus einem Dokument besteht. Für unabhängige Bereiche, die eine explizite vertikale Weiterleitung benötigen, gibt die app-eigene Brücke jeder Geste ein festgelegtes Ziel und behält gleichzeitig SwiftUI-Karten bei. Prüfe die Gestenrichtlinie auf den Macs und Eingabegeräten, die deine App unterstützt, bevor du das Beispiel auf dynamische Inhalte ausweitest.
Quellen
- 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