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