Per un’app Mac con sezioni orizzontali all’interno di un elenco verticale, assegna a ogni gesto una destinazione chiara. Inizia dalla configurazione degli assi di SwiftUI; se ti serve un instradamento esplicito, gestisci le visualizzazioni di scorrimento AppKit annidate tramite un bridge SwiftUI.

Il compito concreto consiste nello scorrere schede sintetiche: scorrendo verticalmente sopra una sezione qualsiasi deve spostarsi l’intero elenco, mentre lo scorrimento orizzontale deve spostare la sezione corrispondente in modo indipendente. Questo richiede offset orizzontali separati per ogni riga. È diverso da un’unica area di lavoro che si sposta insieme in orizzontale e in verticale.

Scegli una struttura di scorrimento adatta al compito

Apple documenta che una ScrollView SwiftUI può scorrere orizzontalmente, verticalmente o su entrambi gli assi. Per un unico documento di grandi dimensioni, usa ScrollView([.horizontal, .vertical]) e assegna al contenuto dimensioni che superino quelle dell’area visibile. È la struttura più semplice quando tutto il contenuto condivide una sola posizione orizzontale e una sola posizione verticale. ScrollView

Le sezioni indipendenti richiedono contenitori di scorrimento separati. La struttura SwiftUI naturale consiste in una ScrollView verticale che contiene istanze di ScrollView orizzontali, ciascuna con un’altezza limitata. Tuttavia, la configurazione di questi assi non specifica di per sé come un evento ricevuto sopra un contenitore interno debba raggiungere quello esterno. La documentazione Apple di ScrollView descrive gli assi supportati, ma non stabilisce un contratto generale di instradamento degli eventi per questo caso di annidamento. ScrollView

Comportamento richiesto Punto di partenza appropriato
Un documento scorre in entrambe le direzioni Una ScrollView SwiftUI a due assi
Ogni sezione mantiene la propria posizione orizzontale Sezioni orizzontali annidate in un contenitore verticale
I gesti verticali sopra le sezioni richiedono una destinazione esplicita Sottoclassi NSScrollView gestite dall’app ed esposte tramite NSViewRepresentable
Un gesto diagonale deve spostare liberamente l’intero documento Un singolo contenitore a due assi; AppKit espone anche usesPredominantAxisScrolling

AppKit distingue i contenuti che scorrono prevalentemente lungo un asse per volta dai contenuti, come un’area di lavoro, che dovrebbero muoversi liberamente lungo entrambi gli assi. La proprietà usesPredominantAxisScrolling esprime questa distinzione; il suo valore predefinito documentato è true. Questa proprietà riguarda il comportamento di una visualizzazione di scorrimento, non la selezione di un’altra visualizzazione di scorrimento annidata come destinazione dell’evento. usesPredominantAxisScrolling

Comprendi i limiti documentati dell’inoltro

Il nome wantsForwardedScrollEvents(for:) può far pensare a una soluzione universale per lo scorrimento annidato. Apple documenta una condizione più circoscritta: le visualizzazioni che eseguono uno scorrimento elastico possono inoltrare gli eventi dei gesti quando il contenuto si trova già sul bordo pertinente all’inizio del gesto. La documentazione non stabilisce l’inoltro incondizionato dei normali eventi della rotellina né la suddivisione arbitraria per asse. wantsForwardedScrollEvents(for:)

Analogamente, l’implementazione predefinita di NSResponder.scrollWheel(with:) passa il messaggio al responder successivo, mentre NSScrollView implementa il metodo per eseguire lo scorrimento. Occorre quindi distinguere il passaggio di un evento non gestito da parte di un responder dal suo consumo da parte di un contenitore di scorrimento. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)

Per un’interfaccia che richiede un controllo esplicito, i blocchi di base supportati sono NSViewRepresentable, che incorpora una visualizzazione AppKit in SwiftUI, e NSHostingView, che incorpora contenuti SwiftUI in AppKit. L’implementazione seguente usa questi bridge per gestire entrambi i contenitori di scorrimento e mantenere le schede in SwiftUI. La sua politica di instradamento è codice applicativo basato sulle API documentate, non una procedura Apple per lo scorrimento annidato. NSViewRepresentable, NSHostingView

Crea un esempio di scorrimento annidato gestito dall’app

Crea in Xcode un progetto per un’app macOS con SwiftUI e Swift. Imposta come destinazione di distribuzione macOS 11 o versioni successive. Sostituisci il punto di ingresso dell’app generato con il primo file riportato sotto, quindi aggiungi il secondo file allo stesso target. Insieme, questi due file formano un programma dimostrativo completo, con etichette sintetiche e senza risorse aggiuntive.

L’esempio usa il ciclo di vita SwiftUI App, disponibile da macOS 11. Mantieni un’unica dichiarazione @main nel target: la documentazione Apple sul ciclo di vita e il riferimento del linguaggio Swift richiedono entrambi un solo punto di ingresso per il programma. App, Attributes

Crea l’elenco esterno e le schede SwiftUI

Il primo file crea otto sezioni. Ogni sezione contiene sedici schede e il proprio documento orizzontale. Il documento esterno tiene traccia della larghezza dell’area visibile e ha un’altezza fissa per la dimostrazione, quindi solo il contenitore esterno deve scorrere verticalmente.

SwiftUI controlla il frame della visualizzazione esterna rappresentata. Il layout personalizzato modifica solo il documento AppKit e i frame delle sezioni al suo interno, rispettando l’avvertenza di Apple di non modificare le proprietà di layout della visualizzazione gestita direttamente da NSViewRepresentable. NSViewRepresentable

Salva il file come 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 coordina la visualizzazione del documento, la visualizzazione di ritaglio e le barre di scorrimento. Qui, il documento esterno contiene le visualizzazioni di scorrimento delle sezioni; il documento di ciascuna sezione è una NSHostingView che contiene le schede SwiftUI. La larghezza di 2444 punti è un calcolo illustrativo del layout: sedici schede da 140, quindici spazi da 12 e 24 punti di margine orizzontale. NSScrollView, NSHostingView

Instrada un gesto una volta e mantienine la destinazione

Il secondo file definisce la politica di instradamento. Un gesto il cui primo movimento diverso da zero è prevalentemente verticale viene assegnato all’elenco esterno. Altrimenti, resta assegnato alla sezione. In caso di parità, la destinazione è la sezione. Queste sono scelte di progettazione esplicite per questa dimostrazione.

Apple fornisce le proprietà separate phase e momentumPhase. Gli eventi legacy della rotellina non hanno nessuna delle due fasi, mentre gli eventi di inerzia non hanno una fase ordinaria del gesto. L’esempio tratta quindi separatamente gli eventi della rotellina senza fase e mantiene la destinazione di un gesto con fase per tutta la sequenza di inerzia. phase, momentumPhase

Salva il file come 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)
        }
    }
}

Il codice legge scrollingDeltaX e scrollingDeltaY solo per scegliere una destinazione. Passa l’evento originale a una sola visualizzazione di scorrimento, lasciando che sia AppKit a eseguire lo scorrimento. Non riconverte il delta in un offset in punti. Questo è importante perché Apple documenta modalità diverse di gestione dei delta precisi e non precisi; per esempio, quando l’applicazione implementa direttamente il movimento, i delta verticali non precisi richiedono una conversione basata sull’altezza delle righe. scrollingDeltaX, scrollingDeltaY

Il riferimento debole verticalOwner punta direttamente alla visualizzazione di scorrimento esterna creata da questo esempio. In questo modo si evita di dipendere dalla gerarchia interna delle visualizzazioni di SwiftUI. L’esempio non aggiunge un monitor di eventi: il suo override gestisce gli eventi recapitati alle visualizzazioni delle sezioni dell’app.

Verifica entrambe le direzioni, l’inerzia e il ridimensionamento

Questo esempio è stato esaminato rispetto alla documentazione API consultata, ma non è stato compilato né eseguito: l’ambiente di ricerca non dispone di un compilatore Swift né di un runtime macOS. Compilalo in Xcode ed esegui le verifiche seguenti prima di adottare la politica di instradamento in un’interfaccia esistente.

Verifica Risultato previsto
Scorri verticalmente con il puntatore sopra una scheda Si sposta l’elenco esterno
Scorri orizzontalmente sopra la prima sezione Si sposta orizzontalmente solo quella sezione
Sposta orizzontalmente un’altra sezione Il suo offset cambia in modo indipendente
Inizia in verticale, poi introduci un movimento diagonale Il gesto resta assegnato all’elenco esterno
Inizia in orizzontale, poi introduci un movimento diagonale Il gesto resta assegnato alla sezione
Lascia che un gesto del trackpad prosegua per inerzia L’inerzia mantiene la destinazione selezionata
Usa la rotellina convenzionale in verticale Ogni evento della rotellina viene assegnato all’elenco esterno
Ridimensiona la finestra Le sezioni seguono la larghezza dell’area visibile esterna

Prova anche l’apertura di gesti con delta zero, l’annullamento, i rapidi cambi di direzione e le barre di scorrimento sia in sovrimpressione sia sempre visibili. Il buffer in ShelfScrollView serve a evitare che un’apertura con delta zero selezioni prematuramente lo scorrimento orizzontale. Il suo comportamento sui dispositivi e sulle versioni di macOS supportati richiede ancora una verifica in esecuzione.

La politica diagonale assegna deliberatamente un’unica destinazione a un gesto con fase. Non promette lo spostamento simultaneo dell’elenco esterno e della sezione. Inoltre, quando una sezione orizzontale raggiunge il bordo, questo esempio continua ad assegnarle il gesto invece di passarlo all’elenco verticale. Se il compito richiede una panoramica diagonale continua, usa un unico documento a due assi.

L’esempio crea subito tutte le schede e usa dimensioni fisse. Considera queste dimensioni come parametri dimostrativi. Per contenuti che cambiano, implementa updateNSView per aggiornare il documento e le visualizzazioni hosting esistenti conservando le rispettive posizioni di scorrimento; Apple documenta questo metodo come il punto in cui applicare nuovo stato SwiftUI a una visualizzazione AppKit rappresentata. NSViewRepresentable

Verifica che lo scorrimento non sia disattivato e controlla il layout del testo

Se nessuna delle due direzioni funziona, controlla i modificatori prima di modificare l’instradamento. Da macOS 13 in poi, scrollDisabled(_:) trasmette la propria impostazione tramite l’ambiente SwiftUI e può influire sia su una visualizzazione di scorrimento SwiftUI esterna sia su quelle annidate. Serve ad abilitare o disabilitare lo scorrimento, non a inoltrarlo tra assi. scrollDisabled(_:)

Se la sezione finale conterrà una visualizzazione di testo AppKit, controlla separatamente la geometria del testo. In TextKit, NSTextContainer.widthTracksTextView stabilisce se la larghezza del contenitore di testo segue quella della visualizzazione di testo. Non sceglie quale visualizzazione di scorrimento riceve un gesto della rotellina. Una modifica al layout del testo può cambiare il modo in cui il contenuto supera orizzontalmente l’area visibile, ma non va presentata come una soluzione di instradamento degli eventi. La dimostrazione con le schede evita questa variabile aggiuntiva. widthTracksTextView

Inizia con una singola visualizzazione di scorrimento SwiftUI a due assi quando l’interfaccia è un unico documento. Per sezioni indipendenti che richiedono un instradamento verticale esplicito, il bridge gestito dall’app definisce la destinazione di ogni gesto mantenendo i contenuti delle schede in SwiftUI. Convalida la politica dei gesti sui Mac e sui dispositivi di input supportati dalla tua app prima di estendere l’esempio a contenuti dinamici.

Fonti