Untuk app Mac dengan deretan horizontal di dalam daftar vertikal, tetapkan tujuan yang jelas untuk setiap gestur. Mulailah dengan konfigurasi sumbu SwiftUI; jika Anda memerlukan perutean eksplisit, kelola tampilan gulir AppKit bertingkat melalui jembatan SwiftUI.

Tugas konkretnya adalah menelusuri kartu sintetis: menggulir secara vertikal di atas deretan mana pun harus menggerakkan seluruh daftar, sedangkan menggulir secara horizontal harus menggerakkan deretan tersebut secara mandiri. Ini memerlukan offset horizontal terpisah untuk setiap baris. Ini berbeda dari satu kanvas yang bergerak bersama secara horizontal dan vertikal.

Pilih struktur pengguliran yang sesuai dengan tugas

Apple mendokumentasikan bahwa ScrollView SwiftUI dapat menggulir secara horizontal, vertikal, atau pada kedua sumbu. Untuk satu dokumen besar, gunakan ScrollView([.horizontal, .vertical]) dan berikan dimensi konten yang melebihi area pandang. Ini adalah struktur paling sederhana ketika semua konten berbagi satu posisi horizontal dan satu posisi vertikal. ScrollView

Deretan mandiri memerlukan wadah gulir terpisah. Struktur SwiftUI yang alami adalah ScrollView vertikal yang berisi instance ScrollView horizontal, masing-masing dengan tinggi terbatas. Namun, mengonfigurasi sumbu tersebut tidak dengan sendirinya menentukan bagaimana peristiwa yang diterima di atas wadah bagian dalam diteruskan ke wadah luar. Dokumentasi ScrollView Apple menjelaskan sumbu yang didukung tanpa menetapkan kontrak perutean peristiwa umum untuk kasus bertingkat ini. ScrollView

Perilaku yang diperlukan Titik awal yang sesuai
Satu dokumen bergerak ke dua arah Satu ScrollView SwiftUI dua sumbu
Setiap deretan mempertahankan posisi horizontalnya sendiri Deretan horizontal bertingkat di dalam wadah vertikal
Gestur vertikal di atas deretan memerlukan tujuan eksplisit Subkelas NSScrollView yang dikelola app dan diekspos melalui NSViewRepresentable
Gestur diagonal harus menggerakkan seluruh dokumen dengan bebas Satu wadah dua sumbu; AppKit juga menyediakan usesPredominantAxisScrolling

AppKit membedakan konten yang terutama menggulir sepanjang satu sumbu pada satu waktu dari konten, seperti kanvas, yang seharusnya bergerak bebas pada kedua sumbu. Properti usesPredominantAxisScrolling menyatakan perbedaan tersebut; nilai default yang didokumentasikan adalah true. Properti itu berkaitan dengan perilaku sebuah tampilan gulir, bukan memilih tampilan gulir bertingkat lain sebagai tujuan peristiwa. usesPredominantAxisScrolling

Pahami batas penerusan yang didokumentasikan

Nama wantsForwardedScrollEvents(for:) dapat memberi kesan bahwa ini adalah perbaikan universal untuk pengguliran bertingkat. Apple mendokumentasikan kondisi yang lebih terbatas: tampilan yang melakukan pengguliran elastis dapat meneruskan peristiwa gestur ketika konten sudah berada di tepi yang relevan saat gestur dimulai. Dokumentasi tersebut tidak menetapkan penerusan tanpa syarat untuk peristiwa roda gulir biasa atau pemisahan sumbu sembarang. wantsForwardedScrollEvents(for:)

Demikian pula, implementasi default NSResponder.scrollWheel(with:) meneruskan pesan ke responder berikutnya, sedangkan NSScrollView mengimplementasikan metode tersebut untuk melakukan pengguliran. Karena itu, bedakan responder yang meneruskan peristiwa yang tidak ditangani dengan wadah gulir yang mengonsumsi peristiwa. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)

Untuk antarmuka yang memerlukan kontrol eksplisit, blok pembangun yang didukung adalah NSViewRepresentable, yang menyematkan tampilan AppKit di SwiftUI, dan NSHostingView, yang menyematkan konten SwiftUI di AppKit. Implementasi di bawah menggunakan jembatan tersebut untuk mengelola kedua wadah gulir sambil mempertahankan kartu dalam SwiftUI. Kebijakan peruteannya adalah kode aplikasi yang didasarkan pada API terdokumentasi, bukan resep pengguliran bertingkat yang disediakan Apple. NSViewRepresentable, NSHostingView

Buat contoh pengguliran bertingkat yang dikelola app

Buat proyek app macOS di Xcode menggunakan SwiftUI dan Swift. Tetapkan target deployment ke macOS 11 atau versi lebih baru. Ganti entri app yang dibuat secara otomatis dengan file pertama di bawah, lalu tambahkan file kedua ke target yang sama. Kedua file ini membentuk satu program demonstrasi lengkap dengan label sintetis dan tanpa aset tambahan.

Contoh ini menggunakan siklus hidup App SwiftUI, yang tersedia mulai macOS 11. Pertahankan tepat satu deklarasi @main di target: dokumentasi siklus hidup Apple dan referensi bahasa Swift sama-sama mewajibkan satu titik masuk program. App, Attributes

Buat daftar luar dan kartu SwiftUI

File pertama membuat delapan deretan. Setiap deretan memiliki enam belas kartu dan dokumen horizontalnya sendiri. Dokumen luar melacak lebar area pandang dan memiliki tinggi demonstrasi tetap, sehingga hanya wadah luar yang perlu menggulir secara vertikal.

SwiftUI mengontrol bingkai tampilan luar yang direpresentasikan. Tata letak khusus hanya mengubah dokumen AppKit dan bingkai deretan di dalamnya, sesuai dengan peringatan Apple agar tidak mengubah properti tata letak tampilan yang dikelola langsung oleh NSViewRepresentable. NSViewRepresentable

Simpan sebagai 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 mengoordinasikan tampilan dokumen, tampilan kliping, dan bilah gulirnya. Di sini, dokumen luar berisi tampilan gulir deretan; dokumen setiap deretan adalah NSHostingView yang berisi kartu SwiftUI. Lebar 2444 poin merupakan perhitungan tata letak ilustratif: enam belas kartu masing-masing berukuran 140, lima belas jarak masing-masing berukuran 12, dan 24 poin padding horizontal. NSScrollView, NSHostingView

Rutekan gestur sekali dan pertahankan tujuannya

File kedua menyediakan kebijakan perutean. Gestur yang gerakan awal nonnolnya terutama vertikal diarahkan ke daftar luar. Jika tidak, gestur tetap diarahkan ke deretan. Jika seimbang, tujuannya adalah deretan. Ini adalah pilihan desain eksplisit untuk demonstrasi ini.

Apple menyediakan properti phase dan momentumPhase secara terpisah. Peristiwa roda gulir lama tidak memiliki fase apa pun, sedangkan peristiwa momentum tidak memiliki fase gestur biasa. Karena itu, contoh ini menangani peristiwa roda gulir tanpa fase secara terpisah dan mempertahankan tujuan gestur berfase sepanjang rangkaian momentumnya. phase, momentumPhase

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

Kode membaca scrollingDeltaX dan scrollingDeltaY hanya untuk memilih tujuan. Kode meneruskan peristiwa asli ke satu tampilan gulir agar AppKit melakukan pengguliran. Delta tidak ditafsirkan ulang sebagai offset titik. Ini penting karena Apple mendokumentasikan penanganan delta presisi dan nonpresisi yang berbeda; misalnya, delta vertikal nonpresisi memerlukan penskalaan tinggi baris ketika aplikasi mengimplementasikan gerakan sendiri. scrollingDeltaX, scrollingDeltaY

Referensi lemah verticalOwner menunjuk langsung ke tampilan gulir luar yang dibuat oleh contoh ini. Dengan demikian, kode tidak bergantung pada hierarki tampilan internal SwiftUI. Contoh ini tidak menambahkan pemantau peristiwa; override-nya menangani peristiwa yang dikirim ke tampilan deretan milik app.

Periksa kedua arah, momentum, dan perubahan ukuran

Contoh ini telah diperiksa terhadap dokumentasi API yang diambil, tetapi belum dikompilasi atau dijalankan: lingkungan riset tidak memiliki compiler Swift atau runtime macOS. Build di Xcode dan lakukan pemeriksaan berikut sebelum menerapkan kebijakan perutean ini pada antarmuka yang sudah ada.

Pemeriksaan Hasil yang diharapkan
Gulir secara vertikal dengan penunjuk di atas kartu Daftar luar bergerak
Gulir secara horizontal di atas deretan pertama Hanya deretan itu yang bergerak secara horizontal
Gerakkan deretan lain secara horizontal Offset-nya berubah secara mandiri
Mulai secara vertikal, lalu tambahkan gerakan diagonal Gestur tetap ditetapkan ke daftar luar
Mulai secara horizontal, lalu tambahkan gerakan diagonal Gestur tetap ditetapkan ke deretan
Lepaskan gestur trackpad hingga menghasilkan momentum Momentum mempertahankan tujuan yang dipilih
Gunakan roda gulir vertikal konvensional Setiap peristiwa roda gulir diarahkan ke daftar luar
Ubah ukuran jendela Deretan mengikuti lebar area pandang luar

Uji juga pembukaan gestur dengan delta nol, pembatalan, perubahan arah cepat, serta bilah gulir overlay dan yang selalu terlihat. Penyanggaan di ShelfScrollView dibuat agar pembukaan dengan delta nol tidak memilih pengguliran horizontal terlalu dini. Perilakunya pada perangkat dan versi macOS yang Anda dukung tetap perlu diverifikasi saat dijalankan.

Kebijakan diagonal sengaja memilih satu tujuan untuk gestur berfase. Kebijakan ini tidak menjanjikan gerakan serentak pada daftar luar dan deretan. Di ujung deretan horizontal, contoh ini juga tetap menggunakan deretan tersebut sebagai tujuan alih-alih mengalihkan gestur ke daftar vertikal. Jika tugas Anda memerlukan penggeseran diagonal berkelanjutan, gunakan satu dokumen dua sumbu.

Contoh ini langsung membuat semua kartu dan menggunakan dimensi tetap. Anggap dimensi tersebut sebagai masukan demonstrasi. Untuk konten yang berubah, implementasikan updateNSView untuk memperbarui dokumen dan tampilan hosting yang sudah ada sambil mempertahankan posisi gulirnya; Apple mendokumentasikan metode tersebut sebagai tempat untuk menerapkan status SwiftUI baru ke tampilan AppKit yang direpresentasikan. NSViewRepresentable

Singkirkan kemungkinan pengguliran dinonaktifkan dan masalah tata letak teks

Jika kedua arah tidak berfungsi, periksa modifier sebelum mengubah perutean. Pada macOS 13 dan versi lebih baru, scrollDisabled(_:) meneruskan pengaturannya melalui lingkungan SwiftUI dan dapat memengaruhi tampilan gulir SwiftUI luar maupun tampilan gulir bertingkat. Fungsinya adalah mengaktifkan atau menonaktifkan pengguliran, bukan meneruskan sumbu. scrollDisabled(_:)

Jika deretan Anda nantinya berisi tampilan teks AppKit, periksa geometri teks secara terpisah. NSTextContainer.widthTracksTextView milik TextKit mengontrol apakah lebar wadah teks mengikuti lebar tampilan teks. Properti ini tidak menentukan tampilan gulir mana yang menerima gestur roda. Perubahan tata letak teks dapat mengubah apakah konten meluap secara horizontal, tetapi jangan menyajikannya sebagai perbaikan perutean peristiwa. Demonstrasi kartu menghindari variabel tambahan tersebut. widthTracksTextView

Mulailah dengan satu tampilan gulir SwiftUI dua sumbu ketika antarmukanya berupa satu dokumen. Untuk deretan mandiri yang memerlukan perutean vertikal eksplisit, jembatan yang dikelola app memberi tujuan yang jelas bagi setiap gestur sambil mempertahankan konten kartu SwiftUI. Validasi kebijakan gestur pada Mac dan perangkat input yang didukung app Anda sebelum memperluas contoh ini ke konten dinamis.

Sumber