W aplikacji na Maca z poziomymi półkami wewnątrz pionowej listy przypisz każdemu gestowi jasny cel. Zacznij od konfiguracji osi w SwiftUI; jeśli potrzebujesz jawnego kierowania zdarzeń, zarządzaj zagnieżdżonymi widokami przewijania AppKit za pomocą mostu SwiftUI.

Konkretnym zadaniem jest tu przeglądanie syntetycznych kart: przewijanie pionowe nad dowolną półką powinno przesuwać całą listę, a przewijanie poziome powinno niezależnie przesuwać tę półkę. Wymaga to osobnych przesunięć poziomych dla każdego wiersza. Różni się to od pojedynczego obszaru roboczego, który przesuwa się jednocześnie w poziomie i pionie.

Wybierz strukturę przewijania odpowiednią do zadania

Apple dokumentuje, że widok SwiftUI ScrollView może przewijać się poziomo, pionowo lub wzdłuż obu osi. W przypadku jednego dużego dokumentu użyj ScrollView([.horizontal, .vertical]) i nadaj jego zawartości wymiary większe niż obszar widoku. To najprostsza struktura, gdy cała zawartość ma wspólną pozycję poziomą i pionową. ScrollView

Niezależne półki wymagają osobnych kontenerów przewijania. Naturalną strukturą SwiftUI jest pionowy ScrollView zawierający poziome instancje ScrollView, każda o ograniczonej wysokości. Skonfigurowanie tych osi samo w sobie nie określa jednak, jak zdarzenie odebrane nad wewnętrznym kontenerem powinno dotrzeć do kontenera zewnętrznego. Dokumentacja Apple dotycząca ScrollView opisuje obsługiwane osie, ale nie ustanawia ogólnej umowy kierowania zdarzeń dla tego przypadku zagnieżdżenia. ScrollView

Wymagane zachowanie Odpowiedni punkt wyjścia
Jeden dokument przesuwa się w obu kierunkach Jeden dwuosiowy SwiftUI ScrollView
Każda półka zachowuje własną pozycję poziomą Zagnieżdżone poziome półki wewnątrz pionowego kontenera
Gesty pionowe nad półkami wymagają jawnego celu Podklasy NSScrollView zarządzane przez aplikację i udostępnione za pomocą NSViewRepresentable
Gest ukośny ma swobodnie przesuwać cały dokument Jeden dwuosiowy kontener; AppKit udostępnia też usesPredominantAxisScrolling

AppKit rozróżnia zawartość przewijaną głównie wzdłuż jednej osi naraz od zawartości, takiej jak obszar roboczy, która powinna swobodnie poruszać się wzdłuż obu osi. Właściwość usesPredominantAxisScrolling wyraża to rozróżnienie; jej udokumentowaną wartością domyślną jest true. Właściwość ta dotyczy zachowania widoku przewijania, a nie wybierania innego zagnieżdżonego widoku przewijania jako celu zdarzenia. usesPredominantAxisScrolling

Poznaj udokumentowaną granicę przekazywania zdarzeń

Nazwa wantsForwardedScrollEvents(for:) może sugerować uniwersalne rozwiązanie problemu zagnieżdżonego przewijania. Apple dokumentuje węższy warunek: widoki obsługujące przewijanie z efektem sprężystości mogą przekazywać zdarzenia gestów, gdy zawartość już znajduje się przy odpowiedniej krawędzi w chwili rozpoczęcia gestu. Dokumentacja nie ustanawia bezwarunkowego przekazywania zwykłych zdarzeń kółka myszy ani dowolnego rozdzielania osi. wantsForwardedScrollEvents(for:)

Podobnie domyślna implementacja NSResponder.scrollWheel(with:) przekazuje komunikat do następnego elementu łańcucha responderów, podczas gdy NSScrollView implementuje tę metodę w celu przewijania. Należy więc odróżnić przekazanie przez responder nieobsłużonego zdarzenia od przejęcia go przez kontener przewijania. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)

Jeśli interfejs wymaga jawnej kontroli, obsługiwane elementy składowe to NSViewRepresentable, które osadza widok AppKit w SwiftUI, oraz NSHostingView, które osadza zawartość SwiftUI w AppKit. Poniższa implementacja korzysta z tych mostów, aby zarządzać oboma kontenerami przewijania, pozostawiając karty w SwiftUI. Jej polityka kierowania zdarzeń jest kodem aplikacji opartym na udokumentowanych interfejsach API, a nie gotową receptą Apple na zagnieżdżone przewijanie. NSViewRepresentable, NSHostingView

Zbuduj przykład zagnieżdżonego przewijania zarządzany przez aplikację

Utwórz w Xcode projekt aplikacji macOS korzystający ze SwiftUI i Swifta. Ustaw minimalną obsługiwaną wersję macOS 11 lub nowszą. Zastąp wygenerowany punkt wejścia aplikacji pierwszym plikiem poniżej, a następnie dodaj drugi plik do tego samego celu kompilacji. Te dwa pliki tworzą kompletny program demonstracyjny z syntetycznymi etykietami i bez dodatkowych zasobów.

Przykład korzysta z cyklu życia SwiftUI App, dostępnego od macOS 11. W celu kompilacji pozostaw dokładnie jedną deklarację @main: dokumentacja cyklu życia Apple oraz referencja języka Swift wymagają jednego punktu wejścia programu. App, Attributes

Utwórz listę zewnętrzną i karty SwiftUI

Pierwszy plik tworzy osiem półek. Każda półka ma szesnaście kart i własny poziomy dokument. Dokument zewnętrzny śledzi szerokość obszaru widoku i ma stałą wysokość demonstracyjną, więc tylko zewnętrzny kontener wymaga przewijania pionowego.

SwiftUI kontroluje ramkę reprezentowanego widoku zewnętrznego. Niestandardowy układ zmienia wyłącznie dokument AppKit i ramki półek wewnątrz niego, zgodnie z ostrzeżeniem Apple, by nie zmieniać właściwości układu widoku zarządzanego bezpośrednio przez NSViewRepresentable. NSViewRepresentable

Zapisz to jako 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 koordynuje widok dokumentu, widok przycinający i paski przewijania. Tutaj dokument zewnętrzny zawiera widoki przewijania półek; dokument każdej półki to NSHostingView zawierający karty SwiftUI. Szerokość 2444 punktów to poglądowe obliczenie układu: szesnaście kart po 140, piętnaście odstępów po 12 oraz 24 punktów poziomego marginesu. NSScrollView, NSHostingView

Skieruj gest raz i zachowaj jego cel

Drugi plik określa politykę kierowania zdarzeń. Gest, którego pierwsze niezerowe przesunięcie jest głównie pionowe, trafia do zewnętrznej listy. W przeciwnym razie pozostaje przy półce. W przypadku remisu wybierana jest półka. To jawne decyzje projektowe na potrzeby tego przykładu.

Apple udostępnia osobne właściwości phase i momentumPhase. Starsze zdarzenia kółka myszy nie mają żadnej fazy, a zdarzenia bezwładnościowe nie mają zwykłej fazy gestu. Dlatego przykład obsługuje niezależnie zdarzenie kółka myszy bez fazy, a cel gestu z fazami zachowuje przez sekwencję jego ruchu bezwładnościowego. phase, momentumPhase

Zapisz to jako 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)
        }
    }
}

Kod odczytuje scrollingDeltaX i scrollingDeltaY wyłącznie po to, by wybrać cel. Przekazuje oryginalne zdarzenie do jednego widoku przewijania, pozwalając AppKit wykonać przewijanie. Nie interpretuje ponownie delty jako przesunięcia punktowego. Ma to znaczenie, ponieważ Apple dokumentuje różne sposoby obsługi delt precyzyjnych i nieprecyzyjnych; na przykład nieprecyzyjne delty pionowe wymagają przeskalowania według wysokości wiersza, gdy aplikacja sama implementuje ruch. scrollingDeltaX, scrollingDeltaY

Słabe odwołanie verticalOwner wskazuje bezpośrednio na zewnętrzny widok przewijania utworzony w tym przykładzie. Dzięki temu nie trzeba polegać na wewnętrznej hierarchii widoków SwiftUI. Przykład nie dodaje monitora zdarzeń; jego przesłonięta metoda obsługuje zdarzenia dostarczane do widoków półek aplikacji.

Sprawdź oba kierunki, ruch bezwładnościowy i zmianę rozmiaru

Przykład sprawdzono na podstawie pobranej dokumentacji API, ale nie został skompilowany ani uruchomiony: środowisko badawcze nie ma kompilatora Swift ani środowiska uruchomieniowego macOS. Skompiluj go w Xcode i wykonaj poniższe sprawdzenia przed zastosowaniem tej polityki kierowania zdarzeń w istniejącym interfejsie.

Sprawdzenie Oczekiwany wynik
Przewiń pionowo, gdy wskaźnik znajduje się nad kartą Przesuwa się lista zewnętrzna
Przewiń poziomo nad pierwszą półką Przesuwa się poziomo tylko ta półka
Przesuń poziomo inną półkę Jej przesunięcie zmienia się niezależnie
Zacznij pionowo, a następnie wprowadź ruch ukośny Gest pozostaje przypisany do listy zewnętrznej
Zacznij poziomo, a następnie wprowadź ruch ukośny Gest pozostaje przypisany do półki
Zakończ gest gładzika, pozwalając mu przejść w ruch bezwładnościowy Ruch bezwładnościowy zachowuje wybrany cel
Użyj tradycyjnego kółka myszy do przewijania pionowego Każde zdarzenie kółka trafia do listy zewnętrznej
Zmień rozmiar okna Półki dopasowują się do szerokości zewnętrznego obszaru widoku

Sprawdź też otwarcia gestu z deltą zerową, anulowanie, szybkie zmiany kierunku oraz paski przewijania nakładane i zawsze widoczne. Buforowanie w ShelfScrollView zapobiega przedwczesnemu wyborowi przewijania poziomego przy otwarciu z deltą zerową. Jego działanie na obsługiwanych urządzeniach i wersjach macOS nadal wymaga weryfikacji w czasie działania.

Polityka ruchu ukośnego celowo wybiera jeden cel dla gestu z fazami. Nie zapewnia jednoczesnego przesuwania listy zewnętrznej i półki. Na końcu poziomej półki przykład również zachowuje ją jako cel, zamiast przekazywać gest do listy pionowej. Jeśli zadanie wymaga ciągłego przesuwania po przekątnej, użyj jednego dokumentu dwuosiowego.

Przykład od razu tworzy wszystkie karty i korzysta ze stałych wymiarów. Traktuj te wymiary jako dane wejściowe demonstracji. W przypadku zmieniającej się zawartości zaimplementuj updateNSView, aby aktualizować istniejący dokument i widoki hostingowe, zachowując ich pozycje przewijania; Apple dokumentuje tę metodę jako miejsce, w którym należy zastosować nowy stan SwiftUI do reprezentowanego widoku AppKit. NSViewRepresentable

Wyklucz wyłączone przewijanie i problemy z układem tekstu

Jeśli nie działa żaden kierunek, przed zmianą kierowania zdarzeń sprawdź modyfikatory. W macOS 13 i nowszych scrollDisabled(_:) przekazuje swoje ustawienie przez środowisko SwiftUI i może wpływać zarówno na zewnętrzny widok przewijania SwiftUI, jak i zagnieżdżone widoki przewijania. Służy do włączania i wyłączania przewijania, a nie do przekazywania zdarzeń między osiami. scrollDisabled(_:)

Jeśli Twoja półka będzie zawierać widok tekstowy AppKit, osobno sprawdź geometrię tekstu. Właściwość TextKit NSTextContainer.widthTracksTextView określa, czy szerokość kontenera tekstu podąża za szerokością widoku tekstowego. Nie wybiera ona widoku przewijania, który odbierze gest kółka myszy. Zmiana układu tekstu może wpłynąć na to, czy zawartość wykracza poza obszar w poziomie, ale nie należy przedstawiać jej jako poprawki kierowania zdarzeń. Demonstracja z kartami pozwala uniknąć tej dodatkowej zmiennej. widthTracksTextView

Jeśli interfejs jest jednym dokumentem, zacznij od pojedynczego dwuosiowego widoku przewijania SwiftUI. W przypadku niezależnych półek wymagających jawnego kierowania przewijania pionowego most zarządzany przez aplikację wyznacza cel każdego gestu, zachowując zawartość kart w SwiftUI. Sprawdź politykę gestów na komputerach Mac i urządzeniach wejściowych obsługiwanych przez aplikację, zanim rozszerzysz przykład o dynamiczną zawartość.

Źródła