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