لإعادة ترتيب قائمة في تطبيق Mac، مرّر مصدري onMove ووجهته في SwiftUI مباشرةً إلى move(fromOffsets:toOffset:). تشير الوجهة إلى حدّ في المجموعة الأصلية، لذا فإن طرح واحد يدويًا عند النقل إلى الأسفل يؤدي إلى نتيجة خاطئة.

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

ما المقصود بوجهة onMove؟

تعرّف Apple وسيطَي onMove(perform:) على أنهما إزاحتان نسبةً إلى المجموعة الأساسية للعرض الديناميكي. الوسيط الأول هو IndexSet لإزاحات المصدر؛ والثاني هو إزاحة الوجهة. تنتمي هذه المواضع إلى المجموعة التي يستخدمها ForEach المعني. توثيق Apple لـ onMove(perform:).

بالنسبة إلى move(fromOffsets:toOffset:)، تعرّف Apple الوجهة بأنها الإزاحة التي تُدرج العناصر المنقولة قبلها، ويمكن أيضًا أن تعني count نهاية المجموعة. تخيّلها حدًا بين العناصر في المجموعة الأصلية. توثيق Apple لـ move(fromOffsets:toOffset:).

لنفترض أن الترتيب الأصلي هو A, B, C, D, E. إزاحات العناصر هي 0, 1, 2, 3, 4، بينما تمتد حدود الإدراج من 0 إلى 5. يقع الحد 4 مباشرةً قبل E؛ أما الحد 5 فيقع بعد العنصر الأخير.

لذلك، يؤدي نقل B من إزاحة المصدر 1 إلى الوجهة 4 إلى الترتيب A, C, D, B, E. ينتهي العنصر المنقول عند الفهرس 3، مع أن وسيط الوجهة كان 4. تستخدم القيمتان مواضع تبدأ من الصفر؛ لكنهما تصفان مرحلتين مختلفتين من العملية.

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

أنشئ قائمة macOS كاملة قابلة لإعادة الترتيب

استخدم مشروع تطبيق SwiftUI أصليًا لنظام macOS في Xcode. يستهدف المثال الكامل أدناه macOS 11 أو أحدث لأنه يستخدم دورة حياة App في SwiftUI؛ وتشير Apple إلى أن onMove نفسه متاح بدءًا من macOS 10.15. استبدل تعريفَي التطبيق وعرض المحتوى اللذين أنشأهما Xcode بهذه الشفرة، مع إبقاء نقطة دخول @main واحدة فقط في الهدف. توثيق Apple لـ App، توثيق Apple لـ onMove(perform:).

import SwiftUI
import Foundation

struct DemoItem: Identifiable {
    let id: Int
    let label: String

    static let samples: [DemoItem] = [
        DemoItem(id: 0, label: "A"),
        DemoItem(id: 1, label: "B"),
        DemoItem(id: 2, label: "C"),
        DemoItem(id: 3, label: "D"),
        DemoItem(id: 4, label: "E")
    ]
}

@main
struct ReorderDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ReorderView()
        }
    }
}

struct ReorderView: View {
    @State private var items = DemoItem.samples
    @State private var selection = Set<Int>()
    @State private var lastMove = "No move yet"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Drag rows to change their order")

            List(selection: $selection) {
                ForEach(items) { item in
                    Text(item.label)
                        .tag(item.id)
                }
                .onMove(perform: moveItems)
            }

            Text("Order: " + items.map(\.label).joined(separator: ", "))
            Text(lastMove)

            Button("Reset") {
                items = DemoItem.samples
                selection.removeAll()
                lastMove = "No move yet"
            }
        }
        .padding()
        .frame(minWidth: 420, minHeight: 300)
    }

    private func moveItems(from source: IndexSet, to destination: Int) {
        lastMove = "Source: \(Array(source)); destination: \(destination)"
        items.move(fromOffsets: source, toOffset: destination)
    }
}

يعرض ForEach المصفوفة نفسها التي يعدّلها معالج الأحداث. لكل عنصر معرّف ثابت مستقل عن موضعه الحالي. تشترط Apple أن تكون عناصر ForEach قابلة للتعرّف أو أن يُحدَّد مفتاح هوية صريح؛ ويسمح الاحتفاظ بمعرّف كل عنصر أثناء نقله للعرض بمواصلة تمثيل العنصر نفسه. توثيق Apple لـ ForEach.

المصفوفة حالة محلية للعرض. يؤدي تغييرها إلى تحديث SwiftUI للعروض التابعة، بما فيها القائمة وترتيب العناصر المعروض. يحتفظ هذا المثال بالترتيب في الذاكرة ولا يتضمن تنفيذًا للاستمرارية. توثيق Apple لـ State.

شغّل التطبيق، واسحب B إلى الحد الواقع مباشرةً قبل E، ثم افحص الترتيب وعرض الاستدعاء. استخدم Reset قبل تجربة سيناريو آخر. ويدعم ربط التحديد الصريح أيضًا تحديد صفوف متعددة؛ إذ توثق Apple مجموعات التحديد والتحديد على Mac من دون الدخول إلى وضع التحرير. توثيق Apple لـ List.

لا تضف EditButton() إلى مثال macOS الأصلي هذا. تتضمن معلومات التوفر الحالية لدى Apple نظام iOS وMac Catalyst، لكنها تستثني macOS الأصلي. لا تنسخ عناصر التحكم الخاصة بتحرير الهاتف المحمول إلى هدف Mac من دون التحقق من توفرها. توثيق Apple لـ EditButton.

كيف تحسب فهرس الإدراج النهائي؟

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

الحساب هو:

فهرس الإدراج بعد الإزالة = الوجهة الأصلية
                         - عدد إزاحات المصدر الأقل من الوجهة

بالنسبة إلى المصدر {1} والوجهة 4، يسبق عنصر واحد الحد في موضعه الأصلي. تصبح المصفوفة المختزلة A, C, D, E، ويكون فهرس الإدراج 4 - 1 = 3. يؤدي إدراج B عند ذلك الموضع إلى الترتيب A, C, D, B, E.

عند تعدد المصادر، لا يكفي تصحيح واحد للنقل إلى الأسفل. مع المصادر {1, 3} والوجهة 5، تكون العناصر المنقولة B, D، وتصبح المصفوفة المتبقية A, C, E. تسبق إزاحتا مصدر الوجهة 5، لذا يكون فهرس الإدراج 3. والنتيجة هي A, C, E, B, D.

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

ما الحالات الطرفية التي ينبغي اختبارها؟

الحالات التالية حسابات توضيحية للترتيب الأصلي A, B, C, D, E. أعد الترتيب إلى حالته الأصلية قبل كل حالة. يعرض العمود الأخير النتيجة المتوقعة وفق النموذج، بصرف النظر عن شكل مؤشر الإفلات.

إزاحات المصدر الوجهة الأصلية فهرس الإدراج بعد الإزالة الترتيب المتوقع
{1} 4 3 A, C, D, B, E
{3} 1 1 A, D, B, C, E
{1} 5 4 A, C, D, E, B
{1} 1 1 A, B, C, D, E
{1} 2 1 A, B, C, D, E
{1, 3} 5 3 A, C, E, B, D
{1, 3} 3 2 A, C, B, D, E

الحالتان اللتان لا يتغير فيهما ترتيب عنصر واحد مهمتان: إدراج عنصر عند حده الحالي أو عند الحد التالي لا يغيّر ترتيبه. توثق Apple هذا السلوك صراحةً. كما أن حد النهاية صالح، مع أنه لا يمكن استخدامه للوصول إلى عنصر بالفهرس. توثيق Apple لـ move(fromOffsets:toOffset:).

تم التحقق من النموذج الحسابي وراء هذه الحالات السبع باستخدام حساب Python صغير وحتمي. جرى فحص برنامج SwiftUI، لكنه لم يُترجم أو يُشغّل هنا لعدم توفر سلسلة أدوات Swift وبيئة تشغيل macOS. تحقّق من تفاعل السحب الفعلي على إصدار macOS المستهدف.

لماذا قد تستمر الصفوف في الانتقال بشكل غير صحيح؟

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

  1. تحقّق من المجموعة الممرّرة إلى ForEach. إذا كانت تعرض مصفوفة مُرشّحة، فإن إزاحاتها تصف تلك المصفوفة المُرشّحة. مثلًا، إزاحتا الصفين الظاهرين B, D هما 0, 1؛ وسيؤدي تطبيق المصدر 0 على المصفوفة الكاملة إلى نقل A. وتنتج هذه النتيجة عن عقد الاستدعاء الموثق لدى Apple، الذي يجعل الإزاحات نسبةً إلى المجموعة. توثيق Apple لـ onMove(perform:).
  2. تحقّق مما إذا كان العرض يعيد فرز البيانات. إذا كان كل تحديث يعيد الفرز حسب التسمية، فلن يؤدي تغيير ترتيب المصفوفة الأساسية إلى تثبيت ترتيب عرض يدوي. اختر سياسة ترتيب يدوي قبل تفعيل إعادة الترتيب في ذلك العرض.
  3. تحقّق من المعرّفات. احتفظ بمعرّف كل عنصر أثناء نقله. لا تُنشئ المعرّفات من جديد أثناء العرض، ولا تستخدم المواضع المتغيرة كهوية للعناصر القابلة للنقل. توثيق Apple لـ ForEach.
  4. تحقّق من عدم تكرار العملية. أزل أي طرح يدوي من الوجهة، ثم تأكد من أن المعالج يطبّق النقل مرة واحدة على المجموعة المعتمدة. تؤثر عملية نقل ثانية في ترتيب سبق تغييره.

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

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

هل يغيّر AppKit أو TextKit قواعد الوجهة؟

إذا كان تطبيقك يستخدم NSTableView بالفعل، فلدى AppKit عقد إفلات خاص به. تعني NSTableView.DropOperation.above الإدراج فوق صف، بينما يعني .on الإفلات على صف. توثق Apple أن الإفلات أسفل الصف الأخير يمثّل بالصف n مع العملية .above، حيث إن n هو عدد الصفوف. فسّر الصف والعملية معًا قبل تحويل إفلات AppKit إلى نقل في النموذج. توثيق Apple لـ NSTableView.DropOperation.

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

في قائمة Mac بسيطة، احتفظ بمجموعة واحدة مرتبة يدويًا، وحافظ على معرّفات العناصر، ومرّر وسيطَي onMove مباشرةً إلى طريقة النقل الخاصة بالمجموعة. استخدم حساب الحدود ومصفوفة الاختبار عندما تحتاج إلى تشخيص النتيجة أو تحويل العملية إلى نموذج مختلف.

المصادر