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

يبني هذا الدليل لوحة SwiftUI صغيرة تحتوي على دائرة اصطناعية واحدة محفوظة في الذاكرة. تتمثل المهمة المحددة في جعل كل عملية سحب مكتملة تتطلب تراجعًا واحدًا بالضبط، مهما بلغ عدد تحديثات الحركة. كما يشرح كيفية الحفاظ على الحدود نفسها في عرض AppKit.

لماذا لا ينبغي أن تظل مجموعة التراجع مفتوحة أثناء السحب

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

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

استخدم التصميم التالي لهذه اللوحة. إنه خيار تنفيذي مستمد من الحدود الموثقة أعلاه:

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

يسمح هذا النهج للدائرة بالتحرك باستمرار دون التعامل مع كل إطار معاينة بوصفه تعديلًا دائمًا. ويصبح السحب المكتمل عملية نموذج واحدة ذات اسم.

اختر الجهة المالكة لسجل التراجع

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

قد يكون لدى محرر مستندات موجود مدير تراجع خاص بالمستند. تتيح SwiftUI المدير السياقي عبر @Environment(\.undoManager)، وتوضح Apple أن القيمة قد تكون nil عندما لا يدعم السياق التراجع. في هذه البنية، واصل استخدام مدير المستند وسياسة التجميع المعتمدة لديه؛ ولا تنسخ إعداد المدير الخاص في هذا المثال إلى مدير مشترك. راجع EnvironmentValues.undoManager.

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

أنشئ مثال SwiftUI كاملًا

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

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

ضع النموذج الكامل التالي في ShapeModel.swift:

import Foundation
import Combine

@MainActor
final class ShapeModel: ObservableObject {
    @Published private(set) var center = CGPoint(x: 120, y: 100)
    @Published private(set) var canUndo = false
    @Published private(set) var canRedo = false

    private let history: UndoManager

    init() {
        let manager = UndoManager()
        manager.groupsByEvent = false
        history = manager
    }

    func commitDrag(translation: CGSize) {
        let destination = CGPoint(
            x: center.x + translation.width,
            y: center.y + translation.height
        )
        guard destination != center else { return }

        // This owner expects no caller-owned group.
        precondition(history.groupingLevel == 0)
        history.beginUndoGrouping()
        defer {
            history.endUndoGrouping()
            refreshAvailability()
        }

        replaceCenter(with: destination)
    }

    private func replaceCenter(with destination: CGPoint) {
        let previous = center
        guard previous != destination else { return }

        history.registerUndo(withTarget: self) { target in
            target.replaceCenter(with: previous)
        }
        history.setActionName("Move Shape")
        center = destination
    }

    func undo() {
        precondition(history.groupingLevel == 0)
        guard history.canUndo else { return }
        history.undo()
        refreshAvailability()
    }

    func redo() {
        precondition(history.groupingLevel == 0)
        guard history.canRedo else { return }
        history.redo()
        refreshAvailability()
    }

    private func refreshAvailability() {
        canUndo = history.canUndo
        canRedo = history.canRedo
    }
}

يوضع defer عمدًا في النطاق المتزامن نفسه مع beginUndoGrouping(). تنفذ Swift الإجراءات المؤجلة عند خروج التنفيذ من ذلك النطاق، بما في ذلك عند العودة المبكرة أو وقوع خطأ. ولا يمكن أن يمتد هذا إلى استدعاءات منفصلة لإيماءة السحب. يضمن هذا الاقتران توازن المجموعة في النموذج عند الخروج من مسارات التحكم المعتادة. راجع تدفق التحكم — الإجراءات المؤجلة.

يعمل إغلاق التراجع على معامل target الخاص به بدلًا من التقاط self. توضح Apple أن المدير يحتفظ بمرجع غير مملوك إلى الهدف، وتوصي باستخدام المعامل المقدّم لتجنب دورات الاحتفاظ. تحتفظ اللوحة بالنموذج أثناء استخدامه، ويملك النموذج سجل التراجع الخاص به. راجع registerUndo(withTarget:handler:).

ضع التطبيق واللوحة في ShapeUndoApp.swift:

import SwiftUI

@main
@MainActor
struct ShapeUndoApp: App {
    var body: some Scene {
        WindowGroup {
            ShapeCanvas()
        }
    }
}

@MainActor
struct ShapeCanvas: View {
    @StateObject private var model = ShapeModel()
    @GestureState private var previewTranslation: CGSize? = nil

    private var drag: some Gesture {
        DragGesture(
            minimumDistance: 1,
            coordinateSpace: .named("board")
        )
        .updating($previewTranslation) { value, state, _ in
            state = value.translation
        }
        .onEnded { value in
            model.commitDrag(translation: value.translation)
        }
    }

    var body: some View {
        let translation = previewTranslation ?? .zero
        let isDragging = previewTranslation != nil

        VStack(spacing: 16) {
            ZStack(alignment: .topLeading) {
                Color.clear

                Circle()
                    .fill(Color.blue)
                    .frame(width: 44, height: 44)
                    .position(
                        x: model.center.x + translation.width,
                        y: model.center.y + translation.height
                    )
                    .gesture(drag)
            }
            .frame(width: 420, height: 300)
            .background(Color.gray.opacity(0.12))
            .coordinateSpace(name: "board")
            .clipped()

            HStack {
                Button("Undo") {
                    guard previewTranslation == nil else { return }
                    model.undo()
                }
                .disabled(isDragging || !model.canUndo)

                Button("Redo") {
                    guard previewTranslation == nil else { return }
                    model.redo()
                }
                .disabled(isDragging || !model.canRedo)
            }
        }
        .padding(20)
    }
}

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

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

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

تحقّق من سحب واحد وتراجع واحد وإعادة ناجحة

استخدم إحداثيات توضيحية لمراجعة الحساب. بدءًا من (120, 100)، تنتج الإزاحات التراكمية (10, 5) و(25, 12) و(40, 20) مراكز معاينة هي (130, 105) و(145, 112) و(160, 120). يبقى المركز المعتمد عند (120, 100) حتى الإكمال، ثم يتغير مرة واحدة إلى (160, 120).

يستدعي التراجع الضابط المسجل بالقيمة (120, 100). وقبل استعادتها، يلتقط الضابط القيمة (160, 120) ويسجل ذلك الموضع للإعادة. لا تمنع التسجيل داخل رد نداء التراجع؛ فهذا التسجيل المتبادل هو ما يحافظ على إمكانية الإعادة. تصف Apple هذا السلوك في تسجيل عمليات التراجع.

لا ينشئ الرمز مجموعة صريحة إضافية داخل الضابط القابل للعكس. يتحكم UndoManager في التجميع أثناء إعادة تشغيل السجل؛ وتوضح Apple أن redo() يسجل العمليات المعاد تشغيلها في مكدس التراجع كمجموعة واحدة. يُعيّن اسم الإجراء عبر setActionName باستخدام التسمية الاصطناعية Move Shape. راجع redo() وsetActionName(_:).

تحقّق من السلوكيات التالية في تطبيق Mac:

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

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

طبّق الحدود نفسها على لوحة AppKit

للعرض الحالي NSView، حافظ على تسلسل أحداث AppKit. يعرّف المرجع الحالي من Apple mouseDragged(with:) بأنه الحركة أثناء الضغط على الزر الأيسر، وmouseUp(with:) بأنه تحرير ذلك الزر. ويصف دليل الأحداث المؤرشف التسلسل ذي الطرق الثلاث وتحويل الإحداثيات اللازم لعرض مخصص. راجع mouseDragged(with:) وmouseUp(with:) وHandling Mouse Events.

طبّق التصميم نفسه على هذه الطرق:

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

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

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

شخّص إخفاقات التجميع دون إغلاق مجموعة يملكها طرف آخر

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

إذا أدى التجميع إلى استثناء، فتحقق من الملكية قبل تغيير عدد مرات الإغلاق. توضح Apple أن groupingLevel == 0 تعني عدم وجود مجموعة مفتوحة، وأن undo() تثير استثناءً عند بقاء أكثر من مجموعة مفتوحة. قد تغلق حلقة تستمر في إغلاق المجموعات حتى يصل المستوى إلى الصفر مجموعات يملكها مستدعٍ آخر. استخدم المستوى للتشخيص، والتزم بعقد الجهة المالكة. راجع groupingLevel وundo().

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

المصادر