سجّل عمليات التراجع لدى مخزن نموذج طويل العمر، ودع العروض ترسل التعديلات عبر أساليب المخزن. في محرر أشكال لنظام Mac، أبقِ معاينات السحب محلية، ونفّذ التغيير مرة واحدة عند الإفلات، واجعل المخزن يسجّل الإجراء العكسي الذي يتيح الإعادة أيضًا.
يبني هذا الدليل تطبيقًا صغيرًا لنظام macOS باستخدام SwiftUI، ويحتوي على مستطيل اصطناعي واحد. يمكنك تحريكه، وإخفاء محرره، والتراجع عن الحركة، ثم إظهار المحرر مجددًا. تتمثل المهمة المحددة في الحفاظ على سلوك التراجع عندما يختفي عرض التحرير بينما يظل نموذجه قائمًا. كل شيء يبقى في الذاكرة.
لماذا ينبغي أن يتلقى المخزن عملية التراجع؟
تتطلب طريقة التسجيل المعتمدة على الإغلاق من Apple هدفًا من نوع مرجعي. وتوضح Apple أيضًا أن مدير التراجع يحتفظ بمرجع غير مملوك إلى ذلك الهدف. لذلك، لا يؤدي تسجيل إجراء إلى ضمان بقاء الهدف حيًا. وتوصي Apple باستخدام معامل الهدف الخاص بمعالج الإغلاق بدلًا من التقاط مرجع آخر إلى الهدف نفسه. وثائق Apple لتسجيل التراجع.
المخزن الذي يملك الحالة الجاري تحريرها هدف مناسب. يضمن مالك ثابت بقاء هذا المخزن، بينما تعرضه العروض المؤقتة وتطلب إجراء التغييرات. هنا، يملك العرض الجذر للنافذة المخزن باستخدام @StateObject، بينما تراقبه لوحة الرسم القابلة للإزالة. تحافظ SwiftUI على كائن الحالة طوال عمر الحاوية التي تعرّفه، لكن تغيير هوية الحاوية قد ينشئ نسخة جديدة. وثائق Apple لـ StateObject.
يصف تعبير «الإجراءات العكسية التي يملكها المخزن» موضع وجود منطق العكس. يظل مدير التراجع هو من يسجل عمليات الاستدعاء وينظم تنفيذها. ويوفر المخزن القيمة القديمة والأسلوب الذي يستعيدها. يتوافق هذا التقسيم مع البنية التي توثقها Apple، والتي تسمح بأن يكون مستقبِل التراجع إما الكائن الذي تغير أو مالكه. دليل Apple لمدير التراجع.
| المسؤولية | المالك في هذا المثال |
|---|---|
| موضع المستطيل المعتمد | مخزن النموذج |
| تسجيل الإجراء العكسي وتغيير الحالة | أسلوب في مخزن النموذج |
| عمر المخزن | جذر نافذة ثابت |
| إزاحة السحب المؤقتة | حالة إيماءة لوحة الرسم |
| سجل التراجع والتنفيذ | مدير التراجع في البيئة |
| اختيار المدير المناسب | العرض عند حدود التفاعل |
تنفيذ تغيير قابل للعكس في المخزن
أنشئ تطبيق SwiftUI لنظام macOS، وحدد هدف نشر macOS 13 أو أحدث، واستخدم حزمة تطوير Apple حالية. تشكل الملفات الثلاثة التالية المثال كاملًا؛ استبدل نقطة دخول التطبيق وعرض المحتوى اللذين أُنشئا تلقائيًا، ولا تُبقِ على تعريف ثانٍ لـ @main. لا تتوفر في بيئة البحث هذه مُصرّفات Swift وأطر عمل macOS، لذا جرت مراجعة هذا المثال لكنه لم يُصرّف هنا.
أنشئ ShapeStore.swift. يحتوي المخزن على موضع معتمد واحد وأسلوب تغيير واحد. يوفّر التقاط الموضع السابق قبل الإسناد المعلومات اللازمة لعكس التعديل.
import Foundation
import Combine
@MainActor
final class ShapeStore: ObservableObject {
@Published private(set) var position = CGPoint(x: 120, y: 100)
func move(to newPosition: CGPoint, undoManager: UndoManager?) {
let previousPosition = position
guard newPosition != previousPosition else { return }
if let undoManager {
undoManager.registerUndo(withTarget: self) {
[weak undoManager] store in
store.move(
to: previousPosition,
undoManager: undoManager
)
}
undoManager.setActionName("Move Rectangle")
}
position = newPosition
}
}
يستدعي الإجراء العكسي الأسلوب نفسه. لنفترض أن الموضع تغيّر من (120, 100) إلى (160, 100). يطلب الاستدعاء المسجل (120, 100). وعندما ينفذ التراجع ذلك الاستدعاء، يرى الأسلوب (160, 100) بوصفه القيمة السابقة، ويسجل عملية الإرجاع. توضح Apple أن عمليات التسجيل التي تتم أثناء التراجع تصبح عمليات إعادة. وتجاوز التسجيل كلما كانت isUndoing صحيحة سيكسر هذا النمط. دليل Apple لمدير التراجع.
يعمل الإغلاق على معامل store الخاص به، ويحتفظ بمرجع ضعيف إلى المدير. يمنع ذلك الإغلاق المخزّن لدى المدير من الاحتفاظ بمرجع قوي يعيد إليه. توضح وثائق Swift لإدارة المراجع أن المراجع الضعيفة لا تُبقي النسخة المشار إليها حية، وتصبح nil عند إلغاء تخصيصها. لغة البرمجة Swift: العد التلقائي للمراجع.
عُزل المخزن وأسلوب التغيير الخاص به على الممثل الرئيسي. ويتوافق ذلك مع وثائق Apple الحالية: UndoManager ومعالج التسجيل معزولان على الممثل الرئيسي. ينفذ الأسلوب التسجيل والتغيير على نحو متزامن، ليبقي هذا التعديل الصغير ضمن التفاعل الذي استدعاه. وثائق Apple لـ UndoManager.
أبقِ المخزن أعلى من محرر يمكن إزالته
أنشئ StoreUndoDemoApp.swift. يملك الجذر المخزن بصرف النظر عن ظهور لوحة الرسم. وتقرأ أزراره مدير البيئة الحالي عند تنفيذ الإجراء. وتتلقى لوحة الرسم المدير نفسه لإجراء تعديلاتها.
import SwiftUI
@main
struct StoreUndoDemoApp: App {
var body: some Scene {
WindowGroup {
EditorRoot()
}
}
}
@MainActor
struct EditorRoot: View {
@StateObject private var store = ShapeStore()
@State private var showsEditor = true
@Environment(\.undoManager) private var undoManager
var body: some View {
VStack(spacing: 16) {
HStack {
Button("Move Right") {
store.move(
to: CGPoint(
x: store.position.x + 20,
y: store.position.y
),
undoManager: undoManager
)
}
Button("Undo") {
if let undoManager, undoManager.canUndo {
undoManager.undo()
}
}
Button("Redo") {
if let undoManager, undoManager.canRedo {
undoManager.redo()
}
}
Toggle("Show Editor", isOn: $showsEditor)
}
Text(
"Position: \(Int(store.position.x)), " +
"\(Int(store.position.y))"
)
if showsEditor {
ShapeCanvas(store: store, undoManager: undoManager)
} else {
Text("Editor hidden")
.frame(width: 400, height: 260)
}
}
.padding(20)
.frame(width: 620, height: 380)
}
}
مدير التراجع في بيئة SwiftUI اختياري. وتسمح Apple صراحةً بتجاوز تسجيل التراجع عندما توفر البيئة nil. يظل المستطيل يتحرك في هذه الحالة ضمن هذا المثال، لكن لا يُنشأ أي إدخال للتراجع. إذا غابت عملية تراجع متوقعة، فتحقق مما إذا كان التفاعل قد تلقى مديرًا قبل تغيير منطق الإجراء العكسي. وثائق Apple لـ undoManager في البيئة.
كما أن تمرير المدير إلى كل تغيير يجعل سياق النافذة واضحًا. لا يحتفظ المخزن بمدير النافذة الذي صادفه أولًا. ولكل نافذة في هذا المثال مخزنها الخاص. إذا عدّلت عدة نوافذ لاحقًا نموذجًا مشتركًا، فحدّد أي سجل يملك تلك التعديلات قبل إعادة استخدام هذا الترتيب عبر النوافذ.
نفّذ عملية سحب واحدة من دون تمديد مجموعة التراجع عبر الأحداث
أنشئ ShapeCanvas.swift. أثناء السحب، يتضمن الموضع المعروض للمستطيل إزاحة مؤقتة. ولا يتغير موضعه المعتمد في المخزن إلا عند اكتمال الإيماءة بنجاح. أكمل السحب قبل تنفيذ تعديل آخر أو أمر تراجع؛ يفترض هذا المثال الموجز أن التفاعلات متتابعة، ولا ينسّق بين الأوامر المتزامنة أثناء السحب.
import SwiftUI
@MainActor
struct ShapeCanvas: View {
@ObservedObject var store: ShapeStore
let undoManager: UndoManager?
@GestureState private var translation: CGSize = .zero
var body: some View {
ZStack(alignment: .topLeading) {
Color.gray.opacity(0.12)
Rectangle()
.fill(Color.blue)
.frame(width: 70, height: 50)
.position(
x: store.position.x + translation.width,
y: store.position.y + translation.height
)
.gesture(
DragGesture()
.updating($translation) { value, state, _ in
state = value.translation
}
.onEnded { value in
store.move(
to: CGPoint(
x: store.position.x
+ value.translation.width,
y: store.position.y
+ value.translation.height
),
undoManager: undoManager
)
}
)
}
.frame(width: 400, height: 260)
.clipped()
}
}
تستخدم الإيماءة إزاحتها النهائية لتنفيذ الحركة. ولا تعتمد على توقيت إعادة تعيين خاصية المعاينة. توضح Apple أن updating(_:body:) يعيد تعيين حالة الإيماءة عند انتهائها أو إلغائها، بينما لا يعمل onEnded(_:) إلا بعد اكتمالها بنجاح. لذلك، لا تترك الإيماءة الملغاة في هذا التصميم حركة معتمدة أو إجراء تراجع جديدًا. وثائق Apple لتحديث الإيماءات، وثائق Apple لاكتمال الإيماءات.
يحافظ هذا على ثلاث حدود منفصلة: يغيّر تحديث المعاينة حالة العرض المؤقتة؛ ويطلب الاكتمال الناجح تعديلًا واحدًا للنموذج؛ ويسجل المخزن الإجراء العكسي لذلك التعديل. أما استدعاء تغيير المخزن عند كل تحديث للسحب، فسيسجل المواضع الوسيطة طوال الإيماءة.
تجمع آلية تجميع الأحداث الافتراضية لدى Apple عمليات التسجيل حول دورات حلقة التشغيل. وقد تنتج الإيماءة تحديثات عبر عدة دورات، لذا لا يعبّر تجميع الأحداث وحده عن المعاملة المطلوبة للسحب بأكمله. نتجنب هذه المشكلة هنا بتنفيذ التغيير مرة واحدة، من دون تعديل إعدادات المدير المشترك. وثائق Apple لـ groupsByEvent.
إذا أضفت لاحقًا أمرًا يغيّر عدة عناصر بشكل متزامن، فيمكن لمجموعة تراجع صريحة ومتوازنة أن تجمع إجراءاته العكسية. أبقِ بدايتها ونهايتها ضمن ذلك الأمر. وإذا أصبح تغيير النموذج مباشرة أثناء السحب ضروريًا، فصمّم عمليات منفصلة للبدء والتحديث والتنفيذ والإلغاء، مع تحديد كيفية تفاعل الأوامر الأخرى مع التعديل النشط. يتعمد نهج المعاينة فقط هنا إبقاء آليات المعاملات الإضافية خارج المخزن.
تحقّق من الملكية والإعادة وتوجيه النافذة كلًّا على حدة
نفّذ التسلسل التالي في التطبيق المشغّل. الإحداثيات قيم توضيحية متوقعة استنادًا إلى الحالة الأولية وزيادة الزر، وليست نتائج مقاسة.
| الإجراء | الموضع المعتمد أو النتيجة المتوقعة |
|---|---|
| افتح نافذة جديدة | (120, 100) |
انقر Move Right |
(140, 100) |
أوقف Show Editor |
تختفي لوحة الرسم؛ ويبقى الموضع (140, 100) |
انقر Undo |
يصبح الموضع (120, 100) بينما تظل لوحة الرسم مخفية |
انقر Redo |
يصبح الموضع (140, 100) |
شغّل Show Editor |
يظهر المستطيل عند الموضع الذي أُعيد إليه |
أكمل عملية سحب، ثم انقر Undo |
يعود المستطيل إلى موضعه قبل السحب |
يميز اختبار إخفاء المحرر بين ملكية النموذج وعمر العرض. إذا لم يعمل التراجع إلا عند وجود لوحة معينة، فتحقق من الهدف المسجل لدى المدير ومن الكائن الذي يحتفظ به. إذا عمل التراجع ولم تعمل الإعادة، فتحقق مما إذا كان الإجراء العكسي يسجل عملية الإرجاع. وإذا احتاج السحب الواحد إلى عدة عمليات تراجع، فتحقق مما إذا كانت تحديثات المعاينة تصل إلى التغيير القابل للتراجع في المخزن.
تجعل الأزرار الصريحة مسار التغيير سهل الفحص. وللسلوك المعتاد لقوائم Mac، اختبر أيضًا قائمة Edit القياسية مع تفعيل النافذة المطلوبة. يحصل المستجيب الأساسي في AppKit على مدير التراجع من المستجيب التالي. وعندما تطلب النافذة من مفوضها مديرًا، تكون windowWillReturnUndoManager(_:) نقطة التخصيص الموثقة؛ ومن دون تنفيذها، تنشئ النافذة مديرها الخاص. التسجيل لدى مدير غير ذي صلة لا يضمن التوجيه المقصود عبر سلسلة المستجيبين. وثائق Apple لـ undoManager في NSResponder، وثائق Apple لمفوض مدير التراجع في النافذة.
يحتاج المخزن أيضًا إلى حدّ مدروس لإنهاء عمره. إذا استبدلت مخزنًا بينما يظل مديره موجودًا، فأزل تسجيلات ذلك المخزن قبل تحريره، باستخدام removeAllActions(withTarget:) على كل مدير يحتفظ بإجراءاته. لا تمسح السجل لمجرد اختفاء لوحة الرسم؛ فإتاحة تعديلات المخزن أثناء اختفائها هي السلوك المطلوب هنا. وثائق Apple لتنظيف إجراءات التراجع الخاصة بهدف.
يحتوي هذا المثال على مستطيل ثابت واحد، ولا يتضمن مسارًا للحذف. عند توسيعه ليشمل مجموعة، التقط معرّفات ثابتة للعناصر والقيم اللازمة لعكس كل عملية. يحتاج الحذف إلى إجراء عكسي يستطيع استعادة العنصر وترتيبه المقصود؛ ففهرس قديم في المصفوفة وحده لا يكفي إذا كان من الممكن أن تتغير المجموعة.
يُسند التصميم المكتمل مسؤولية العمر إلى الجذر، وحالة التفاعل المؤقتة إلى لوحة الرسم، والتعديلات القابلة للعكس إلى المخزن. ويظل بإمكانك التراجع عن هذه التعديلات بعد إخفاء لوحة الرسم، لأن أساليبها العكسية تعمل على المخزن الذي لا يزال حيًا.
المصادر
- registerUndo(withTarget:handler:) — Apple
- StateObject — Apple
- Undo Manager — Apple
- Automatic Reference Counting — Swift.org
- UndoManager — Apple
- undoManager — EnvironmentValues — Apple
- updating(_:body:) — Apple
- onEnded(_:) — Apple
- groupsByEvent — Apple
- undoManager — NSResponder — Apple
- windowWillReturnUndoManager(_:) — Apple
- removeAllActions(withTarget:) — Apple