I en Mac-app med horisontella hyllor i en vertikal lista bör varje gest ha ett tydligt mål. Börja med SwiftUIs axelkonfiguration. Om du behöver uttrycklig dirigering kan du hantera de nästlade AppKit-rullningsvyerna via en SwiftUI-brygga.

Den konkreta uppgiften här är att bläddra bland syntetiska kort: om du rullar vertikalt över en hylla ska hela listan flyttas, medan horisontell rullning ska flytta den hyllan oberoende av de andra. Det kräver separata horisontella förskjutningar för varje rad. Det skiljer sig från en enda arbetsyta som flyttas horisontellt och vertikalt tillsammans.

Välj en rullningsstruktur som passar uppgiften

Apple dokumenterar att en SwiftUI-ScrollView kan rulla horisontellt, vertikalt eller längs båda axlarna. För ett stort dokument använder du ScrollView([.horizontal, .vertical]) och ger innehållet dimensioner som överskrider visningsområdet. Det här är den enklaste strukturen när allt innehåll delar en horisontell och en vertikal position. ScrollView

Oberoende hyllor kräver separata rullningsbehållare. Den naturliga SwiftUI-strukturen är en vertikal ScrollView som innehåller horisontella ScrollView-instanser, var och en med en avgränsad höjd. Men att konfigurera axlarna anger inte i sig hur en händelse som tas emot över en inre behållare ska nå den yttre behållaren. Apples dokumentation för ScrollView beskriver vilka axlar som stöds, men fastställer inget allmänt kontrakt för händelsedirigering i det här nästlade fallet. ScrollView

Önskat beteende Lämplig utgångspunkt
Ett dokument flyttas i båda riktningarna En SwiftUI-ScrollView med två axlar
Varje hylla behåller sin egen horisontella position Nästlade horisontella hyllor i en vertikal behållare
Vertikala gester över hyllor behöver ett uttryckligt mål Appägda NSScrollView-underklasser som exponeras via NSViewRepresentable
En diagonal gest ska flytta hela dokumentet fritt En behållare med två axlar; AppKit har även usesPredominantAxisScrolling

AppKit skiljer mellan innehåll som huvudsakligen rullas längs en axel åt gången och innehåll, till exempel en arbetsyta, som bör kunna flyttas fritt längs båda axlarna. Egenskapen usesPredominantAxisScrolling uttrycker den skillnaden; det dokumenterade standardvärdet är true. Egenskapen gäller en rullningsvys beteende, inte valet av en annan nästlad rullningsvy som händelsens mål. usesPredominantAxisScrolling

Förstå gränsen för dokumenterad vidarebefordran

Namnet wantsForwardedScrollEvents(for:) kan ge intrycket att det löser alla problem med nästlad rullning. Apple dokumenterar ett snävare villkor: vyer med elastisk rullning kan vidarebefordra gesthändelser när innehållet redan befinner sig vid den relevanta kanten när gesten börjar. Dokumentationen fastslår inte att vanliga mushjulshändelser eller godtycklig uppdelning mellan axlar alltid vidarebefordras. wantsForwardedScrollEvents(for:)

På samma sätt skickar standardimplementationen av NSResponder.scrollWheel(with:) meddelandet vidare till nästa responder, medan NSScrollView implementerar metoden för att utföra rullningen. Skilj därför mellan att en responder skickar vidare en händelse som den inte hanterar och att en rullningsbehållare förbrukar den. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)

Om du behöver uttrycklig kontroll i ett gränssnitt är de API-byggstenar som stöds NSViewRepresentable, som bäddar in en AppKit-vy i SwiftUI, och NSHostingView, som bäddar in SwiftUI-innehåll i AppKit. Implementationen nedan använder dessa bryggor för att hantera båda rullningsbehållarna och behålla korten i SwiftUI. Dirigeringspolicyn är applikationskod som bygger på de dokumenterade API:erna, inte ett recept från Apple för nästlad rullning. NSViewRepresentable, NSHostingView

Bygg ett exempel med appägda nästlade rullningsvyer

Skapa ett macOS-approjekt i Xcode med SwiftUI och Swift. Ange macOS 11 eller senare som lägsta distributionsversion. Ersätt den genererade appens startpunkt med den första filen nedan och lägg sedan till den andra filen i samma mål. Tillsammans bildar filerna ett komplett demonstrationsprogram med syntetiska etiketter och utan extra resurser.

Exemplet använder livscykeln App i SwiftUI, som är tillgänglig från macOS 11. Behåll exakt en @main-deklaration i målet: både Apples dokumentation av appens livscykel och Swifts språkhandbok kräver en enda programpingång. App, Attributes

Skapa den yttre listan och SwiftUI-korten

Den första filen skapar åtta hyllor. Varje hylla har sexton kort och ett eget horisontellt dokument. Det yttre dokumentet håller reda på visningsområdets bredd och har en fast demonstrationshöjd, så det är bara den yttre behållaren som behöver vertikal rullning.

SwiftUI styr ramen för den representerade yttre vyn. Den anpassade layouten ändrar bara dokument- och hyllramarna inuti AppKit-vyn, i enlighet med Apples varning om att inte ändra layout-egenskaper för den vy som hanteras direkt av NSViewRepresentable. NSViewRepresentable

Spara filen som 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 samordnar sin dokumentvy, beskärningsvy och sina rullningslister. Här innehåller det yttre dokumentet hyllornas rullningsvyer. Varje hyllas dokument är en NSHostingView med SwiftUI-korten. Bredden på 2444 punkter är ett illustrativt layoutmått: sexton kort på 140, femton mellanrum på 12 och 24 punkters horisontell utfyllnad. NSScrollView, NSHostingView

Dirigera en gest en gång och behåll dess mål

Den andra filen innehåller dirigeringspolicyn. En gest vars första rörelse med värde som inte är noll huvudsakligen är vertikal går till den yttre listan. Annars stannar den i hyllan. Vid lika rörelse väljs hyllan. Det här är uttryckliga designval för demonstrationen.

Apple tillhandahåller separata egenskaper för phase och momentumPhase. Äldre mushjulshändelser har ingen fas, medan momentumhändelser saknar en vanlig gestfas. Exemplet behandlar därför en ofasad hjulhändelse separat och behåller målet för en fasad gest under hela dess momentumsekvens. phase, momentumPhase

Spara filen som 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)
        }
    }
}

Koden läser scrollingDeltaX och scrollingDeltaY enbart för att välja ett mål. Den skickar den ursprungliga händelsen till en rullningsvy, så att AppKit kan utföra rullningen. Den tolkar inte om deltaförändringen som en punktförskjutning. Det är viktigt eftersom Apple dokumenterar olika hantering av precisa och oprecisa deltaförändringar. Till exempel kräver oprecisa vertikala deltaförändringar skalning med radhöjden när en app själv hanterar förflyttningen. scrollingDeltaX, scrollingDeltaY

Den svaga referensen verticalOwner pekar direkt på den yttre rullningsvy som exemplet skapar. Därmed blir lösningen inte beroende av SwiftUIs interna vyhierarki. Exemplet lägger inte till någon händelseövervakare; åsidosättningen hanterar händelser som levereras till appens hyllvyer.

Kontrollera båda riktningarna, momentum och storleksändring

Exemplet har granskats mot den hämtade API-dokumentationen, men har inte kompilerats eller körts: undersökningsmiljön saknar Swift-kompilator och macOS-körtidsmiljö. Bygg det i Xcode och utför följande kontroller innan du använder dirigeringspolicyn i ett befintligt gränssnitt.

Kontroll Förväntat resultat
Rulla vertikalt med pekaren över ett kort Den yttre listan flyttas
Rulla horisontellt över den första hyllan Bara den hyllan flyttas horisontellt
Flytta en annan hylla horisontellt Dess förskjutning ändras oberoende
Börja vertikalt och lägg sedan till diagonal rörelse Gesten förblir tilldelad den yttre listan
Börja horisontellt och lägg sedan till diagonal rörelse Gesten förblir tilldelad hyllan
Släpp en styrplattegest så att den fortsätter med momentum Momentum behåller det valda målet
Använd ett vanligt vertikalt mushjul Varje hjulhändelse dirigeras till den yttre listan
Ändra fönstrets storlek Hyllorna följer den yttre visningsområdets bredd

Testa även geststarter med noll delta, avbrytning, snabba riktningsbyten samt både överlagrade och alltid synliga rullningslister. Buffringen i ShelfScrollView finns där för att en geststart med noll delta inte i förtid ska välja horisontell rullning. Beteendet behöver fortfarande verifieras i körning på de enheter och macOS-versioner du stöder.

Den diagonala policyn väljer medvetet ett mål för en fasad gest. Den lovar inte samtidig förflyttning av den yttre listan och hyllan. När en horisontell hylla tar slut behåller exemplet också den hyllan som mål i stället för att lämna över gesten till den vertikala listan. Om uppgiften kräver kontinuerlig diagonal panorering använder du i stället ett enda dokument med två axlar.

Exemplet bygger alla kort direkt och använder fasta dimensioner. Se dessa dimensioner som demonstrationsvärden. För innehåll som ändras implementerar du updateNSView för att uppdatera det befintliga dokumentet och värdvyerna, samtidigt som rullningspositionerna bevaras. Apple dokumenterar den metoden som platsen där nytt SwiftUI-tillstånd tillämpas på en representerad AppKit-vy. NSViewRepresentable

Uteslut inaktiverad rullning och problem med textlayout

Om ingen riktning fungerar bör du granska modifierare innan du ändrar dirigeringen. I macOS 13 och senare skickar scrollDisabled(_:) sin inställning vidare via SwiftUIs miljö och kan påverka både en yttre SwiftUI-rullningsvy och nästlade rullningsvyer. Det styr om rullning är aktiverad; det dirigerar inte vidare mellan axlar. scrollDisabled(_:)

Om hyllan senare ska innehålla en AppKit-textvy kontrollerar du textgeometrin separat. TextKits NSTextContainer.widthTracksTextView avgör om textbehållarens bredd följer textvyns bredd. Den väljer inte vilken rullningsvy som tar emot en hjulgest. En ändring av textlayouten kan påverka om innehållet flödar över horisontellt, men den bör inte beskrivas som en lösning för händelsedirigering. Kortdemonstrationen undviker den ytterligare variabeln. widthTracksTextView

Börja med en enda SwiftUI-rullningsvy med två axlar när gränssnittet består av ett dokument. För oberoende hyllor som behöver uttrycklig vertikal dirigering ger den appägda bryggan varje gest ett bestämt mål samtidigt som kortinnehållet förblir i SwiftUI. Kontrollera gestpolicyn på de Mac-datorer och inmatningsenheter som appen stöder innan du utökar exemplet med dynamiskt innehåll.

Källor