أرفق سحب العنصر الأب باستخدام simultaneousGesture(_:including:) للسماح له بالمشاركة إلى جانب تفاعلات العناصر الفرعية. أبقِ إجراءات النقر ضمن أزرار العناصر الفرعية، وحدد حدًا للحركة قبل بدء السحب، وقرر صراحةً ما إذا كان يمكن أن يتزامن التحديد مع السحب.

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

اختر عقد التفاعل قبل تغيير أولوية الإيماءة

توثّق Apple أن simultaneousGesture(_:including:) يضيف إيماءة بالأولوية نفسها للإيماءات الموجودة. ويعرض مثالها استجابة معالج النقر للعنصر الفرعي ومعالج النقر للحاوية المحيطة عند النقر. وهذه آلية التركيب المناسبة عندما ينبغي للعنصر الأب المشاركة من دون أن يتعمد التقدم على العنصر الفرعي. توثيق Apple للإيماءات المتزامنة

لكن التعرّف المتزامن لا يعبّر بحد ذاته عن قاعدة «يجب أن يؤدي السحب إلى إلغاء كل إجراء للعناصر الفرعية». كما أن مثال Apple لا يثبت أن كل نمط زر وكل حاوية يحسمان التنافس بين الزر والسحب بالطريقة نفسها. تعامل مع مشاركة الإيماءات وسلوك التطبيق كقرارين منفصلين.

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

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

أنشئ لوحة كاملة قابلة للتحريك بأزرار للعناصر الفرعية

أنشئ تطبيق SwiftUI لنظام macOS في Xcode، واضبط هدف النشر على macOS 14 أو أحدث، واستبدل ملفي التطبيق وعرض المحتوى اللذين أُنشئا تلقائيًا بالملف الواحد التالي. أبقِ نقطة دخول واحدة للتطبيق فقط في الهدف. يغطي شرط macOS 14 المعدّل الحديث coordinateSpace(_:) المستخدم هنا. توثيق Apple لمساحة الإحداثيات

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

import SwiftUI

@main
struct GesturePanelApp: App {
    var body: some Scene {
        WindowGroup {
            PanelDemo()
        }
    }
}

private struct DemoItem: Identifiable {
    let id: Int
    let title: String
    let color: Color
}

private struct ItemTarget: View {
    let item: DemoItem
    let isSelected: Bool
    let select: () -> Void

    var body: some View {
        Button(action: select) {
            HStack(spacing: 10) {
                Circle()
                    .fill(item.color)
                    .frame(width: 18, height: 18)
                Text(item.title)
                Spacer()
                if isSelected {
                    Text("Selected")
                        .font(.caption)
                }
            }
            .padding(12)
            .frame(width: 220)
            .background(
                isSelected
                    ? Color.accentColor.opacity(0.18)
                    : Color.gray.opacity(0.12)
            )
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
    }
}

private struct PanelDemo: View {
    private let items = [
        DemoItem(id: 1, title: "Item A", color: .blue),
        DemoItem(id: 2, title: "Item B", color: .orange),
        DemoItem(id: 3, title: "Item C", color: .purple)
    ]

    @State private var selectedID: Int? = nil
    @State private var committedOffset: CGSize = .zero
    @GestureState private var liveTranslation: CGSize = .zero

    private var pan: some Gesture {
        DragGesture(
            minimumDistance: 8,
            coordinateSpace: .named("stage")
        )
        .updating($liveTranslation) { value, translation, _ in
            translation = value.translation
        }
        .onEnded { value in
            committedOffset = CGSize(
                width: committedOffset.width + value.translation.width,
                height: committedOffset.height + value.translation.height
            )
        }
    }

    var body: some View {
        VStack(spacing: 12) {
            Text("Click an item or drag the panel")

            ZStack {
                Color.gray.opacity(0.06)

                VStack(spacing: 10) {
                    Text("Drag panel")
                        .font(.headline)
                        .frame(width: 244, height: 32)
                        .contentShape(Rectangle())

                    ForEach(items) { item in
                        ItemTarget(
                            item: item,
                            isSelected: selectedID == item.id,
                            select: { selectedID = item.id }
                        )
                    }
                }
                .padding(16)
                .background(Color.gray.opacity(0.15))
                .contentShape(Rectangle())
                .simultaneousGesture(pan, including: .all)
                .offset(
                    x: committedOffset.width + liveTranslation.width,
                    y: committedOffset.height + liveTranslation.height
                )
            }
            .frame(width: 640, height: 420)
            .coordinateSpace(.named("stage"))

            Button("Reset panel") {
                committedOffset = .zero
            }
        }
        .padding(20)
    }
}

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

لماذا للسحب حد غير صفري

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

لماذا تفصل الحركة المؤقتة عن الحركة المعتمدة

أثناء التعرّف، يكتب updating(_:body:) الإزاحة الحالية إلى liveTranslation. وتوثّق Apple أن حالة الإيماءة تُعاد إلى وضعها عند انتهاء الإيماءة أو إلغائها. لا يعتمد المثال الإزاحة النهائية إلا في onEnded، لذا يؤدي الإلغاء إلى إسقاط المعاينة غير المعتمدة بدلًا من ترك إزاحة مؤقتة قديمة. توثيق Apple للتحديث

الموضع المعروض هو الإزاحة المعتمدة مضافًا إليها الإزاحة الحالية. وتعرّف Apple translation على أنها الإزاحة الكلية منذ بداية السحب؛ لذا فإن إضافتها مرارًا إلى الإزاحة المعتمدة عند كل تغيير سيحسب الحركة أكثر من مرة. للتوضيح، تؤدي إزاحة أفقية معتمدة مقدارها 40 وإزاحة حالية مقدارها 12 إلى موضع معروض مقداره 52؛ وعند إكمال السحب، تُعتمد 52 مرة واحدة. توثيق Apple للإزاحة

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

لماذا يبقى التحديد لدى العنصر الأب

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

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

افهم مناطق الالتقاط وأقنعة الإيماءات

يحدد contentShape(Rectangle()) الشكل المستخدم لاختبار الالتقاط. وفي تسمية الزر، يأتي بعد الحشو والإطار لكي تكون منطقة التسمية المستطيلة المقصودة صريحة. وعلى العنصر الأب، يحدد شكل التفاعل للوحة. لكنه لا يحدد أولوية أدوات التعرّف ولا يشترط تعاون إيماءتين. توثيق Apple لشكل المحتوى

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

القناع التأثير الموثق مدى ملاءمته هنا
.all يفعّل الإيماءة المضافة والإيماءات الأخرى على العرض وعروضه الفرعية نقطة بداية للمشاركة بينهما
.gesture يفعّل الإيماءة المضافة ويعطّل الإيماءات في تسلسل العروض الفرعية يتعارض مع الحفاظ على إيماءات العناصر الفرعية
.subviews يفعّل إيماءات العروض الفرعية ويعطّل الإيماءة المضافة يعطّل سحب العنصر الأب
.none يعطّل الإيماءات في تسلسل العروض الفرعية، بما فيها الإيماءة المضافة يعطّل التفاعل المقصود

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

استخدم مقبض سحب منفصلًا عندما يلزم تمييز التنشيط

للوحة ينبغي ألا تشترك أزرارها في منطقة سحب، احتفظ بالحالة نفسها وحساب الحركة، لكن أرفق pan بالرأس فقط. أزل .simultaneousGesture(pan, including: .all) من اللوحة الأم.

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

Text("Drag panel")
    .font(.headline)
    .frame(width: 244, height: 32)
    .contentShape(Rectangle())
    .gesture(pan)

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

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

حافظ على ملكية أحداث AppKit عند حدود الأطر

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

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

توثّق AppKit أن التنفيذ الافتراضي لـ mouseDown(with:) يمرر الرسالة إلى المستجيب التالي. وهذا سلوك لسلسلة المستجيبين، وليس وعدًا بأن كل عنصر سلف يتلقى نسخة مستقلة بعد تعامل عنصر تحكم مع الحدث. أبقِ تنشيط عنصر التحكم وتحريك اللوحة مرتبطين باستدعاءات أو أدوات تعرّف تملكها صراحةً. توثيق Apple لضغط زر الفأرة

عندما تملك أداتي تعرّف إيماءات من AppKit وتحتاج إلى التعرّف المتزامن، توفر AppKit الدالة gestureRecognizer(_:shouldRecognizeSimultaneouslyWith:). توثّق Apple أن إرجاع true يسمح للزوج بالتعرّف معًا؛ أما إرجاع false فلا يضمن الاستبعاد إذا سمح مفوّض أداة التعرّف الأخرى بذلك. قصّر هذه السياسة على زوج أدوات التعرّف المقصود. توثيق Apple لمفوّض أداة التعرّف

تحقق من التفاعل بمصفوفة سلوك صغيرة

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

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

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

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

المصادر