Pour une app Mac avec des rangées horizontales dans une liste verticale, attribuez une destination claire à chaque geste. Commencez par configurer les axes de SwiftUI ; si vous avez besoin d’un routage explicite, gérez les vues de défilement AppKit imbriquées avec un pont SwiftUI.
La tâche concrète consiste ici à parcourir des cartes synthétiques : le défilement vertical au-dessus de n’importe quelle rangée doit déplacer toute la liste, tandis que le défilement horizontal doit déplacer indépendamment cette rangée. Chaque rangée doit donc avoir son propre décalage horizontal. Ce cas diffère d’une seule surface qui se déplace simultanément à l’horizontale et à la verticale.
Choisir la structure de défilement adaptée à la tâche
Apple indique qu’une ScrollView SwiftUI peut défiler horizontalement, verticalement ou sur les deux axes. Pour un grand document unique, utilisez ScrollView([.horizontal, .vertical]) et donnez à son contenu des dimensions qui dépassent celles de la zone visible. C’est la structure la plus simple lorsque tout le contenu partage une seule position horizontale et une seule position verticale. ScrollView
Des rangées indépendantes nécessitent des conteneurs de défilement distincts. La structure SwiftUI naturelle est une ScrollView verticale contenant des instances de ScrollView horizontales, chacune ayant une hauteur limitée. Toutefois, configurer ces axes ne précise pas à lui seul comment un événement reçu au-dessus d’un conteneur interne doit parvenir au conteneur externe. La documentation d’Apple sur ScrollView décrit les axes pris en charge, sans établir de règle générale de routage des événements pour ce cas d’imbrication. ScrollView
| Comportement requis | Point de départ approprié |
|---|---|
| Un document se déplace dans les deux directions | Une ScrollView SwiftUI à deux axes |
| Chaque rangée conserve sa propre position horizontale | Des rangées horizontales imbriquées dans un conteneur vertical |
| Les gestes verticaux au-dessus des rangées nécessitent une destination explicite | Des sous-classes de NSScrollView gérées par l’app et exposées via NSViewRepresentable |
| Un geste diagonal doit déplacer librement tout le document | Un conteneur à deux axes ; AppKit expose également usesPredominantAxisScrolling |
AppKit distingue le contenu qui défile principalement le long d’un axe à la fois de celui, comme une surface, qui doit se déplacer librement dans les deux directions. Sa propriété usesPredominantAxisScrolling exprime cette distinction ; sa valeur par défaut documentée est true. Cette propriété concerne le comportement d’une vue de défilement ; elle ne sélectionne pas une autre vue de défilement imbriquée comme destination de l’événement. usesPredominantAxisScrolling
Comprendre les limites documentées du transfert
Le nom wantsForwardedScrollEvents(for:) peut laisser penser qu’il s’agit d’une solution universelle au défilement imbriqué. Apple documente une condition plus restreinte : les vues qui effectuent un défilement élastique peuvent transférer les événements de geste lorsque le contenu se trouve déjà au bord concerné au début du geste. Cette documentation n’établit pas de transfert inconditionnel des événements de molette ordinaires ni de répartition arbitraire selon l’axe. wantsForwardedScrollEvents(for:)
De même, l’implémentation par défaut de NSResponder.scrollWheel(with:) transmet le message au prochain répondeur, tandis que NSScrollView implémente cette méthode pour effectuer le défilement. Il faut donc distinguer le répondeur qui transmet un événement non traité de celui qui le consomme pour faire défiler le contenu. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)
Pour une interface qui exige un contrôle explicite, les éléments pris en charge sont NSViewRepresentable, qui intègre une vue AppKit dans SwiftUI, et NSHostingView, qui intègre du contenu SwiftUI dans AppKit. L’implémentation ci-dessous utilise ces ponts pour gérer les deux conteneurs de défilement tout en conservant les cartes en SwiftUI. Sa politique de routage est du code applicatif dérivé des API documentées, et non une recette Apple pour le défilement imbriqué. NSViewRepresentable, NSHostingView
Construire un exemple de défilement imbriqué géré par l’app
Créez dans Xcode un projet d’app macOS utilisant SwiftUI et Swift. Définissez macOS 11 ou une version ultérieure comme cible de déploiement. Remplacez le point d’entrée généré de l’app par le premier fichier ci-dessous, puis ajoutez le second fichier à la même cible. Ces deux fichiers constituent un programme de démonstration complet, avec des libellés synthétiques et sans ressources supplémentaires.
L’exemple utilise le cycle de vie App de SwiftUI, disponible à partir de macOS 11. Gardez une seule déclaration @main dans la cible : la documentation d’Apple sur le cycle de vie et la référence du langage Swift en exigent chacune une comme point d’entrée du programme. App, Attributes
Créer la liste externe et les cartes SwiftUI
Le premier fichier crée huit rangées. Chaque rangée contient seize cartes et son propre document horizontal. Le document externe suit la largeur de la zone visible et possède une hauteur fixe pour la démonstration ; seul le conteneur externe doit donc défiler verticalement.
SwiftUI contrôle le cadre de la vue représentée à l’extérieur. La disposition personnalisée ne modifie que les cadres du document AppKit et des rangées qu’il contient, conformément à l’avertissement d’Apple de ne pas modifier les propriétés de disposition de la vue gérée directement par NSViewRepresentable. NSViewRepresentable
Enregistrez ceci sous le nom 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 coordonne sa vue de document, sa vue de découpe et ses barres de défilement. Ici, le document externe contient les vues de défilement des rangées ; le document de chaque rangée est une NSHostingView qui contient les cartes SwiftUI. La largeur de 2444 points est un calcul de mise en page illustratif : seize cartes de 140, quinze espaces de 12 et 24 points de marge horizontale. NSScrollView, NSHostingView
Router un geste une seule fois et conserver sa destination
Le second fichier définit la politique de routage. Un geste dont le premier déplacement non nul est principalement vertical est dirigé vers la liste externe. Sinon, il reste associé à la rangée. En cas d’égalité, la rangée est choisie. Ce sont des choix de conception explicites pour cette démonstration.
Apple fournit des propriétés distinctes phase et momentumPhase. Les événements de molette historiques n’ont aucune phase, tandis que les événements d’inertie n’ont pas de phase de geste ordinaire. L’exemple traite donc séparément les événements de molette sans phase et conserve la destination d’un geste avec phase pendant sa séquence d’inertie. phase, momentumPhase
Enregistrez ceci sous le nom 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)
}
}
}
Le code ne lit scrollingDeltaX et scrollingDeltaY que pour choisir une destination. Il transmet l’événement d’origine à une seule vue de défilement, laissant AppKit effectuer le défilement. Il ne réinterprète pas le delta comme un décalage en points. C’est important, car Apple documente un traitement différent des deltas précis et non précis ; par exemple, les deltas verticaux non précis nécessitent une mise à l’échelle par la hauteur de ligne lorsqu’une application implémente elle-même le déplacement. scrollingDeltaX, scrollingDeltaY
La référence faible verticalOwner pointe directement vers la vue de défilement externe créée par cet exemple. Cela évite de dépendre de la hiérarchie interne des vues de SwiftUI. L’exemple n’ajoute aucun observateur d’événements ; sa redéfinition gère les événements reçus par les vues des rangées de l’app.
Vérifier les deux directions, l’inertie et le redimensionnement
Cet exemple a été examiné à la lumière de la documentation des API consultée, mais il n’a été ni compilé ni exécuté : l’environnement de recherche ne dispose ni d’un compilateur Swift ni d’un environnement d’exécution macOS. Compilez-le dans Xcode et effectuez les vérifications suivantes avant d’adopter cette politique de routage dans une interface existante.
| Vérification | Résultat attendu |
|---|---|
| Faites défiler verticalement avec le pointeur au-dessus d’une carte | La liste externe se déplace |
| Faites défiler horizontalement au-dessus de la première rangée | Seule cette rangée se déplace horizontalement |
| Déplacez une autre rangée horizontalement | Son décalage change indépendamment |
| Commencez verticalement, puis introduisez un mouvement diagonal | Le geste reste associé à la liste externe |
| Commencez horizontalement, puis introduisez un mouvement diagonal | Le geste reste associé à la rangée |
| Relâchez un geste sur le trackpad et laissez l’inertie se poursuivre | L’inertie conserve la destination sélectionnée |
| Utilisez une molette verticale classique | Chaque événement de molette est dirigé vers la liste externe |
| Redimensionnez la fenêtre | Les rangées suivent la largeur de la zone visible externe |
Testez également les débuts de geste sans delta, l’annulation, les changements rapides de direction, ainsi que les barres de défilement superposées et toujours visibles. La mise en mémoire tampon dans ShelfScrollView évite qu’un début de geste sans delta ne sélectionne prématurément le défilement horizontal. Son comportement sur les appareils et les versions de macOS que vous prenez en charge doit encore être vérifié à l’exécution.
La politique diagonale choisit délibérément une seule destination pour un geste avec phase. Elle ne promet pas le déplacement simultané de la liste externe et de la rangée. Au bord d’une rangée horizontale, cet exemple conserve aussi cette rangée comme destination au lieu de transférer le geste à la liste verticale. Si votre tâche exige un panoramique diagonal continu, utilisez plutôt un seul document à deux axes.
L’exemple crée immédiatement toutes les cartes et utilise des dimensions fixes. Considérez ces dimensions comme des paramètres de démonstration. Pour du contenu évolutif, implémentez updateNSView afin de mettre à jour le document existant et les vues d’hébergement tout en conservant leurs positions de défilement ; Apple indique que cette méthode sert à appliquer le nouvel état SwiftUI à une vue AppKit représentée. NSViewRepresentable
Écarter les problèmes de défilement désactivé et de disposition du texte
Si aucune direction ne fonctionne, vérifiez les modificateurs avant de modifier le routage. À partir de macOS 13, scrollDisabled(_:) transmet son paramètre via l’environnement SwiftUI et peut affecter à la fois une vue de défilement SwiftUI externe et les vues imbriquées. Il s’agit d’un contrôle d’activation du défilement, et non d’un mécanisme de transfert selon l’axe. scrollDisabled(_:)
Si votre rangée finit par contenir une vue de texte AppKit, vérifiez séparément la géométrie du texte. widthTracksTextView de TextKit détermine si la largeur du conteneur de texte suit celle de la vue de texte. Cette propriété ne choisit pas la vue de défilement qui reçoit un geste de molette. Une modification de la disposition du texte peut déterminer si le contenu déborde horizontalement, mais elle ne doit pas être présentée comme une solution de routage des événements. La démonstration avec des cartes évite cette variable supplémentaire. widthTracksTextView
Commencez par une seule vue de défilement SwiftUI à deux axes lorsque l’interface constitue un seul document. Pour des rangées indépendantes qui nécessitent un routage vertical explicite, le pont géré par l’app définit la destination de chaque geste tout en conservant le contenu des cartes en SwiftUI. Vérifiez la politique de gestion des gestes sur les Mac et les appareils de saisie pris en charge par votre app avant d’adapter l’exemple à du contenu dynamique.
Sources
- 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