Para una app de Mac con estantes horizontales dentro de una lista vertical, asigna un destino claro a cada gesto. Empieza con la configuración de ejes de SwiftUI; si necesitas un enrutamiento explícito, controla las vistas de desplazamiento anidadas de AppKit mediante un puente de SwiftUI.

La tarea concreta consiste en explorar tarjetas sintéticas: desplazarse verticalmente sobre cualquier estante debería mover toda la lista, mientras que desplazarse horizontalmente debería mover ese estante de forma independiente. Esto requiere desplazamientos horizontales separados para cada fila. Es distinto de un único lienzo que se mueve horizontal y verticalmente a la vez.

Elige la estructura de desplazamiento adecuada para la tarea

Apple documenta que una ScrollView de SwiftUI puede desplazarse horizontalmente, verticalmente o en ambos ejes. Para un documento grande, usa ScrollView([.horizontal, .vertical]) y asigna a su contenido dimensiones que superen las del área visible. Esta es la estructura más sencilla cuando todo el contenido comparte una posición horizontal y otra vertical. ScrollView

Los estantes independientes requieren contenedores de desplazamiento separados. La estructura natural de SwiftUI es una ScrollView vertical que contiene instancias de ScrollView horizontales, cada una con una altura limitada. Sin embargo, configurar esos ejes no especifica por sí solo cómo debe llegar un evento recibido sobre un contenedor interno al contenedor externo. La documentación de Apple sobre ScrollView describe los ejes compatibles, pero no establece un contrato general de enrutamiento de eventos para este caso anidado. ScrollView

Comportamiento requerido Punto de partida adecuado
Un documento se mueve en ambas direcciones Una ScrollView de SwiftUI con dos ejes
Cada estante conserva su propia posición horizontal Estantes horizontales anidados dentro de un contenedor vertical
Los gestos verticales sobre los estantes necesitan un destino explícito Subclases de NSScrollView controladas por la app y expuestas mediante NSViewRepresentable
Un gesto diagonal debe mover libremente todo el documento Un contenedor de dos ejes; AppKit también expone usesPredominantAxisScrolling

AppKit distingue el contenido que se desplaza principalmente a lo largo de un eje cada vez del contenido, como un lienzo, que debería moverse libremente en ambos ejes. La propiedad usesPredominantAxisScrolling expresa esa distinción; su valor predeterminado documentado es true. Esa propiedad se refiere al comportamiento de una vista de desplazamiento, no a la selección de otra vista de desplazamiento anidada como destino del evento. usesPredominantAxisScrolling

Comprende los límites documentados de la propagación

El nombre wantsForwardedScrollEvents(for:) puede sugerir una solución universal para el desplazamiento anidado. Apple documenta una condición más acotada: las vistas que realizan desplazamiento elástico pueden propagar eventos de gesto cuando el contenido ya está en el borde pertinente al comienzo del gesto. Esa documentación no establece la propagación incondicional de eventos normales de rueda ni la división arbitraria por ejes. wantsForwardedScrollEvents(for:)

De forma similar, la implementación predeterminada de NSResponder.scrollWheel(with:) pasa el mensaje al siguiente respondedor, mientras que NSScrollView implementa el método para realizar el desplazamiento. Por tanto, debes distinguir entre un respondedor que pasa un evento sin gestionar al siguiente y un contenedor de desplazamiento que lo consume. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)

Para una interfaz en la que necesitas control explícito, los componentes compatibles son NSViewRepresentable, que integra una vista de AppKit en SwiftUI, y NSHostingView, que integra contenido de SwiftUI en AppKit. La implementación que sigue usa esos puentes para controlar ambos contenedores de desplazamiento y mantener las tarjetas en SwiftUI. Su política de enrutamiento es código de la aplicación derivado de las API documentadas, no una receta de Apple para el desplazamiento anidado. NSViewRepresentable, NSHostingView

Crea un ejemplo de desplazamiento anidado controlado por la app

Crea en Xcode un proyecto de app para macOS con SwiftUI y Swift. Establece macOS 11 o posterior como destino de implementación. Sustituye la entrada de app generada por el primer archivo que aparece abajo y, luego, añade el segundo archivo al mismo destino. En conjunto, estos dos archivos forman un programa de demostración completo con etiquetas sintéticas y sin recursos adicionales.

El ejemplo usa el ciclo de vida App de SwiftUI, disponible desde macOS 11. Mantén exactamente una declaración @main en el destino: tanto la documentación de Apple sobre el ciclo de vida como la referencia del lenguaje Swift exigen un único punto de entrada del programa. App, Attributes

Crea la lista externa y las tarjetas de SwiftUI

El primer archivo crea ocho estantes. Cada estante tiene dieciséis tarjetas y su propio documento horizontal. El documento externo sigue el ancho del área visible y tiene una altura de demostración fija, por lo que solo el contenedor externo necesita desplazamiento vertical.

SwiftUI controla el marco de la vista representada. El diseño personalizado solo modifica el documento de AppKit y los marcos de los estantes que contiene, respetando la advertencia de Apple de no modificar las propiedades de diseño de la vista gestionada directamente por NSViewRepresentable. NSViewRepresentable

Guarda esto como 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 su vista de documento, su vista de recorte y sus barras de desplazamiento. Aquí, el documento externo contiene las vistas de desplazamiento de los estantes; el documento de cada estante es una NSHostingView que contiene las tarjetas de SwiftUI. El ancho de 2444 puntos es un cálculo ilustrativo del diseño: dieciséis tarjetas de 140, quince espacios de 12 y 24 puntos de margen horizontal. NSScrollView, NSHostingView

Enruta cada gesto una sola vez y conserva su destino

El segundo archivo define la política de enrutamiento. Un gesto cuyo primer movimiento distinto de cero es predominantemente vertical se dirige a la lista externa. De lo contrario, permanece en el estante. Si hay empate, el gesto se asigna al estante. Estas son decisiones de diseño explícitas para esta demostración.

Apple proporciona las propiedades independientes phase y momentumPhase. Los eventos de rueda heredados no tienen fase, mientras que los eventos de inercia no tienen una fase de gesto normal. Por eso, el ejemplo trata por separado los eventos de rueda sin fase y conserva el destino de un gesto con fase durante su secuencia de inercia. phase, momentumPhase

Guarda esto como 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)
        }
    }
}

El código solo consulta scrollingDeltaX y scrollingDeltaY para elegir un destino. Pasa el evento original a una vista de desplazamiento, lo que permite que AppKit realice el desplazamiento. No reinterpreta el delta como un desplazamiento en puntos. Esto importa porque Apple documenta un tratamiento distinto para los deltas precisos y los no precisos; por ejemplo, los deltas verticales no precisos requieren escalado según la altura de línea cuando la aplicación implementa el movimiento por su cuenta. scrollingDeltaX, scrollingDeltaY

La referencia débil verticalOwner apunta directamente a la vista de desplazamiento externa creada por este ejemplo. Así se evita depender de la jerarquía interna de vistas de SwiftUI. El ejemplo no añade un monitor de eventos; su sobreescritura gestiona los eventos que reciben las vistas de estante de la app.

Comprueba ambas direcciones, la inercia y el redimensionamiento

Este ejemplo se revisó contrastándolo con la documentación de API consultada, pero no se compiló ni se ejecutó: el entorno de investigación no tiene un compilador de Swift ni un entorno de ejecución de macOS. Compílalo en Xcode y realiza las siguientes comprobaciones antes de adoptar esta política de enrutamiento en una interfaz existente.

Comprobación Resultado esperado
Desplazarse verticalmente con el puntero sobre una tarjeta Se mueve la lista externa
Desplazarse horizontalmente sobre el primer estante Solo ese estante se mueve horizontalmente
Desplazar horizontalmente otro estante Su desplazamiento cambia de forma independiente
Empezar en vertical y luego introducir movimiento diagonal El gesto sigue asignado a la lista externa
Empezar en horizontal y luego introducir movimiento diagonal El gesto sigue asignado al estante
Soltar un gesto del trackpad para que continúe por inercia La inercia conserva el destino seleccionado
Usar una rueda convencional en vertical Cada evento de rueda se enruta a la lista externa
Redimensionar la ventana Los estantes siguen el ancho del área visible externa

Prueba también los inicios de gesto con delta cero, la cancelación, los cambios rápidos de dirección y las barras de desplazamiento superpuestas y siempre visibles. El almacenamiento temporal en ShelfScrollView permite que una apertura con delta cero no seleccione prematuramente el desplazamiento horizontal. Aún hay que verificar su comportamiento en tiempo de ejecución con los dispositivos y las versiones de macOS que admitas.

La política diagonal asigna deliberadamente un destino a cada gesto con fase. No promete que la lista externa y el estante se muevan a la vez. Al llegar al final de un estante horizontal, el ejemplo también conserva ese estante como destino en lugar de transferir el gesto a la lista vertical. Si tu tarea requiere un desplazamiento diagonal continuo, usa un único documento de dos ejes.

El ejemplo crea todas las tarjetas de inmediato y usa dimensiones fijas. Considera esas dimensiones como valores de demostración. Para contenido cambiante, implementa updateNSView para actualizar el documento existente y las vistas de alojamiento, conservando sus posiciones de desplazamiento; Apple documenta ese método como el lugar donde se aplica el nuevo estado de SwiftUI a una vista de AppKit representada. NSViewRepresentable

Descarta que el desplazamiento esté desactivado y los problemas de diseño de texto

Si ninguna dirección funciona, revisa los modificadores antes de cambiar el enrutamiento. En macOS 13 y versiones posteriores, scrollDisabled(_:) transmite su configuración a través del entorno de SwiftUI y puede afectar tanto a una vista de desplazamiento externa de SwiftUI como a las anidadas. Es un control para habilitar el desplazamiento, no un mecanismo de propagación entre ejes. scrollDisabled(_:)

Si tu estante termina por contener una vista de texto de AppKit, comprueba por separado la geometría del texto. NSTextContainer.widthTracksTextView de TextKit controla si el ancho del contenedor de texto sigue el ancho de la vista de texto. No determina qué vista de desplazamiento recibe un gesto de rueda. Un cambio en el diseño del texto puede alterar si el contenido se desborda horizontalmente, pero no debe presentarse como una solución de enrutamiento de eventos. La demostración de tarjetas evita esa variable adicional. widthTracksTextView

Empieza con una única vista de desplazamiento de dos ejes de SwiftUI si la interfaz es un solo documento. Para estantes independientes que necesitan un enrutamiento vertical explícito, el puente controlado por la app define el destino de cada gesto y conserva el contenido de las tarjetas en SwiftUI. Valida la política de gestos en los Mac y con los dispositivos de entrada que admita tu app antes de ampliar el ejemplo para usar contenido dinámico.

Fuentes