ادمج DragGesture وRotateGesture وMagnifyGesture باستخدام simultaneously(with:)، ثم اعرض قيمها عبر مسار تحويل واحد. في لوحة رسم Mac، أبقِ سطح الإدخال ثابتًا وخزّن التغيير المؤقت لكل إيماءة على حدة عن نتيجتها المعتمدة.

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

ما معنى التزامن على Mac؟

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

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

استخدم جدول القرار هذا لاختيار طبقة الإدخال للوحة الرسم:

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

يتطلب المثال macOS 14 أو إصدارًا أحدث، حيث يتوفر كل من MagnifyGesture وRotateGesture. ويستخدم المُهيئات الأساسية لهما بدلًا من واجهات برمجة التطبيقات الأحدث لضبط نوع الإدخال. MagnifyGesture، RotateGesture

كيف ينبغي تنظيم حالة التحويل؟

امنح كل مكوّن قيمة معتمدة وقيمة حية للإيماءة. يُجمع الإزاحة والدوران بالجمع، بينما يُجمع المقياس بالضرب. تحدد المعادلات التالية سلوك المثال، ولا تفرضها SwiftUI:

الإزاحة المعروضة = الإزاحة المعتمدة + إزاحة السحب الحية
الدوران المعروض = الدوران المعتمد + الدوران الحي
المقياس المعروض = تقييد(المقياس المعتمد × التكبير الحي)

استخدم @GestureState للقيم الحية. توثّق Apple أنه يُعاد ضبطها عندما تصبح الإيماءة غير نشطة. اعتمد كل مكوّن عبر رد النداء الخاص به onEnded، والذي لا يعمل إلا عند اكتمال الإيماءة بنجاح. وهذا يوضح معنى الإلغاء: تخلّص من المعاينة غير المكتملة لذلك المكوّن واحتفظ بقيمته المعتمدة السابقة. GestureState، onEnded(_:)

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

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

إنشاء مثال SwiftUI كامل

أنشئ تطبيق SwiftUI لـ macOS يستهدف macOS 14 أو إصدارًا أحدث. استبدل نقطة دخول التطبيق وعرض المحتوى اللذين أنشأهما المشروع بالملف الواحد التالي؛ وأبقِ تصريحًا واحدًا فقط لـ @main في الهدف. لا حاجة إلى أصول أو حزم أو بيانات خارجية.

تملك اللوحة الرمادية الإيماءة. ويتلقى الشكل التابع لها التحويلات المرئية، لذا لا يؤدي تحريك الشكل أو تدويره إلى تحويل سطح إدخال الإيماءة. يكبّر المثال الشكل ويدوّره عمدًا حول مركزه.

import SwiftUI

@main
struct TransformCanvasApp: App {
    var body: some Scene {
        WindowGroup {
            TransformCanvas()
                .frame(minWidth: 400, minHeight: 300)
        }
        .defaultSize(width: 720, height: 500)
    }
}

struct TransformCanvas: View {
    @State private var offset = CGSize.zero
    @State private var angle = Angle.zero
    @State private var scale: CGFloat = 1

    @GestureState private var liveOffset = CGSize.zero
    @GestureState private var liveAngle = Angle.zero
    @GestureState private var liveScale: CGFloat = 1

    private func boundedScale(_ value: CGFloat) -> CGFloat {
        min(max(value, 0.25), 4)
    }

    private var pan: some Gesture {
        DragGesture(minimumDistance: 3, coordinateSpace: .local)
            .updating($liveOffset) { value, state, _ in
                state = value.translation
            }
            .onEnded { value in
                offset = CGSize(
                    width: offset.width + value.translation.width,
                    height: offset.height + value.translation.height
                )
            }
    }

    private var rotation: some Gesture {
        RotateGesture()
            .updating($liveAngle) { value, state, _ in
                state = value.rotation
            }
            .onEnded { value in
                angle = angle + value.rotation
            }
    }

    private var zoom: some Gesture {
        MagnifyGesture()
            .updating($liveScale) { value, state, _ in
                state = value.magnification
            }
            .onEnded { value in
                scale = boundedScale(scale * value.magnification)
            }
    }

    private var manipulation: some Gesture {
        pan.simultaneously(with: rotation)
            .simultaneously(with: zoom)
    }

    var body: some View {
        ZStack {
            Color.gray.opacity(0.12)

            RoundedRectangle(cornerRadius: 18)
                .fill(Color.blue)
                .overlay(alignment: .topLeading) {
                    Circle()
                        .fill(Color.white)
                        .frame(width: 24, height: 24)
                        .padding(16)
                }
                .frame(width: 180, height: 120)
                .scaleEffect(
                    boundedScale(scale * liveScale),
                    anchor: .center
                )
                .rotationEffect(angle + liveAngle, anchor: .center)
                .offset(CGSize(
                    width: offset.width + liveOffset.width,
                    height: offset.height + liveOffset.height
                ))
                .allowsHitTesting(false)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .contentShape(Rectangle())
        .clipped()
        .gesture(manipulation)
    }
}

توثّق Apple أنواع إيماءات السحب والتكبير والدوران الأساسية، وكيفية إرفاق الإيماءات بالعروض. يجمع المثال بين واجهات برمجة التطبيقات هذه وسياسة صريحة للحالة والإحداثيات. لم تكن SwiftUI متاحة في بيئة البحث، لذا روجع البرنامج بالاستناد إلى التوثيق، لكن لم يُجمّع أو يُشغّل هنا. DragGesture، MagnifyGesture، RotateGesture

لماذا نطبّق المقياس ثم الدوران ثم الإزاحة؟

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

هذا الترتيب خيار تصميم مستمد من مسار التحويل في المثال. توثّق Apple أن scaleEffect يغيّر مقياس الناتج المعروض حول نقطة ارتكاز، وأن rotationEffect يدوّر الناتج المعروض، وأن offset يزيح المحتوى المعروض. لا تحوّل هذه التأثيرات المرئية اللوحة الثابتة إلى حاوية تخطيط متحولة. scaleEffect(_:anchor:)، rotationEffect(_:anchor:)، offset(_:)

الحدّان 0.25 و4 مثالان على إعدادات يحددها التطبيق. يُطبّق التقييد نفسه أثناء المعاينة والاعتماد، لذا يحافظ إفلات الإيماءة على المقياس المعروض. تختار min وmax في Swift القيمتين الأصغر والأكبر المستخدمتين للتعبير عن هذين الحدّين. min(::)، max(::)

التحقق من التفاعل بتوقعات ملموسة

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

  1. اسحب مع النقر على اللوحة، ثم أفلت واسحب مرة أخرى. ينبغي أن يبدأ السحب الثاني من موضع الشكل المعتمد بعد السحب الأول.
  2. استخدم القرص لتكبير الشكل، ثم أفلت واستخدم القرص مرة أخرى. ينبغي أن تضرب كل إيماءة المقياس المحتفظ به.
  3. دوّر الشكل، ثم أفلت ودوّره مرة أخرى. ينبغي أن تتراكم الزاوية.
  4. بعد التكبير والتدوير، اسحب أفقيًا. ينبغي أن يظل الشكل يتحرك على المحور الأفقي للوحة.
  5. عندما تسمح الأجهزة بتداخل المدخلات، أنهِ أحد المكوّنات مع مواصلة الآخر. ينبغي أن يواصل المكوّن المتبقي تحديث خاصيته.
  6. قاطع إيماءة نشطة، مثلًا بالتبديل بين التطبيقات. ينبغي لأي إيماءة تصبح غير نشطة دون اكتمال ناجح أن تتخلّص من معاينتها الحية بدلًا من إبقاء الحالة المؤقتة عالقة.

على سبيل المثال، لنفترض أن المقياس المحتفظ به هو 1.5 وأن إيماءة القرص أبلغت عن 1.2. تكون النتيجة المعروضة 1.8. وإذا كانت الزاوية المحتفظ بها 30 درجة والدوران الحي 15 درجة، فالزاوية المعروضة 45 درجة. وتظل إزاحة سحب مقدارها 40 نقطة إزاحةً على اللوحة بمقدار 40 نقطة، بصرف النظر عن المقياس والزاوية.

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

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

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

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

اجعل لكل تدفق إدخال مالكًا واحدًا. في لوحة رسم AppKit هذه، عالج التمرير عبر scrollWheel(with:) واستخدم معرّفات الإيماءات للتكبير والدوران. مرّر التغييرات الناتجة إلى نموذج الإزاحة والزاوية والمقياس نفسه. تجنّب إرفاق معالج SwiftUI آخر يطبّق المدخلات ذاتها على ذلك النموذج أيضًا.

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

حافظ على اصطلاحات القيم عند الربط. يبلّغ معرّف إيماءة التكبير في AppKit عن مقدار يجب إضافته إلى 1 للحصول على عامل مقياس، بينما يكشف معرّف إيماءة الدوران عن قيمة بالراديان. لا تنسخ إسنادات القيم في SwiftUI كما هي. NSMagnificationGestureRecognizer.magnification، NSRotationGestureRecognizer.rotation

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

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

لعارض السحب مع النقر، يوفّر تطبيق SwiftUI نقطة بداية كاملة: سطح إدخال ثابت واحد، وثلاثة مكوّنات إيماءات تُدار كل منها على حدة، وترتيب مدروس للتحويلات. حافظ على هذه الحدود عند توسيعه، واختر مسار AppKit عندما تتطلب المهمة الموجّهة للقارئ التحريك بالتمرير.

المصادر