縦方向のリスト内に横方向の棚がある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と入力デバイスでジェスチャの方針を検証してください。
参考資料
- 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