縦方向のリスト内に横方向の棚があるMacアプリでは、それぞれのジェスチャの移動先を明確にします。まずSwiftUIの軸設定を使います。明示的な振り分けが必要なら、SwiftUIブリッジを介して入れ子のAppKitスクロールビューを管理します。

ここで扱う具体的なタスクは、合成カードの閲覧です。どの棚の上で縦スクロールしてもリスト全体が動き、横スクロールするとその棚だけが独立して動く必要があります。そのため、各行に個別の横方向オフセットが必要です。これは、単一のキャンバスが横方向と縦方向に一体となって動く場合とは異なります。

タスクに合ったスクロール構造を選ぶ

Appleのドキュメントによると、SwiftUIのScrollViewは横方向、縦方向、または両方の軸でスクロールできます。大きなドキュメントを1つ表示する場合は、ScrollView([.horizontal, .vertical])を使い、ビューポートを超えるサイズのコンテンツを指定します。すべてのコンテンツが横方向と縦方向それぞれ1つの位置を共有する場合に、最も単純な構造です。ScrollView

棚をそれぞれ独立させるには、個別のスクロールコンテナが必要です。自然なSwiftUIの構造は、縦方向のScrollViewの中に、高さを制限した横方向のScrollViewを複数配置することです。ただし、軸を設定しただけでは、内側のコンテナ上で受け取ったイベントを外側のコンテナへどう届けるかは決まりません。AppleのScrollViewのドキュメントはサポートする軸を説明していますが、この入れ子のケースにおける一般的なイベント振り分けの仕様は定めていません。ScrollView

必要な動作 適切な出発点
1つのドキュメントを両方向に動かす 2軸対応のSwiftUI ScrollViewを1つ使う
各棚の横方向位置を個別に保持する 縦方向のコンテナ内に横方向の棚を入れ子にする
棚の上での縦ジェスチャに明示的な移動先が必要 NSViewRepresentableを通じてアプリが所有するNSScrollViewのサブクラスを使う
斜めジェスチャでドキュメント全体を自由に動かす 2軸対応コンテナを1つ使う。AppKitにはusesPredominantAxisScrollingもある

AppKitは、主に一度に1つの軸に沿ってスクロールするコンテンツと、キャンバスのように両軸に沿って自由に動かすコンテンツを区別しています。その違いはusesPredominantAxisScrollingプロパティで表されます。ドキュメントに記載された既定値はtrueです。このプロパティが定めるのはスクロールビューの動作であり、イベントの移動先として別の入れ子スクロールビューを選ぶことではありません。usesPredominantAxisScrolling

ドキュメントに記載された転送範囲を理解する

wantsForwardedScrollEvents(for:)という名前から、あらゆる入れ子スクロールの問題を解決するように思えるかもしれません。Appleのドキュメントに記載されている条件はもっと限定的です。弾性スクロールを行うビューは、ジェスチャの開始時点ですでにコンテンツが該当する端に達している場合、ジェスチャイベントを転送できます。この説明は、通常のホイールイベントや任意の軸分割が無条件に転送されることを定めていません。wantsForwardedScrollEvents(for:)

同様に、既定のNSResponder.scrollWheel(with:)実装はメッセージを次のレスポンダに渡します。一方、NSScrollViewはスクロールを実行するためにこのメソッドを実装しています。そのため、レスポンダが未処理イベントを次へ渡すことと、スクロールコンテナがイベントを処理することは区別してください。NSResponder scrollWheel(with:)、NSScrollView scrollWheel(with:)

インターフェイスを明示的に制御する必要がある場合に使える仕組みとして、AppKitビューをSwiftUIに埋め込むNSViewRepresentableと、SwiftUIコンテンツをAppKitに埋め込むNSHostingViewがあります。以下の実装ではこれらのブリッジを使って両方のスクロールコンテナを管理し、カードはSwiftUIのままにします。この振り分け方針は、ドキュメント化されたAPIをもとにアプリのコードで定めるものであり、Appleが提供する入れ子スクロールの手順ではありません。NSViewRepresentable、NSHostingView

アプリが管理する入れ子スクロールの例を作る

SwiftUIとSwiftを使ってXcodeでmacOSアプリプロジェクトを作成します。デプロイメントターゲットをmacOS 11以降に設定します。生成されたアプリのエントリポイントを下の1つ目のファイルに置き換え、2つ目のファイルを同じターゲットに追加します。この2つのファイルで、合成ラベルを使った完全なデモプログラムができ、追加のアセットは不要です。

このサンプルはmacOS 11から利用できるSwiftUIのAppライフサイクルを使います。ターゲット内の@main宣言は必ず1つにしてください。AppleのライフサイクルドキュメントとSwiftの言語リファレンスは、プログラムのエントリポイントを1つと定めています。App、Attributes

外側のリストとSwiftUIカードを作る

1つ目のファイルでは、8つの棚を作ります。各棚には16枚のカードがあり、それぞれが個別の横方向ドキュメントを持ちます。外側のドキュメントはビューポート幅を追跡し、デモ用の固定の高さを持つため、縦方向にスクロールする必要があるのは外側のコンテナだけです。

SwiftUIが、表現対象である外側ビューのフレームを制御します。カスタムレイアウトが変更するのは、その内部にあるAppKitのドキュメントと棚のフレームだけです。これにより、NSViewRepresentableが直接管理するビューのレイアウトプロパティを変更しないようにというAppleの注意に沿っています。NSViewRepresentable

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は、ドキュメントビュー、クリッピングビュー、スクローラを連携させます。ここでは外側のドキュメントに棚のスクロールビューが含まれ、各棚のドキュメントはSwiftUIカードを格納したNSHostingViewです。2444ポイントという幅は、レイアウト計算を説明するためのものです。140のカード16枚、12の間隔15個、そして左右のパディング24ポイントから算出しています。NSScrollView、NSHostingView

ジェスチャを1度だけ振り分け、移動先を保持する

2つ目のファイルで振り分け方針を定義します。最初のゼロ以外の動きが主に縦方向なら、外側のリストに送ります。それ以外は棚に送ります。同程度の場合は棚が優先されます。これらは、このデモのために明示的に選んだ設計方針です。

AppleはphaseとmomentumPhaseという別々のプロパティを用意しています。従来のホイールイベントにはどちらのフェーズもなく、モーメンタムイベントには通常のジェスチャフェーズがありません。そのためこのサンプルでは、フェーズのないホイールイベントを個別に扱い、フェーズのあるジェスチャはモーメンタムシーケンスの間も移動先を保持します。phase、momentumPhase

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)
        }
    }
}

このコードは移動先を選ぶためだけにscrollingDeltaXとscrollingDeltaYを読み取ります。元のイベントを片方のスクロールビューに渡し、スクロール自体はAppKitに実行させます。デルタをポイント単位のオフセットとして再解釈することはありません。Appleは精密デルタと非精密デルタで処理が異なると説明しています。たとえば、アプリが動きを独自に実装する場合、非精密な縦方向デルタには行の高さに応じた倍率が必要です。scrollingDeltaX、scrollingDeltaY

弱参照のverticalOwnerは、この例で作成した外側のスクロールビューを直接参照します。これにより、SwiftUI内部のビュー階層に依存せずに済みます。このサンプルはイベントモニタを追加せず、棚のビューに届いたイベントをオーバーライドで処理します。

両方向の動き、モーメンタム、リサイズを確認する

この例は取得したAPIドキュメントと照合しましたが、調査環境にSwiftコンパイラもmacOSランタイムもないため、ビルドも実行もしていません。既存のインターフェイスにこの振り分け方針を採用する前に、Xcodeでビルドし、次の項目を確認してください。

確認項目 想定される結果
ポインタをカード上に置いて縦スクロールする 外側のリストが動く
1つ目の棚の上で横スクロールする その棚だけが横方向に動く
別の棚を横方向に動かす その棚のオフセットが独立して変わる
縦方向に動き始めてから斜めの動きを加える ジェスチャは外側のリストに割り当てられたままになる
横方向に動き始めてから斜めの動きを加える ジェスチャは棚に割り当てられたままになる
トラックパッドのジェスチャを離し、モーメンタムに移る 選択された移動先がモーメンタム中も維持される
一般的な縦方向ホイールを使う ホイールイベントごとに外側のリストへ振り分けられる
ウィンドウのサイズを変更する 棚が外側のビューポート幅に追従する

デルタがゼロのジェスチャ開始、キャンセル、急な方向変更、オーバーレイと常時表示の両方のスクローラでも確認してください。ShelfScrollViewのバッファリングは、デルタがゼロの開始イベントによって横スクロールが早まって選択されるのを防ぐためにあります。サポート対象のデバイスやmacOSバージョンでの動作は、実行して確認する必要があります。

この斜め移動の方針では、フェーズのあるジェスチャに対して移動先を1つだけ選びます。外側のリストと棚が同時に動くことは保証しません。また、このサンプルでは横方向の棚の端に達しても、その棚が移動先として維持され、縦方向のリストには引き継がれません。斜め方向への連続的なパンが必要なら、2軸対応のドキュメントを1つ使ってください。

この例ではすべてのカードを即座に作成し、固定サイズを使います。これらのサイズはデモ用の値として扱ってください。コンテンツが変化する場合は、updateNSViewを実装して既存のドキュメントとホスティングビューを更新しつつ、スクロール位置を維持します。Appleはこのメソッドを、表現対象のAppKitビューに新しいSwiftUI状態を適用する場所として説明しています。NSViewRepresentable

スクロール無効化とテキストレイアウトの問題を切り分ける

どちらの方向にも動かない場合は、振り分けを変える前にモディファイアを確認してください。macOS 13以降では、scrollDisabled(_:)の設定はSwiftUIの環境を通じて適用され、外側のSwiftUIスクロールビューと入れ子のスクロールビューの両方に影響する場合があります。これはスクロールの有効・無効を切り替えるもので、軸を転送する仕組みではありません。scrollDisabled(_:)

最終的な棚にAppKitのテキストビューを含める場合は、テキストのジオメトリを別途確認してください。TextKitのNSTextContainer.widthTracksTextViewは、テキストコンテナの幅をテキストビューの幅に追従させるかどうかを制御します。ホイールジェスチャの受け取り先となるスクロールビューは選びません。テキストレイアウトを変えるとコンテンツが横方向にはみ出すかどうかは変わりますが、イベント振り分けの修正として説明すべきではありません。カードのデモでは、この追加要因を避けています。widthTracksTextView

インターフェイスが1つのドキュメントなら、まず2軸対応のSwiftUIスクロールビューを1つ使います。独立した棚で縦方向の移動先を明示する必要がある場合は、アプリが管理するブリッジを使うと、SwiftUIのカードコンテンツを保ちながら各ジェスチャの移動先を定められます。この例を動的コンテンツに拡張する前に、アプリがサポートするMacと入力デバイスでジェスチャの方針を検証してください。

参考資料