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

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

لماذا لا يؤدي تغيير الحالة إلى تسليم السحب إلى هدف آخر على نحو موثوق؟

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

توثّق Apple واجهة SwiftUI ‏validateDrop(info:) باعتبارها تحققًا عند دخول إسقاط متوافق إلى وجهة. وتوثّق dropUpdated(info:) باعتبارها تحديثًا عندما يتحرك إسقاط تم التحقق منه داخل العرض المعدّل. لا يَعِد أي من الوصفين بإجراء بحث جديد عن الوجهة كلما تغيرت حالة غير مرتبطة للعرض المالك. التحقق في SwiftUI وتحديثات الإسقاط.

حدود AppKit أوضح: لا تستقبل سوى وجهة واحدة سلسلة رسائل draggingUpdated(_:) في كل مرة. عندما يكون عرضان متداخلان وجهتين صالحتين، يستقبل العرض الأعلى تلك الرسائل إلى أن يُفلت العنصر أو يُسحب إلى الخارج. يصف ذلك توجيه AppKit؛ لكنه لا يثبت وجود عقد عام في SwiftUI لأولوية العروض الشقيقة. تحديثات وجهات AppKit.

إن إرجاع false من إجراء الإسقاط الأقدم في SwiftUI يفيد بأن الإسقاط لم ينجح. لا تصف Apple هذه القيمة المعادة بأنها طلب لإعادة المحاولة على وجهة أسفلها. وبالمثل، فإن إرجاع nil من dropUpdated(info:) يحافظ على آخر اقتراح صالح أو يستخدم النسخ افتراضيًا؛ ولا يعني «تمرير الإسقاط». معالجة الإسقاط باستخدام الإغلاق وسلوك الاقتراحات.

النتيجة العملية هي جعل الرجوع إلى وجهة بديلة قاعدة صريحة في التطبيق. في لوحة واحدة، يمكن لمستقبِل أصلي واحد الاختيار بين عدة لوحات منطقية من دون الاعتماد على التسليم الأصلي بين مستقبِلات فرعية متداخلة.

حدّد الملكية والرجوع إلى وجهة بديلة قبل إضافة المعدّلات

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

موقع المؤشر هل تقبل الزرقاء الإسقاط؟ الوجهة المنطقية
منطقة البرتقالية فقط أي قيمة البرتقالية
منطقة التداخل نعم الزرقاء
منطقة التداخل لا البرتقالية
منطقة الزرقاء فقط لا لا شيء
خارج اللوحتين أي قيمة لا شيء

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

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

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

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

أنشئ سطح إسقاط واحدًا ثابتًا في SwiftUI

يتطلب المثال الكامل التالي تطبيق SwiftUI لنظام macOS، وهدف نشر macOS 26، وحزمة تطوير برمجيات تتضمن dropDestination(for:isEnabled:action:). توثّق Apple هذه الزيادة باعتبارها تقبل نوعًا من Transferable، وعلامة تمكين، وإجراءً يستقبل العناصر التي فُك ترميزها بالإضافة إلى DropSession. وتشغل الوجهة حجم العرض المعدّل وموضعه. واجهة وجهة الإسقاط الحالية.

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

النموذج والتوجيه الحتمي

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

import SwiftUI
import Combine

@MainActor
final class CanvasModel: ObservableObject {
    enum Zone: String {
        case orange = "Orange"
        case blue = "Blue"
    }

    let orangeRect = CGRect(x: 30, y: 30, width: 230, height: 170)
    let blueRect = CGRect(x: 150, y: 100, width: 230, height: 170)
    let samples = ["Circle", "Square"]

    @Published var blueEnabled = true
    @Published private(set) var orangeItems: [String] = []
    @Published private(set) var blueItems: [String] = []
    @Published private(set) var message = "Drag a sample onto a panel."

    func recipient(at point: CGPoint) -> Zone? {
        if blueEnabled && blueRect.contains(point) {
            return .blue
        }
        if orangeRect.contains(point) {
            return .orange
        }
        return nil
    }

    func receive(_ items: [String], at point: CGPoint, isLocal: Bool) {
        guard isLocal,
              !items.isEmpty,
              items.allSatisfy({ samples.contains($0) }) else {
            message = "No items added: use this window's sample labels."
            return
        }
        guard let zone = recipient(at: point) else {
            message = "No items added: this location has no eligible panel."
            return
        }
        switch zone {
        case .orange:
            orangeItems.append(contentsOf: items)
        case .blue:
            blueItems.append(contentsOf: items)
        }
        message = "Added \(items.joined(separator: ", ")) to \(zone.rawValue)."
    }
}

التطبيق ومستقبِل الإسقاط

يمتلك مستطيل العنصر الأب المعدّل الوحيد الخاص بالإسقاط. ولا تسجّل أي من اللوحتين الملونتين وجهة منافسة. توفر DropSession موقعًا في مساحة الإحداثيات المحلية للوجهة وlocalSession يحدد السحب الذي بدأ داخل التطبيق. ويتحقق النموذج أيضًا من قيم العينة. معلومات جلسة الإسقاط.

@main
struct DropRoutingDemoApp: App {
    var body: some Scene {
        WindowGroup {
            CanvasDemo()
        }
    }
}

@MainActor
struct CanvasDemo: View {
    @StateObject private var model = CanvasModel()
    @State private var scheduledChange: Task<Void, Never>?

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack {
                ForEach(model.samples, id: \.self) { item in
                    Text(item)
                        .padding(10)
                        .background(Color.gray.opacity(0.15))
                        .draggable(item)
                }
            }

            Toggle("Blue accepts drops", isOn: $model.blueEnabled)

            Button("Toggle blue in 3 seconds") {
                scheduledChange?.cancel()
                scheduledChange = Task { @MainActor in
                    do {
                        try await Task.sleep(for: .seconds(3))
                    } catch {
                        return
                    }
                    model.blueEnabled.toggle()
                }
            }

            ZStack(alignment: .topLeading) {
                Rectangle().fill(Color.gray.opacity(0.08))
                panel("Orange", rect: model.orangeRect,
                      color: .orange, opacity: 0.55)
                panel("Blue", rect: model.blueRect,
                      color: .blue,
                      opacity: model.blueEnabled ? 0.55 : 0.15)
            }
            .frame(width: 420, height: 300)
            .dropDestination(for: String.self, isEnabled: true) {
                items, session in
                model.receive(items, at: session.location,
                              isLocal: session.localSession != nil)
            }

            Text(model.message)
            Text("Orange: \(model.orangeItems.joined(separator: ", "))")
            Text("Blue: \(model.blueItems.joined(separator: ", "))")
        }
        .padding(20)
        .onDisappear {
            scheduledChange?.cancel()
        }
    }

    private func panel(_ title: String, rect: CGRect,
                       color: Color, opacity: Double) -> some View {
        Rectangle()
            .fill(color.opacity(opacity))
            .overlay(Text(title))
            .frame(width: rect.width, height: rect.height)
            .position(x: rect.midX, y: rect.midY)
    }
}

تثبّت draggable(_:) في SwiftUI الإيماءات اللازمة لبدء السحب بإدخال المستخدم. أما الزر المؤجل فيغير حالة الأهلية في المثال فقط؛ ولا يبدأ جلسة السحب أو يعيد تشغيلها أو ينقلها. سلوك مصدر السحب في SwiftUI.

اضغط Toggle blue in 3 seconds، ثم اسحب Circle إلى منطقة التداخل وأبقِه هناك حتى تتلاشى الزرقاء. أفلته داخل منطقة التداخل: المستلم المقصود هو البرتقالية. أفلته في الجزء غير المتداخل من الزرقاء بينما هي معطّلة: لن تتغير أي من مصفوفتَي العناصر، وستوضح الحالة السبب.

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

تمت مراجعة التعليمات البرمجية وفق تعريفات الواجهات المسترجعة. ولم تُترجم أو تُشغّل هنا لأن البيئة المتاحة لا تتضمن سلسلة أدوات Swift أو بيئة تشغيل macOS.

عالج تحديثات التحويم وأهداف النشر الأقدم كلًا على حدة

إذا كنت تدعم أنظمة لا تتضمن الزيادة الأحدث، فاحتفظ بسياسة النموذج والهندسة نفسها مع اختيار واجهة استقبال متاحة. تمرر dropDestination(for:action:isTargeted:) الأقدم العناصر التي فُك ترميزها ونقطة محلية؛ وتعبر القيمة المنطقية المعادة عن النجاح. ويصف الإغلاق isTargeted دخول منطقة الوجهة الأصلية والخروج منها، وهي في هذا التصميم اللوحة كلها. ولا يحدد اللوحة المنطقية الموجودة أسفل المؤشر. عقد وجهة الإسقاط الأقدم.

للتحقق الصريح وتقديم اقتراحات للعمليات، تثبّت onDrop(of:delegate:) مفوّض إسقاط. استخدم validateDrop(info:) للأهلية الأولية، وdropUpdated(info:) للاقتراحات الحالية، وperformDrop(info:) للتحقق النهائي والدمج. إن إرجاع .forbidden يصف نشاطًا غير مسموح به في ذلك الموقع؛ ولا يوثّق تسليمًا إلى مستقبِل أصلي آخر. الوجهات المعتمدة على المفوّض وعمليات الإسقاط.

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

لعرض التحويم، خزّن معلومات الجلسة المؤقتة بشكل منفصل عن العناصر المستلمة. تشير وثائق DropSession الأحدث إلى onDropSessionUpdated(_:) لمراقبة تغييرات المراحل. لا تفترض أن تغيير حالة النموذج ينشئ تحديثًا آخر للمؤشر؛ فإذا كان لديك بالفعل موقع تحويم صالح، فأعد حساب التمييز المنطقي أيضًا عند تغير الأهلية. وينبغي أن يعيد القبول النهائي استدعاء دالة التوجيه. دورة حياة جلسة الإسقاط.

متى ينبغي للوحة استخدام AppKit بدلًا من ذلك؟

استخدم وجهة AppKit عندما يعتمد التفاعل المطلوب على تحديثات أصلية دورية موثقة أثناء ثبات المؤشر. تتحكم wantsPeriodicDraggingUpdates() في AppKit بهذا السلوك: إذ يؤدي إرجاع false إلى قصر التحديثات على حركة الفأرة أو تغييرات مفاتيح التعديل؛ وإلا فتحدث تحديثات دورية حتى عندما لا يتغير شيء. ويوفر ذلك طريقة موثقة لإعادة تقييم العملية الحالية أثناء سحب ثابت. تحديثات السحب الدورية.

وتظل البنية المقابلة وجهة واحدة للوحة. سجّل أنواع لوحة اللصق المقبولة، وحدد اللوحات المنطقية أثناء draggingEntered(_:) وdraggingUpdated(_:)، وتحقق من القبول عند الإفلات، ثم أدمج العنصر في performDragOperation(_:). وتصف Apple تحديدًا تخصيص العملية لمناطق الرسومات أو النص الموجودة داخل عرض وجهة واحدة. دورة حياة وجهة AppKit والعمليات المعتمدة على الموضع.

عند تضمين هذه الوجهة باستخدام NSViewRepresentable، حدّث مرجع النموذج وإعدادات الأهلية والاستدعاءات الراجعة في updateNSView(_:context:). لا يكفي الإنشاء الأولي لمزامنة تغييرات المالك اللاحقة؛ إذ تحدد Apple طريقة التحديث بوصفها الموضع الذي يتيح جعل عرض AppKit متوافقًا مع حالة SwiftUI. تحديث عرض AppKit.

حافظ أيضًا على حدود حدث المصدر. تبدأ beginDraggingSession(with:event:source:) في AppKit جلسةً من عناصر السحب وحدث الضغط على زر الفأرة الذي بدأ السحب. وهي ليست واجهة لإعادة توجيه الوجهة. أبقِ مصدر SwiftUI الحالي إذا كان يلبي المهمة؛ واستخدم مصدر AppKit فقط إذا كنت تحتاج أيضًا إلى التحكم في التفاعل من جهة المصدر. بدء سحب في AppKit.

تحقّق من تغييرات الحالة عند النقاط المؤثرة في الملكية

استخدم الفحوص اليدوية التالية على إصدارات macOS التي تدعمها. فهي تميز التوجيه الصحيح عن تغيير مرئي يبدو أنه يعمل فحسب:

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

في لوحة قابلة للتمرير أو التكبير أو تغيير الحجم، وسّع التصميم نفسه بتحويل حدود كل لوحة وموقع الإسقاط إلى مساحة إحداثيات واحدة. تتجنب المستطيلات الثابتة في هذا المثال ذلك التحويل؛ وسيؤدي نسخ أرقامها إلى تخطيط محوّل إلى إفساد سياسة التوجيه.

يحدد المستقبِل الأصلي الثابت ودالة التوجيه التي تستخدم الحالة الحالية نتيجة هذا التفاعل بوضوح: تستقبل اللوحة المؤهلة العليا العنصر، وتستقبل لوحة سفلية مؤهلة الإسقاط في منطقة التداخل، وتترك المنطقة غير المغطاة النموذج بلا تغيير. اجعل هذه السياسة مشتركة بين استجابة التحويم والقبول النهائي، مع إجراء القبول النهائي فحصه الخاص.

المصادر