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

اختر نمط التفاعل قبل كتابة الإيماءة

مهمة القارئ واضحة: إضافة محدد أحجام أفقي مخصص إلى تطبيق Mac. الأحجام المتاحة هي 20 و40 و60 و80 و100 نقطة. يعرض السحب معاينة لاختيار مثبت عند قيمة محددة؛ وعند الإفلات، يُطبَّق هذا الحجم على دائرة اصطناعية. تظل جميع البيانات في الذاكرة.

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

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

تحدد ثلاثة قرارات هذا المثال:

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

حوّل موضع المؤشر إلى فهرس منفصل

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

تتكون عملية التحويل من أربع خطوات: اطرح الهامش الأيسر، واقسم على طول المسار القابل للاستخدام، واحصر النتيجة ضمن النطاق 0...1، ثم اضربها في عدد الفواصل قبل التقريب. تتضمن الخيارات الخمسة أربعة فواصل. ويحدد التحويل العكسي موضع المقبض المختار عند علامة التدريج المطابقة.

المساعد التالي جزء من التطبيق الكامل الذي سنركبه في القسم التالي. تختار سياسة التقريب فيه الفهرس الأعلى عند نقطة المنتصف تمامًا، لأن الفهارس المعيارية غير سالبة. توثّق Apple أن .toNearestOrAwayFromZero يقرّب إلى أقرب قيمة، مع إبعاد حالات التعادل عن الصفر. توثيق rounded(_:) من Apple

import SwiftUI

enum SnapGeometry {
    static func index(
        at x: CGFloat,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> Int {
        guard count > 1, x.isFinite, width > 2 * inset else {
            return 0
        }
        let fraction = min(max((x - inset) / (width - 2 * inset), 0), 1)
        return Int(
            (fraction * CGFloat(count - 1))
                .rounded(.toNearestOrAwayFromZero)
        )
    }

    static func position(
        for index: Int,
        width: CGFloat,
        inset: CGFloat,
        count: Int
    ) -> CGFloat {
        guard count > 1 else { return width / 2 }
        return inset + CGFloat(index) / CGFloat(count - 1)
            * (width - 2 * inset)
    }
}

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

الإحداثي السيني للمؤشر الفهرس المختار الحجم بالنقاط
-10 0 20
12 0 20
51 1 40
90 1 40
168 2 60
324 4 100
350 4 100

عند 51، يكون المؤشر في منتصف المسافة تمامًا بين علامتي التدريج الأولى والثانية، لذا تختار القاعدة الفهرس 1. تحققت من هذه الحسابات الحتمية باستخدام Python. لم يكن مترجم Swift متاحًا في بيئة البحث، لذلك راجعت تطبيق SwiftUI أدناه، لكنني لم أتمكن من تجميعه أو تشغيله هنا.

أنشئ عنصر التحكم في macOS والعرض الذي يملكه

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

يملك العرض الأب الفهرس المعتمد في @State. ويتلقى عنصر التحكم قيمة @Binding تربطه بمصدر الحقيقة هذا من دون إنشاء اختيار دائم ثانٍ. يتبع ذلك نمط الملكية الذي توثقه Apple للروابط. توثيق Binding من Apple

@main
struct SnapSizeApp: App {
    var body: some Scene {
        WindowGroup {
            SizeDemo()
        }
    }
}

struct SizeDemo: View {
    private let sizes = [20, 40, 60, 80, 100]
    @State private var selectedIndex = 2

    var body: some View {
        VStack(spacing: 24) {
            Text("Circle size")
                .font(.headline)

            Circle()
                .fill(Color.accentColor)
                .frame(
                    width: CGFloat(sizes[selectedIndex]),
                    height: CGFloat(sizes[selectedIndex])
                )
                .frame(width: 120, height: 120)

            DiscreteSizeControl(
                sizes: sizes,
                selection: $selectedIndex
            )

            Text("Applied size: \(sizes[selectedIndex]) pt")
        }
        .padding(32)
        .frame(minWidth: 420, minHeight: 340)
    }
}

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

struct DiscreteSizeControl: View {
    let sizes: [Int]
    @Binding var selection: Int
    @GestureState private var previewIndex: Int? = nil

    private let trackWidth: CGFloat = 336
    private let radius: CGFloat = 12

    private var shownIndex: Int {
        previewIndex ?? selection
    }

    private func snappedIndex(at x: CGFloat) -> Int {
        SnapGeometry.index(
            at: x, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func xPosition(for index: Int) -> CGFloat {
        SnapGeometry.position(
            for: index, width: trackWidth,
            inset: radius, count: sizes.count
        )
    }

    private func moveSelection(by amount: Int) {
        guard previewIndex == nil else { return }
        selection = min(max(selection + amount, 0), sizes.count - 1)
    }

    var body: some View {
        VStack(spacing: 10) {
            Text("Selected size: \(sizes[shownIndex]) pt")
                .monospacedDigit()

            ZStack(alignment: .topLeading) {
                Capsule()
                    .fill(Color.secondary.opacity(0.25))
                    .frame(width: trackWidth - 2 * radius, height: 4)
                    .position(x: trackWidth / 2, y: 20)

                ForEach(sizes.indices, id: \.self) { index in
                    Circle()
                        .fill(Color.secondary)
                        .frame(width: 5, height: 5)
                        .position(x: xPosition(for: index), y: 20)
                }

                Circle()
                    .fill(Color.accentColor)
                    .frame(width: 2 * radius, height: 2 * radius)
                    .position(x: xPosition(for: shownIndex), y: 20)
            }
            .frame(width: trackWidth, height: 40)
            .contentShape(Rectangle())
            .gesture(
                DragGesture(minimumDistance: 0, coordinateSpace: .local)
                    .updating($previewIndex) { value, state, _ in
                        state = snappedIndex(at: value.location.x)
                    }
                    .onEnded { value in
                        selection = snappedIndex(at: value.location.x)
                    }
            )
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("Circle size")
            .accessibilityValue("\(sizes[shownIndex]) points")
            .accessibilityAdjustableAction { direction in
                switch direction {
                case .increment:
                    moveSelection(by: 1)
                case .decrement:
                    moveSelection(by: -1)
                @unknown default:
                    break
                }
            }

            HStack {
                Button("Smaller") { moveSelection(by: -1) }
                    .disabled(selection == 0 || previewIndex != nil)
                Button("Larger") { moveSelection(by: 1) }
                    .disabled(
                        selection == sizes.count - 1 || previewIndex != nil
                    )
            }
        }
    }
}

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

اربط الإيماءة بهندسة ثابتة لعنصر التحكم

تُربط عملية السحب بحاوية المسار الثابتة التي يبلغ عرضها 336 نقطة. ويتحرك المقبض داخل تلك الحاوية. تعرّف Apple location بأنه موضع الحدث الحالي، وتتيح لـ DragGesture تحديد مساحة الإحداثيات المستخدمة للمواضع. يؤدي استخدام .local على الحاوية الثابتة إلى جعل الإحداثي السيني المبلّغ عنه متوافقًا مع حسابات الرسم. توثيق DragGesture من Apple، توثيق location من Apple

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

للتفاعل باستخدام المقبض وحده مع الحفاظ على إزاحة الإمساك الأولية، استخدم تحويلًا مختلفًا: التقط مركز المقبض عند بدء الإيماءة مرة واحدة، ثم أضف إليه الإزاحة الأفقية الكلية للإيماءة قبل تثبيت القيمة. لا تضف الإزاحة الكلية مرارًا إلى مركز تم تحديثه بالفعل. توثّق Swift أن الإزاحة تمثل الحركة من بداية الإيماءة، لا الحركة منذ استدعاء رد النداء السابق. توثيق translation من Apple

أبقِ الإيماءة بعيدة عن مجموعة الملصقات والأزرار المحيطة. يؤثر إرفاق الإيماءة في علاقات التعرّف مع العرض المرفقة به وعروضه الفرعية؛ وقد يؤدي توسيع هذا النطاق إلى تنافس مع عناصر تحكم أخرى. إذا أدرجت هذا المثال في عرض قابل للتمرير، فتحقق من التعرّف هناك على وجه التحديد. السحب بمسافة صفرية تفاعل فوري مقصود، لذا تُعد اللوحة المستقلة نقطة بداية أوضح. توثيق gesture(_:including:) من Apple

حدّد متى تتحول المعاينة إلى حالة معتمدة

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

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

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

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

عندما يتولى عرض AppKit التفاعل

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

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

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

تحقّق من الحدود والعتبات وعمليات السحب المنقطعة

تحقق من التفاعل في تطبيق Mac باتباع تسلسل قصير ومحدد:

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

مع خيارات غير متساوية مثل 20 و30 و60 و100، يظل هذا التصميم المعتمد على الفهرس يباعد بين الخيارات بالتساوي. وهذا ترتيب رتبي مقصود. إذا كان ينبغي أن تعكس المسافات الفروق الرقمية، فحوّل كل قيمة رقمية إلى موضعها الخاص على المسار واختر الموضع الأقرب؛ إذ إن تقريب فهرس موحّد سيعبّر عن تفاعل مختلف.

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

المصادر