Dikey bir listenin içindeki yatay raflara sahip bir Mac uygulamasında, her hareketi açık bir hedefe yönlendirin. SwiftUI’ın eksen yapılandırmasıyla başlayın; açık yönlendirme gerekiyorsa, iç içe AppKit kaydırma görünümlerinin denetimini bir SwiftUI köprüsü üzerinden alın.

Buradaki somut görev, sentetik kartlara göz atmaktır: herhangi bir rafın üzerindeyken dikey kaydırma tüm listeyi hareket ettirmeli, yatay kaydırma ise yalnızca o rafı bağımsız olarak hareket ettirmelidir. Bunun için her satırda ayrı yatay kaydırma konumları gerekir. Bu, yatay ve dikey yönde birlikte hareket eden tek bir tuvalden farklıdır.

Göreve uygun kaydırma yapısını seçin

Apple, SwiftUI ScrollView görünümünün yatay, dikey veya her iki eksende kaydırılabildiğini belgeliyor. Büyük, tek bir belge için ScrollView([.horizontal, .vertical]) kullanın ve içeriğine görünüm alanını aşan boyutlar verin. Tüm içerik yatayda ve dikeyde aynı konumu paylaşıyorsa bu en basit yapıdır. ScrollView

Bağımsız raflar ayrı kaydırma kapsayıcıları gerektirir. Doğal SwiftUI yapısı, sınırlandırılmış yüksekliğe sahip yatay ScrollView görünümlerini içeren dikey bir ScrollView görünümüdür. Ancak bu eksenleri yapılandırmak, iç kapsayıcı üzerinde alınan bir olayın dış kapsayıcıya nasıl ulaşacağını tek başına belirlemez. Apple’ın ScrollView belgeleri desteklenen eksenleri açıklar, ancak bu iç içe kullanım durumu için genel bir olay yönlendirme sözleşmesi tanımlamaz. ScrollView

Gereken davranış Uygun başlangıç noktası
Tek bir belge iki yönde hareket eder İki eksenli tek bir SwiftUI ScrollView
Her raf kendi yatay konumunu korur Dikey bir kapsayıcı içindeki iç içe yatay raflar
Rafların üzerindeki dikey hareketler açık bir hedefe yönlendirilmelidir NSViewRepresentable üzerinden sunulan, uygulamanın denetimindeki NSScrollView alt sınıfları
Çapraz hareket belgenin tamamını serbestçe kaydırmalıdır İki eksenli tek bir kapsayıcı; AppKit ayrıca usesPredominantAxisScrolling özelliğini sunar

AppKit, tek seferde ağırlıklı olarak bir eksende kaydırılan içerikle, tuval gibi her iki eksende serbestçe hareket etmesi gereken içeriği birbirinden ayırır. usesPredominantAxisScrolling özelliği bu ayrımı ifade eder; belgelenen varsayılan değeri true şeklindedir. Bu özellik, olay hedefi olarak farklı bir iç içe kaydırma görünümünü seçmek yerine, bir kaydırma görünümünün davranışını belirler. usesPredominantAxisScrolling

Belgelenen iletim sınırını anlayın

wantsForwardedScrollEvents(for:) adı, iç içe kaydırma için evrensel bir çözüm olduğu izlenimini verebilir. Apple daha dar bir koşulu belgeliyor: elastik kaydırma yapan görünümler, içerik hareketin başlangıcında ilgili kenardaysa hareket olaylarını iletebilir. Bu belgeler, sıradan tekerlek olaylarının koşulsuz iletildiğini veya eksenlerin gelişigüzel biçimde ayrıldığını göstermez. wantsForwardedScrollEvents(for:)

Benzer biçimde, varsayılan NSResponder.scrollWheel(with:) uygulaması iletiyi bir sonraki yanıtlayıcıya aktarırken, NSScrollView kaydırma gerçekleştirmek için bu yöntemi uygular. Bu nedenle bir yanıtlayıcının işlenmemiş olayı iletmesiyle bir kaydırma kapsayıcısının olayı tüketmesini birbirinden ayırmalısınız. NSResponder scrollWheel(with:), NSScrollView scrollWheel(with:)

Açık denetim gerektiren bir arayüz için desteklenen yapı taşları, bir AppKit görünümünü SwiftUI içine yerleştiren NSViewRepresentable ve SwiftUI içeriğini AppKit içine yerleştiren NSHostingView bileşenleridir. Aşağıdaki uygulama, her iki kaydırma kapsayıcısını da denetlemek ve kartları SwiftUI’da tutmak için bu köprüleri kullanır. Yönlendirme politikası, Apple’ın sağladığı bir iç içe kaydırma tarifi değil, belgelenen API’lerden yararlanan uygulama kodudur. NSViewRepresentable, NSHostingView

Uygulamanın denetimindeki iç içe kaydırma örneğini oluşturun

Xcode’da SwiftUI ve Swift kullanarak bir macOS uygulaması projesi oluşturun. Dağıtım hedefini macOS 11 veya üzeri olarak ayarlayın. Oluşturulan uygulama girişini aşağıdaki ilk dosyayla değiştirin, ardından ikinci dosyayı aynı hedefe ekleyin. Bu iki dosya birlikte, sentetik etiketler kullanan ve ek varlık gerektirmeyen eksiksiz bir tanıtım programı oluşturur.

Örnek, macOS 11’den itibaren kullanılabilen SwiftUI App yaşam döngüsünü kullanır. Hedefte tam olarak bir @main bildirimi bulundurun: hem Apple’ın yaşam döngüsü belgeleri hem de Swift dil başvurusu tek bir program giriş noktası gerektirir. App, Attributes

Dış listeyi ve SwiftUI kartlarını oluşturun

İlk dosya sekiz raf oluşturur. Her rafta on altı kart ve kendine ait yatay bir belge bulunur. Dış belge, görünüm alanının genişliğini izler ve sabit bir tanıtım yüksekliğine sahiptir; böylece yalnızca dış kapsayıcının dikey kaydırma yapması gerekir.

SwiftUI, temsil edilen dış görünümün çerçevesini denetler. Özel düzen yalnızca içindeki AppKit belge ve raf çerçevelerini değiştirir; böylece Apple’ın NSViewRepresentable tarafından doğrudan yönetilen görünümün düzen özelliklerini değiştirmeme uyarısına uyar. NSViewRepresentable

Bunu NestedScrollDemoApp.swift adıyla kaydedin:

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 belge görünümünü, kırpma görünümünü ve kaydırma çubuklarını eşgüdüm içinde çalıştırır. Burada dış belge, raf kaydırma görünümlerini; her rafın belgesi ise SwiftUI kartlarını içeren bir NSHostingView görünümünü barındırır. 2444 noktalık genişlik, örnek bir düzen hesabıdır: 140 boyutundaki on altı kart, 12 boyutundaki on beş boşluk ve yatay kenar boşluğu için 24 nokta. NSScrollView, NSHostingView

Hareketi bir kez yönlendirin ve hedefini koruyun

İkinci dosya yönlendirme politikasını sağlar. İlk sıfır olmayan hareketi ağırlıklı olarak dikey olan bir hareket dış listeye gider. Aksi durumda raf üzerinde kalır. Eşitlik durumunda raf seçilir. Bunlar bu tanıtım için belirlenmiş açık tasarım tercihleridir.

Apple, ayrı phase ve momentumPhase özellikleri sunar. Eski tekerlek olaylarının aşaması yoktur; ivmeli kaydırma olaylarında ise olağan hareket aşaması bulunmaz. Bu nedenle örnek, aşaması olmayan bir tekerlek olayını bağımsız olarak ele alır ve aşamalı bir hareketin hedefini ivmeli kaydırma dizisi boyunca korur. phase, momentumPhase

Bunu ShelfScrollView.swift adıyla kaydedin:

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

Kod, yalnızca hedefi seçmek için scrollingDeltaX ve scrollingDeltaY değerlerini okur. Kaydırmayı AppKit’in gerçekleştirebilmesi için özgün olayı tek bir kaydırma görünümüne iletir. Delta değerini bir nokta kaydırma mesafesi olarak yeniden yorumlamaz. Bu önemlidir; çünkü Apple hassas ve hassas olmayan deltaların farklı ele alındığını belgeliyor. Örneğin, uygulama hareketi kendisi gerçekleştiriyorsa hassas olmayan dikey deltaların satır yüksekliğiyle ölçeklendirilmesi gerekir. scrollingDeltaX, scrollingDeltaY

Zayıf verticalOwner başvurusu, bu örneğin oluşturduğu dış kaydırma görünümünü doğrudan işaret eder. Böylece SwiftUI’ın iç görünüm hiyerarşisine bağımlı kalınmaz. Örnek bir olay izleyicisi eklemez; geçersiz kılınan yöntem, uygulamanın raf görünümlerine teslim edilen olayları işler.

Her iki yönü, ivmeyi ve yeniden boyutlandırmayı kontrol edin

Bu örnek, alınan API belgeleriyle karşılaştırılarak incelendi; ancak derlenmedi veya çalıştırılmadı: araştırma ortamında Swift derleyicisi ya da macOS çalışma zamanı yok. Yönlendirme politikasını mevcut bir arayüze uygulamadan önce Xcode’da derleyin ve aşağıdaki kontrolleri gerçekleştirin.

Kontrol Beklenen sonuç
İşaretçi bir kartın üzerindeyken dikey kaydırın Dış liste hareket eder
İlk rafın üzerinde yatay kaydırın Yalnızca o raf yatay hareket eder
Başka bir rafı yatay kaydırın Konumu bağımsız olarak değişir
Dikey başlayıp ardından çapraz harekete geçin Hareket dış listeye atanmış kalır
Yatay başlayıp ardından çapraz harekete geçin Hareket rafta kalır
İzleme dörtgeni hareketini ivmeli kaydırmayla sonlandırın İvmeli kaydırma seçilen hedefte kalır
Geleneksel dikey tekerlek kullanın Her tekerlek olayı dış listeye yönlendirilir
Pencereyi yeniden boyutlandırın Raflar dış görünüm alanının genişliğini izler

Sıfır deltali hareket başlangıçlarını, iptali, hızlı yön değişikliklerini ve hem kaplama hem de sürekli görünür kaydırma çubuklarını da deneyin. ShelfScrollView içindeki arabelleğe alma, sıfır deltali bir başlangıcın yatay kaydırmayı erken seçmesini önlemek için kullanılır. Desteklediğiniz aygıt ve macOS sürümlerindeki davranışının çalışma zamanında doğrulanması gerekir.

Çapraz hareket politikası, aşamalı bir hareket için bilerek tek bir hedef seçer. Dış listenin ve rafın eşzamanlı hareket etmesini sağlamaz. Yatay rafın sonunda da bu örnek, hareketi dikey listeye devretmek yerine rafı hedef olarak korur. Sürekli çapraz kaydırma gerekiyorsa bunun yerine iki eksenli tek bir belge kullanın.

Örnek tüm kartları hemen oluşturur ve sabit boyutlar kullanır. Bu boyutları tanıtım girdileri olarak değerlendirin. İçerik değişiyorsa, kaydırma konumlarını koruyarak mevcut belge ve barındırma görünümlerini güncellemek için updateNSView yöntemini uygulayın; Apple, temsil edilen bir AppKit görünümüne yeni SwiftUI durumunu uygulamak için bu yöntemin kullanıldığını belgeliyor. NSViewRepresentable

Kaydırmanın devre dışı bırakılmadığını ve metin düzeni sorunları olmadığını doğrulayın

Her iki yönde de kaydırma çalışmıyorsa yönlendirmeyi değiştirmeden önce değiştiricileri inceleyin. macOS 13 ve sonrasında scrollDisabled(_:), ayarını SwiftUI ortamı üzerinden aktarır ve hem dış SwiftUI kaydırma görünümünü hem de iç içe kaydırma görünümlerini etkileyebilir. Bu bir kaydırmayı etkinleştirme denetimidir; eksenleri iletme mekanizması değildir. scrollDisabled(_:)

Sonradan rafınızda bir AppKit metin görünümü kullanırsanız metin geometrisini ayrıca kontrol edin. TextKit’teki NSTextContainer.widthTracksTextView, metin kapsayıcısının genişliğinin metin görünümünün genişliğini izleyip izlemeyeceğini belirler. Hangi kaydırma görünümünün tekerlek hareketini alacağını seçmez. Metin düzenindeki bir değişiklik, içeriğin yatayda taşmasına neden olabilir; ancak bu, olay yönlendirme çözümü olarak sunulmamalıdır. Kart örneği bu ek değişkeni devre dışı bırakır. widthTracksTextView

Arayüz tek bir belgeyse iki eksenli tek bir SwiftUI kaydırma görünümüyle başlayın. Açık dikey yönlendirme gerektiren bağımsız raflar için uygulamanın denetimindeki köprü, SwiftUI kart içeriğini korurken her harekete tanımlı bir hedef sağlar. Örneği dinamik içeriğe genişletmeden önce hareket politikasını uygulamanızın desteklediği Mac’lerde ve giriş aygıtlarında doğrulayın.

Kaynaklar