في تطبيق Mac يضم رفوفًا أفقية داخل قائمة رأسية، حدّد وجهة واضحة لكل إيماءة. ابدأ بإعداد المحاور في SwiftUI؛ وإذا احتجت إلى توجيه صريح، فتولَّ ملكية عروض التمرير المتداخلة في AppKit عبر جسر SwiftUI.

المهمة المحددة هنا هي استعراض بطاقات اصطناعية: ينبغي للتمرير عموديًا فوق أي رف أن يحرّك القائمة كلها، بينما يحرّك التمرير أفقيًا ذلك الرف وحده. يتطلب ذلك إزاحات أفقية مستقلة لكل صف. ويختلف هذا عن لوحة واحدة تتحرك أفقيًا وعموديًا معًا.

اختر بنية التمرير المناسبة للمهمة

توضح Apple أن ScrollView في SwiftUI يمكنه التمرير أفقيًا أو عموديًا أو على كلا المحورين. لمستند كبير واحد، استخدم ScrollView([.horizontal, .vertical]) واجعل أبعاد محتواه تتجاوز إطار العرض. هذه أبسط بنية عندما يشترك المحتوى كله في موضع أفقي واحد وموضع عمودي واحد. ScrollView

تتطلب الرفوف المستقلة حاويات تمرير منفصلة. بنية SwiftUI الطبيعية هي ScrollView رأسي يحتوي على مثيلات ScrollView أفقية، لكل منها ارتفاع محدد. لكن ضبط هذه المحاور لا يحدد بحد ذاته كيفية وصول الحدث الوارد فوق حاوية داخلية إلى الحاوية الخارجية. تصف وثائق Apple لـScrollView المحاور المدعومة، لكنها لا تحدد عقدًا عامًا لتوجيه الأحداث في هذه الحالة المتداخلة. ScrollView

السلوك المطلوب نقطة بداية مناسبة
تحريك مستند واحد في كلا الاتجاهين ScrollView واحدة من SwiftUI بمحورين
احتفاظ كل رف بموضعه الأفقي الخاص رفوف أفقية متداخلة داخل حاوية رأسية
الحاجة إلى وجهة صريحة للإيماءات الرأسية فوق الرفوف فئات فرعية من NSScrollView تملكها التطبيقات وتُعرض عبر NSViewRepresentable
الحاجة إلى تحريك المستند كله بحرية بإيماءة قطرية حاوية واحدة بمحورين؛ وتتيح AppKit أيضًا usesPredominantAxisScrolling

تميّز AppKit بين المحتوى الذي يتمرر غالبًا على محور واحد في كل مرة، والمحتوى الذي ينبغي أن يتحرك بحرية على كلا المحورين، مثل لوحة الرسم. تعبّر الخاصية usesPredominantAxisScrolling عن هذا التمييز، وقيمتها الافتراضية الموثقة هي true. تتعلق هذه الخاصية بسلوك عرض التمرير نفسه، ولا تختار عرض تمرير متداخلًا مختلفًا ليكون وجهة الحدث. usesPredominantAxisScrolling

افهم حدود إعادة التوجيه الموثقة

قد يوحي الاسم wantsForwardedScrollEvents(for:) بأنه حل شامل للتمرير المتداخل. لكن Apple توثّق شرطًا أضيق: يمكن للعروض التي تنفذ تمريرًا مرنًا إعادة توجيه أحداث الإيماءات عندما يكون المحتوى عند الحافة المعنية بالفعل في بداية الإيماءة. ولا تثبت هذه الوثائق إعادة توجيه غير مشروطة لأحداث عجلة التمرير العادية أو فصل المحاور كيفما اتفق. wantsForwardedScrollEvents(for:)

وبالمثل، يمرر التنفيذ الافتراضي لـNSResponder.scrollWheel(with:) الرسالة إلى المستجيب التالي، بينما تنفذ NSScrollView هذه الطريقة للتمرير. لذلك ينبغي التمييز بين تمرير مستجيب لحدث لم يتعامل معه، واستهلاك حاوية تمرير للحدث. NSResponder scrollWheel(with:)، NSScrollView scrollWheel(with:)

لواجهة تحتاج فيها إلى تحكم صريح، فإن اللبنات الأساسية المدعومة هي NSViewRepresentable، الذي يضمّن عرض AppKit في SwiftUI، وNSHostingView، الذي يضمّن محتوى SwiftUI في AppKit. يستخدم التنفيذ أدناه هذين الجسرين لامتلاك حاويتي التمرير كلتيهما، مع إبقاء البطاقات في SwiftUI. سياسة التوجيه هنا منطق في التطبيق مستمد من واجهات API الموثقة، وليست وصفة من Apple للتمرير المتداخل. NSViewRepresentable، NSHostingView

أنشئ مثالًا للتمرير المتداخل يملكه التطبيق

أنشئ مشروع تطبيق macOS في Xcode باستخدام SwiftUI وSwift. اجعل هدف النشر macOS 11 أو أحدث. استبدل نقطة دخول التطبيق التي أنشأها Xcode بالملف الأول أدناه، ثم أضف الملف الثاني إلى الهدف نفسه. يشكّل الملفان معًا برنامجًا توضيحيًا كاملًا بملصقات اصطناعية ومن دون أصول إضافية.

يستخدم المثال دورة حياة App في SwiftUI، المتاحة منذ macOS 11. أبقِ تصريحًا واحدًا فقط لـ@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 بين عرض المستند وعرض القصّ وأشرطة التمرير. هنا، يحتوي المستند الخارجي على عروض تمرير الرفوف؛ ويكون مستند كل رف NSHostingView يحتوي على بطاقات SwiftUI. عرض 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. ولا يضيف المثال مراقب أحداث؛ إذ يتعامل التجاوز مع الأحداث المرسلة إلى عروض الرفوف في التطبيق.

تحقّق من الاتجاهين والزخم وتغيير الحجم

تمت مراجعة هذا المثال بمقارنته بوثائق واجهات API المسترجعة، لكنه لم يُترجم أو يُشغَّل: فبيئة البحث لا تضم مترجم Swift أو بيئة تشغيل macOS. ابنِه في Xcode ونفّذ عمليات التحقق التالية قبل اعتماد سياسة التوجيه في واجهة موجودة.

التحقق النتيجة المقصودة
التمرير رأسيًا والمؤشر فوق بطاقة تتحرك القائمة الخارجية
التمرير أفقيًا فوق الرف الأول يتحرك ذلك الرف أفقيًا فقط
تحريك رف آخر أفقيًا يتغير موضعه باستقلالية
البدء عموديًا ثم إدخال حركة قطرية تظل الإيماءة مخصصة للقائمة الخارجية
البدء أفقيًا ثم إدخال حركة قطرية تظل الإيماءة مخصصة للرف
إنهاء إيماءة لوحة تعقب مع استمرار الزخم يحتفظ الزخم بالوجهة المحددة
استخدام عجلة تمرير عمودية تقليدية يُوجَّه كل حدث عجلة إلى القائمة الخارجية
تغيير حجم النافذة تتبع الرفوف عرض إطار العرض الخارجي

اختبر أيضًا بدايات الإيماءات ذات الدلتا الصفرية، والإلغاء، والتغييرات السريعة في الاتجاه، وأشرطة التمرير المتراكبة والظاهرة دائمًا. توجد آلية التخزين المؤقت في ShelfScrollView كي لا تؤدي بداية الإيماءة ذات الدلتا الصفرية إلى تحديد التمرير الأفقي قبل أوانه. ولا يزال يلزم التحقق أثناء التشغيل من سلوكها مع الأجهزة وإصدارات macOS التي تدعمها.

تختار سياسة الإيماءات القطرية عمدًا وجهة واحدة للإيماءة ذات المراحل. ولا تضمن تحريك القائمة الخارجية والرف في الوقت نفسه. وعند نهاية الرف الأفقي، يحتفظ هذا المثال أيضًا بذلك الرف بوصفه الوجهة بدلًا من تسليم الإيماءة إلى القائمة الرأسية. إذا كانت مهمتك تتطلب تحريكًا قطريًا متواصلًا، فاستخدم مستندًا واحدًا بمحورين.

ينشئ المثال جميع البطاقات فورًا ويستخدم أبعادًا ثابتة. تعامل مع هذه الأبعاد على أنها مدخلات توضيحية. للمحتوى المتغير، نفّذ updateNSView لتحديث المستندات وعروض الاستضافة الموجودة مع الاحتفاظ بمواضع التمرير؛ إذ توثّق Apple هذه الطريقة بوصفها الموضع الذي تُطبّق فيه حالة SwiftUI الجديدة على عرض AppKit مُمثَّل. NSViewRepresentable

تحقّق من تعطيل التمرير ومشكلات تخطيط النص

إذا لم يعمل أي اتجاه، فافحص المعدّلات قبل تغيير التوجيه. في macOS 13 والإصدارات الأحدث، يمرر scrollDisabled(_:) إعداداته عبر بيئة SwiftUI، وقد يؤثر في كل من عرض التمرير الخارجي في SwiftUI وعروض التمرير المتداخلة. إنه عنصر للتحكم في تفعيل التمرير، وليس آلية لتمرير الأحداث بين المحاور. scrollDisabled(_:)

إذا كان رفك سيحتوي في النهاية على عرض نص من AppKit، فتحقق من هندسة النص بصورة منفصلة. تتحكم NSTextContainer.widthTracksTextView في ما إذا كان عرض حاوية النص يتبع عرض العرض النصي. ولا تختار عرض التمرير الذي يستقبل إيماءة عجلة التمرير. قد يغيّر تعديل تخطيط النص ما إذا كان المحتوى يفيض أفقيًا، لكن لا ينبغي عرضه على أنه إصلاح لتوجيه الأحداث. يتجنب عرض البطاقات التوضيحي هذا المتغير الإضافي. widthTracksTextView

ابدأ بعرض تمرير واحد بمحورين في SwiftUI عندما تكون الواجهة مستندًا واحدًا. أما الرفوف المستقلة التي تحتاج إلى توجيه رأسي صريح، فيمنحك جسر يملكه التطبيق وجهة محددة لكل إيماءة مع الإبقاء على محتوى البطاقات في SwiftUI. تحقّق من سياسة الإيماءات على أجهزة Mac وأجهزة الإدخال التي يدعمها تطبيقك قبل توسيع المثال إلى محتوى ديناميكي.

المصادر