लंबे समय तक जीवित रहने वाले model store के साथ undo operations पंजीकृत करें और views को store methods के ज़रिए edits भेजने दें। Mac shape editor में drag previews को स्थानीय रखें, छोड़ने पर एक बार commit करें और store से वह inverse पंजीकृत करवाएँ जिससे redo भी संभव हो।
यह tutorial एक छोटा macOS SwiftUI app बनाता है, जिसमें एक काल्पनिक rectangle है। आप उसे खिसका सकते हैं, उसका editor छिपा सकते हैं, खिसकाने की क्रिया undo कर सकते हैं और editor फिर दिखा सकते हैं। ठोस कार्य यह है कि जब editing view गायब हो जाए, लेकिन उसका model जीवित रहे, तब undo व्यवहार बरकरार रखा जाए। सब कुछ memory में रहता है।
Store को undo operation क्यों मिलना चाहिए?
Apple की closure-आधारित पंजीकरण विधि के लिए reference-type target आवश्यक है। यह भी दस्तावेज़ित है कि undo manager उस target का unowned reference रखता है। इसलिए, कोई action पंजीकृत करने से target का जीवनकाल तय नहीं होता। Apple की सलाह है कि उसी target का कोई और reference capture करने के बजाय handler का target parameter इस्तेमाल करें। Apple का undo registration documentation.
जिस store के पास edited state का स्वामित्व हो, वह उपयुक्त target है। एक स्थिर owner उस store को जीवित रखता है; अस्थायी views उसे दिखाते हैं और बदलावों का अनुरोध करते हैं। यहाँ, window का root view @StateObject के ज़रिए store का स्वामी है, जबकि हटाया जा सकने वाला canvas उसे observe करता है। SwiftUI किसी state object को उसके घोषित करने वाले container के जीवनकाल तक बनाए रखता है, लेकिन container की identity बदलने से नया instance बन सकता है। Apple का StateObject documentation.
“Store-owned inverses” बताता है कि बदलाव पलटने का logic कहाँ रहता है। Undo manager अब भी callbacks रिकॉर्ड करता और उन्हें चलाने का समय तय करता है। Store पुरानी value और उसे बहाल करने वाली method देता है। यह विभाजन Apple के दस्तावेज़ित architecture के अनुरूप है, जिसमें undo recipient बदली गई object या उसका owner, दोनों में से कोई हो सकता है। Apple की Undo Manager guide.
| ज़िम्मेदारी | इस उदाहरण में owner |
|---|---|
| Commit की गई rectangle position | Model store |
| Inverse पंजीकरण और mutation | Model store method |
| Store का जीवनकाल | स्थिर window root |
| अस्थायी drag translation | Canvas gesture state |
| Undo history और execution | Environment undo manager |
| लागू manager का चयन | Interaction boundary पर view |
Reversible store mutation लागू करें
macOS 13 या उसके बाद के deployment target और वर्तमान Apple SDK के साथ macOS SwiftUI app बनाएँ। निम्नलिखित तीन files पूरा उदाहरण बनाती हैं; दूसरी @main declaration रखने के बजाय generated app entry point और content view को बदलें। इस research environment में Swift compiler और macOS frameworks उपलब्ध नहीं हैं, इसलिए इस उदाहरण की जाँच की गई है, लेकिन इसे यहाँ compile नहीं किया गया।
ShapeStore.swift बनाएँ। Store में एक committed position और एक mutation method है। Assignment से पहले पिछली position capture करने पर method के पास edit को पलटने के लिए ज़रूरी जानकारी रहती है।
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
}
}
Inverse उसी method को call करता है। मान लें कि position (120, 100) से बदलकर (160, 100) होती है। पंजीकृत callback (120, 100) का अनुरोध करता है। जब undo वह callback चलाता है, तो method अपनी पिछली value (160, 100) देखता है और वापसी का बदलाव पंजीकृत करता है। Apple दस्तावेज़ित करता है कि undo के दौरान किए गए registrations redo operations बन जाते हैं। यदि isUndoing true होने पर registration छोड़ दिया जाए, तो यह pattern टूट जाएगा। Apple की Undo Manager guide.
Closure अपने store parameter पर काम करता है और manager का weak reference capture करता है। इससे manager की stored closure से वापस उसी manager तक strong reference नहीं बनता। Swift के reference-counting documentation के अनुसार weak references अपने संदर्भित instance को जीवित नहीं रखते और उसके deallocate होने पर nil बन जाते हैं। The Swift Programming Language: Automatic Reference Counting.
Store और उसका mutation method main-actor पर isolated हैं। यह Apple के मौजूदा documentation से मेल खाता है: UndoManager और registration handler main-actor पर isolated हैं। Method registration और mutation को synchronously करता है, जिससे यह छोटा edit उसी interaction के भीतर रहता है जिसने इसे call किया। Apple का UndoManager documentation.
Store को हटाए जा सकने वाले editor से ऊपर रखें
StoreUndoDemoApp.swift बनाएँ। Canvas दिखाई दे या नहीं, root store का स्वामी बना रहता है। इसके buttons action चलने पर मौजूदा environment manager पढ़ते हैं। Canvas अपने edits के लिए वही manager प्राप्त करता है।
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 का environment undo manager optional है। Apple स्पष्ट रूप से अनुमति देता है कि यदि environment nil दे, तो undo registration छोड़ दी जाए। इस स्थिति में भी यह उदाहरण rectangle को खिसकाता है, लेकिन कोई undo entry नहीं बनाता। यदि अपेक्षित undo operation मौजूद न हो, तो inverse logic बदलने से पहले जाँचें कि interaction को manager मिला था या नहीं। Apple का environment undoManager documentation.
हर mutation में manager पास करने से window context भी स्पष्ट रहता है। Store उस window manager को retain नहीं करता जो उसे सबसे पहले मिला हो। इस उदाहरण में हर window root अपने store का स्वामी है। यदि बाद में कई windows एक shared model को edit करें, तो इस व्यवस्था को windows में फिर इस्तेमाल करने से पहले तय करें कि उन edits की history किसके पास होगी।
Undo group को कई events तक फैलाए बिना एक drag commit करें
ShapeCanvas.swift बनाएँ। Drag करते समय rectangle की दिखाई गई position में एक अस्थायी translation शामिल होती है। Store की committed position केवल gesture सफलतापूर्वक समाप्त होने पर बदलती है। कोई दूसरा edit या undo command चलाने से पहले drag पूरा करें; यह संक्षिप्त उदाहरण क्रमिक interactions मानता है और drag के दौरान एक साथ आए commands का समन्वय नहीं करता।
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()
}
}
Gesture अपनी अंतिम translation का उपयोग करके move commit करता है। यह preview property के reset होने के समय पर निर्भर नहीं करता। Apple दस्तावेज़ित करता है कि gesture समाप्त या रद्द होने पर updating(_:body:) gesture state reset करता है, जबकि onEnded(_:) केवल सफलतापूर्वक पूरा होने के बाद चलता है। इसलिए, इस व्यवस्था में रद्द हुआ gesture न तो committed movement छोड़ता है, न ही कोई नया undo action। Apple का gesture updating documentation, Apple का gesture completion documentation.
इससे तीन अलग-अलग सीमाएँ बनी रहती हैं: preview update अस्थायी display state बदलता है; सफल completion एक model edit का अनुरोध करता है; store उस edit का inverse पंजीकृत करता है। यदि हर drag update पर store mutation call करें, तो gesture के दौरान बीच-बीच की positions भी पंजीकृत होंगी।
Apple की default event grouping run loop के passes के आसपास registrations इकट्ठा करती है। Drag कई passes में updates दे सकता है, इसलिए केवल event grouping से पूरे drag को एक transaction के रूप में व्यक्त नहीं किया जा सकता। यहाँ, एक बार commit करने से shared manager का configuration बदले बिना यह समस्या हल होती है। Apple का groupsByEvent documentation.
भविष्य में ऐसा कोई command जो एक साथ कई items बदलता हो, उसके inverses इकट्ठा करने के लिए स्पष्ट और संतुलित undo group इस्तेमाल कर सकता है। उसका आरंभ और अंत उसी command के भीतर रखें। यदि drag करते समय live model mutation आवश्यक हो जाए, तो अलग-अलग begin, update, commit और cancel operations बनाएँ, और तय करें कि दूसरे commands सक्रिय edit के साथ कैसे interact करेंगे। यहाँ का preview-only तरीका जानबूझकर store में यह अतिरिक्त transaction machinery नहीं जोड़ता।
Ownership, redo और window routing को अलग-अलग जाँचें
चलते हुए app में निम्नलिखित क्रम आज़माएँ। Coordinates दिए गए initial state और button increment से अपेक्षित उदाहरणात्मक मान हैं, मापे गए परिणाम नहीं।
| क्रिया | अपेक्षित committed position या परिणाम |
|---|---|
| नई window शुरू करें | (120, 100) |
Move Right पर click करें |
(140, 100) |
Show Editor बंद करें |
Canvas गायब हो जाता है; position (140, 100) बनी रहती है |
Undo पर click करें |
Canvas गायब रहते हुए position (120, 100) हो जाती है |
Redo पर click करें |
Position (140, 100) हो जाती है |
Show Editor चालू करें |
Rectangle redo की गई position पर दिखाई देता है |
Drag पूरा करें, फिर Undo पर click करें |
Rectangle drag से पहले वाली position पर लौट आता है |
छिपे editor की जाँच model ownership और presentation के जीवनकाल में अंतर स्पष्ट करती है। यदि undo केवल किसी खास panel के मौजूद रहने पर काम करे, तो manager के साथ पंजीकृत target और उसे retain करने वाली object की जाँच करें। यदि undo काम करे लेकिन redo कुछ न करे, तो देखें कि inverse वापसी का operation पंजीकृत करता है या नहीं। यदि एक drag को कई undo लगते हों, तो देखें कि preview updates store के undoable mutation तक पहुँच रहे हैं या नहीं।
स्पष्ट buttons से mutation path की जाँच आसान होती है। सामान्य Mac menu व्यवहार के लिए, इच्छित window सक्रिय रखते हुए मानक Edit menu भी जाँचें। AppKit का base responder अपना undo manager अगले responder से प्राप्त करता है। जब कोई window अपने delegate से manager माँगती है, तब windowWillReturnUndoManager(_:) दस्तावेज़ित customization point है; यदि वह implementation न हो, तो window अपना manager बनाती है। किसी असंबंधित manager के साथ registration करने से responder chain के ज़रिए अपेक्षित routing स्थापित नहीं होती। Apple का NSResponder undoManager documentation, Apple का window undo-manager delegate documentation.
Store के लिए एक स्पष्ट retirement boundary भी चाहिए। यदि आप store को बदलते हैं जबकि उसका manager अब भी मौजूद है, तो store को release करने से पहले उसके registrations उस store के actions रखने वाले हर manager से हटाएँ; इसके लिए removeAllActions(withTarget:) का उपयोग करें। केवल canvas के गायब होने पर history साफ़ न करें: उस दौरान store के edits उपलब्ध रखना ही यहाँ बनाया जा रहा व्यवहार है। Apple का target-specific undo cleanup documentation.
इस उदाहरण में एक स्थिर rectangle है और deletion का कोई रास्ता नहीं। इसे collection तक बढ़ाते समय, stable item identifiers और हर operation को पलटने के लिए ज़रूरी values capture करें। Deletion के inverse में item और उसका निर्धारित क्रम फिर बहाल होना चाहिए; यदि collection बदल सकती है, तो पुराना array index अकेले पर्याप्त नहीं है।
यह पूरा design lifetime की ज़िम्मेदारी root को, अस्थायी interaction state की ज़िम्मेदारी canvas को और reversible edits की ज़िम्मेदारी store को देता है। Canvas छिपाने पर भी edits उपलब्ध रहते हैं, क्योंकि उनके inverse methods अब भी जीवित store पर काम करते हैं।
स्रोत
- 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