對於在垂直列表中包含水平架列的 Mac app,請為每個手勢指定明確的目的地。先從 SwiftUI 的軸向設定著手;如果需要明確路由,請透過 SwiftUI 橋接來管理巢狀 AppKit 捲動檢視。

這裡的具體任務是瀏覽合成卡片:在任何架列上垂直捲動時,應移動整個列表;水平捲動時,則應獨立移動該架列。這需要為每一列設定各自的水平偏移量。這不同於水平與垂直方向一同移動的單一畫布。

選擇符合任務需求的捲動結構

Apple 說明 SwiftUI 的 ScrollView 可以沿水平方向、垂直方向或兩個軸向捲動。對於一份大型文件,請使用 ScrollView([.horizontal, .vertical]),並讓其內容尺寸超出視窗範圍。當所有內容共用同一個水平位置和垂直位置時,這是最簡單的結構。ScrollView

獨立架列需要個別的捲動容器。自然的 SwiftUI 結構是讓垂直 ScrollView 包含水平 ScrollView 執行個體,且每個執行個體都有界定的高度。不過,設定這些軸向本身並未指定內層容器收到的事件應如何傳遞至外層容器。Apple 的 ScrollView 文件說明了支援的軸向,但沒有訂定這種巢狀情況的一般事件路由規則。ScrollView

所需行為 適合的起始做法
一份文件可往兩個方向移動 一個雙軸 SwiftUI ScrollView
每個架列保留自己的水平位置 在垂直容器內巢狀放置水平架列
在架列上進行的垂直手勢需要明確目的地 透過 NSViewRepresentable 呈現由 app 管理的 NSScrollView 子類別
對角手勢必須讓整份文件自由移動 一個雙軸容器;AppKit 也提供 usesPredominantAxisScrolling

AppKit 區分主要一次沿單一軸向捲動的內容,以及應能沿兩個軸向自由移動的內容,例如畫布。其 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 撰寫的 app 程式碼,而非 Apple 提供的巢狀捲動範例。NSViewRepresentable、NSHostingView

建立由 app 管理的巢狀捲動範例

在 Xcode 中建立 macOS app 專案,使用 SwiftUI 和 Swift。將部署目標設為 macOS 11 或更新版本。以第一個檔案取代產生的 app 進入點,再將第二個檔案加入同一個目標。這兩個檔案合起來就是一個完整的示範程式,使用合成標籤,無需其他資產。

範例使用 macOS 11 起提供的 SwiftUI App 生命週期。目標中只保留一個 @main 宣告:Apple 的生命週期文件和 Swift 語言參考都要求程式只能有一個進入點。App、Attributes

建立外層列表和 SwiftUI 卡片

第一個檔案會建立八個架列。每個架列有十六張卡片,並有自己的水平文件。外層文件會追蹤視窗的寬度,並使用固定的示範高度,因此只有外層容器需要垂直捲動。

SwiftUI 會控制所呈現外層檢視的框架。自訂配置只會變更其中的 AppKit 文件和架列框架,遵守 Apple 的提醒:不要變更由 NSViewRepresentable 直接管理之檢視的配置屬性。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 的卡片、十五個 12 的間距,以及 24 點的水平內距。NSScrollView、NSHostingView

將手勢路由一次並保留其目的地

第二個檔案提供路由政策。首次出現非零移動時,若以垂直方向為主,手勢就交由外層列表處理;否則,手勢會留在架列。若兩個方向相同,則由架列處理。這些是本示範明確採用的設計選擇。

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 內部的檢視階層。範例不會新增事件監聽器;它的覆寫只會處理傳送至 app 架列檢視的事件。

檢查兩個方向、慣性和調整大小

本範例已對照取回的 API 文件進行檢視,但尚未編譯或執行:研究環境沒有 Swift 編譯器或 macOS 執行階段。請在 Xcode 中建置,並在既有介面採用此路由政策前完成以下檢查。

檢查項目 預期結果
指標停在卡片上方時垂直捲動 外層列表移動
在第一個架列上水平捲動 只有該架列水平移動
另一個架列水平移動 其偏移量獨立變更
開始時垂直移動,接著加入對角移動 手勢仍指派給外層列表
開始時水平移動,接著加入對角移動 手勢仍指派給架列
觸控式軌跡板手勢放開後進入慣性階段 慣性階段保留選定的目的地
使用傳統垂直捲軸 每個捲軸事件都路由至外層列表
調整視窗大小 架列會配合外層視窗寬度

另外也請測試零位移的手勢起始、取消、快速變換方向,以及覆疊式和永遠顯示的捲軸。ShelfScrollView 中的緩衝機制是為了避免零位移的起始事件過早選擇水平捲動。仍需在你支援的裝置和 macOS 版本上實際驗證其行為。

對角移動政策會刻意為含階段資訊的手勢選定一個目的地。它不保證外層列表和架列會同時移動。在水平架列的末端,範例也會繼續將該架列保留為目的地,而不會把手勢交給垂直列表。如果任務需要連續的對角平移,請改用單一雙軸文件。

範例會立即建立所有卡片,並使用固定尺寸。請將這些尺寸視為示範用的輸入值。若內容會變動,請實作 updateNSView,在保留捲動位置的同時更新現有的文件和託管檢視;Apple 文件指出,此方法是將新的 SwiftUI 狀態套用至所呈現 AppKit 檢視的位置。NSViewRepresentable

排除停用捲動和文字配置問題

若兩個方向都無法捲動,請先檢查修飾器,再調整路由。在 macOS 13 和更新版本中,scrollDisabled(_:) 會透過 SwiftUI 環境傳遞其設定,並可能影響外層 SwiftUI 捲動檢視和巢狀捲動檢視。它是控制是否啟用捲動的機制,而不是軸向轉送機制。scrollDisabled(_:)

若你的架列最後包含 AppKit 文字檢視,請另外檢查文字幾何設定。TextKit 的 NSTextContainer.widthTracksTextView 控制文字容器的寬度是否隨文字檢視寬度變動。它不會決定哪個捲動檢視接收捲軸手勢。文字配置變更可能會改變內容是否超出寬度,但不應被說成是事件路由的修正方法。卡片範例避免了這項額外變數。widthTracksTextView

如果介面是一份文件,請從單一雙軸 SwiftUI 捲動檢視開始。若是需要明確垂直路由的獨立架列,由 app 管理的橋接能為每個手勢指定明確目的地,同時保留 SwiftUI 卡片內容。將範例擴充至動態內容前,請先在 app 支援的 Mac 和輸入裝置上驗證手勢政策。

來源