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
- ScrollView — Apple
- usesPredominantAxisScrolling — Apple
- wantsForwardedScrollEvents(for:) — Apple
- scrollWheel(with:) — NSResponder — Apple
- scrollWheel(with:) — NSScrollView — Apple
- NSViewRepresentable — Apple
- NSHostingView — Apple
- NSScrollView — Apple
- App — Apple
- Attributes — Swift.org
- phase — Apple
- momentumPhase — Apple
- scrollingDeltaX — Apple
- scrollingDeltaY — Apple
- scrollDisabled(_:) — Apple
- widthTracksTextView — Apple