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

المهمة هي إعادة ترتيب المجموعات داخل تطبيقك: مثلًا، نقل Group A، التي تحتوي على Tile A1 وTile A2، إلى ما بعد Group B. يستخدم المثال بيانات محلية اصطناعية، ويوفر زري Move Up وMove Down صريحين إلى جانب استدعاء رد SwiftUI الخاص بتحريك القائمة.

ما الذي ينبغي اعتباره عنصرًا واحدًا قابلًا للنقل؟

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

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

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

عرض SwiftUI المسمى Group ليس نموذج التطبيق المستخدم أدناه. فتسمية حاوية أو عرض عنوان لا يحددان سجلات العناصر التابعة التي ينبغي أن تنتقل معها. تجعل مصفوفة children المخزنة في العنصر الأصلي هذه العلاقة واضحة.

إنشاء مثال كامل باستخدام SwiftUI

أنشئ مشروع تطبيق macOS في Xcode باستخدام واجهة SwiftUI وهدف نشر macOS 12 أو أحدث. استبدل ملف نقطة دخول التطبيق المُنشأ بالبرنامج الكامل أدناه، مع الإبقاء على تصريح واحد فقط للتطبيق @main في الهدف. يستخدم المثال عمدًا قائمة بسيطة وأزرارًا نصية لتسهيل معاينة النقل.

import Foundation
import SwiftUI

private struct DemoItem: Identifiable {
    let id: String
    let title: String
}

private struct DemoGroup: Identifiable {
    let id: String
    let title: String
    let children: [DemoItem]
}

@main
struct GroupMoveDemoApp: App {
    var body: some Scene {
        WindowGroup {
            GroupCollectionView()
        }
    }
}

private struct GroupCollectionView: View {
    @State private var groups: [DemoGroup] = [
        DemoGroup(
            id: "group-a", title: "Group A",
            children: [
                DemoItem(id: "a1", title: "Tile A1"),
                DemoItem(id: "a2", title: "Tile A2")
            ]
        ),
        DemoGroup(
            id: "group-b", title: "Group B",
            children: [
                DemoItem(id: "b1", title: "Tile B1")
            ]
        ),
        DemoGroup(
            id: "group-c", title: "Group C",
            children: []
        )
    ]

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Arrange Groups")
                .font(.title2)
                .padding(.horizontal)

            List {
                ForEach(groups) { group in
                    VStack(alignment: .leading, spacing: 8) {
                        HStack {
                            Text(group.title).font(.headline)
                            Spacer()
                            Button("Move Up") {
                                moveOne(id: group.id, down: false)
                            }
                            .disabled(group.id == groups.first?.id)

                            Button("Move Down") {
                                moveOne(id: group.id, down: true)
                            }
                            .disabled(group.id == groups.last?.id)
                        }
                        .buttonStyle(BorderlessButtonStyle())

                        ForEach(group.children) { child in
                            Text(child.title)
                                .padding(.leading, 16)
                        }
                    }
                    .padding(.vertical, 8)
                }
                .onMove(perform: moveGroups)
            }
        }
        .padding(.vertical)
        .frame(minWidth: 520, minHeight: 320)
    }

    private func moveGroups(from source: IndexSet, to destination: Int) {
        withAnimation {
            groups.move(fromOffsets: source, toOffset: destination)
        }
    }

    private func moveOne(id: String, down: Bool) {
        guard let index = groups.firstIndex(where: { $0.id == id }) else {
            return
        }
        let neighbor = down ? index + 1 : index - 1
        guard groups.indices.contains(neighbor) else {
            return
        }
        let destination = down ? index + 2 : index - 1
        moveGroups(from: IndexSet(integer: index), to: destination)
    }
}

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

شغّل التطبيق وانقر على Move Down بجانب Group A. سيصبح الترتيب المتوقع Group B، ثم Group A، ثم Group C، مع بقاء Tile A1 وTile A2 معروضتين أسفل Group A. انقر على Move Up بجانب تلك المجموعة لاستعادة الترتيب الأولي. وتوضح Group C أيضًا أن المجموعة التي لا تحتوي على عناصر تابعة تظل قابلة للنقل.

يحتفظ هذا البرنامج بالترتيب في الذاكرة طوال عمر العرض الحالي؛ ولا يتضمن طبقة لحفظ البيانات. جرت مراجعة المراجع ومنطق النقل، لكن لم يُترجم البرنامج هنا لأن الصدفة المتاحة لا تتضمن مترجم Swift. أنشئ التطبيق وتحقق من الواجهة في Xcode على Mac قبل دمج هذا المثال.

لماذا يستخدم النقل إلى الأسفل وجهة مختلفة؟

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

في ترتيب البداية التوضيحي [A, B, C]، توضح هذه العمليات المستقلة الفرق:

النتيجة المطلوبة إزاحة المصدر الوجهة الممررة إلى move النتيجة
نقل A إلى ما بعد B 0 2 [B, A, C]
نقل B إلى ما قبل A 1 0 [B, A, C]
نقل A إلى النهاية 0 3 [B, C, A]
إبقاء A في مكانه 0 1 [A, B, C]

لهذا يستخدم زر النقل إلى الأسفل index + 2: فهو يُدرج العنصر قبل العنصر الذي يلي الجار التالي، أو في النهاية. ويستخدم زر النقل إلى الأعلى index - 1. أما الإزاحات التي يوفرها onMove مباشرةً، فمررها إلى طريقة المجموعة دون إعادة تطبيق هذا الحساب الخاص بالأزرار.

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

أي واجهة تحتاج إلى تنفيذ مختلف؟

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

متطلب الواجهة نقطة بداية مناسبة مسؤولية النقل
يشكل عنوان المجموعة وعناصرها التابعة كتلة واحدة قابلة للنقل ForEach خارجي مع سطر مركّب واحد لكل مجموعة إعادة ترتيب مصفوفة العناصر الأصلية
بنية هرمية قابلة للتوسيع ذات عناصر متفرعة متداخلة OutlineGroup تنفيذ نقل النموذج لمجموعة العناصر الأصلية المقصودة
مجموعة AppKit ذات قسم لكل مجموعة NSCollectionView تحديث مصدر البيانات، ثم إعلام عرض المجموعة بنقل القسم
نص مخصص قابل للتحرير داخل عنصر TextKit ضمن محرر العنصر إبقاء ترتيب المجموعات في نموذج المجموعة المحيط

تحتاج الأشجار القابلة للتوسيع إلى قواعد نقل صريحة

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

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

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

يدعم AppKit نقل قسم مع العناصر التي يحتوي عليها

عندما تكون واجهتك NSCollectionView ويمثل كل قسم مجموعة، توفر Apple moveSection(_:toSection:). وتشمل وثائقها صراحةً إعادة تنظيم الأقسام والعناصر التي تحتوي عليها، وتتطلب تحديث مصدر البيانات قبل استدعائها. تتوفر الواجهة البرمجية على macOS 10.11 والإصدارات الأحدث. وثائق Apple لنقل الأقسام.

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

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

يتولى TextKit تخطيط النص داخل العنصر

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

كيف تشخّص تحرك المجموعة على نحو غير صحيح؟

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

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

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

في واجهة Mac مدمجة، ابدأ بنموذج أصلي واحد لكل سطر قابل للنقل، وبوظيفة مشتركة واحدة لإعادة الترتيب. عندئذٍ يمكن للمستخدم نقل Group A بإجراء واحد مع بقاء عناصرها التابعة مرتبطة بها. أضف تنفيذًا هرميًا أو تنفيذًا باستخدام AppKit عندما تحتاج الواجهة إلى تفاعل مستقل مع العناصر التابعة أو إلى سلوك مجموعة قائم على الأقسام.

المصادر