Untuk bentuk yang diseret di dalam app Mac Anda, tampilkan pergerakannya sebagai pratinjau selama gestur dan tetapkan posisi akhirnya dalam satu grup urung singkat. Tempatkan model dan pengelola urung di bawah satu pemilik, pasangkan setiap pembukaan grup dengan penutupannya, dan daftarkan perpindahan invers agar urungkan ulang juga berfungsi.

Tutorial ini membuat kanvas SwiftUI kecil yang berisi satu lingkaran sintetis di memori. Tugas konkretnya adalah membuat satu seretan yang selesai hanya memerlukan tepat satu kali urungkan, berapa pun jumlah pembaruan gerakan yang diterima. Tutorial ini juga menjelaskan cara mempertahankan batas yang sama dalam tampilan AppKit.

Mengapa seretan tidak boleh membiarkan grup urung tetap terbuka

Seretan adalah interaksi yang berlangsung dari waktu ke waktu. Grup urung mengumpulkan operasi yang dapat dibatalkan. Batas keduanya tidak harus memiliki masa hidup yang sama. Apple mendokumentasikan bahwa groupsByEvent secara default bernilai true dan mengelompokkan operasi di sekitar iterasi loop jalan; perilaku itu tidak mendefinisikan transaksi seretan yang lengkap. Grup eksplisit juga dapat bersarang di dalam grup yang sudah ada. Lihat dokumentasi Apple tentang pengelompokan peristiwa otomatis dan memulai grup urung.

Membuka grup dalam callback gerakan SwiftUI pertama lalu menutupnya di onEnded menyisakan celah pembatalan. Apple secara eksplisit menyatakan bahwa onEnded hanya dijalankan ketika gestur berakhir dengan sukses. Sebaliknya, status yang dikelola melalui updating direset ketika gestur berakhir atau dibatalkan. endUndoGrouping() yang tidak dipasangkan dengan penutup akan memicu pengecualian, sehingga menebak apakah callback telah membuka grup juga tidak sesuai. Lihat onEnded(_:), updating(_:body:), dan endUndoGrouping().

Untuk kanvas ini, gunakan rancangan berikut. Ini adalah pilihan implementasi yang diturunkan dari batas-batas yang didokumentasikan tersebut:

Tahap interaksi Status tampilan Model yang ditetapkan Pengelompokan urung
Sebelum menyeret Pusat tersimpan Pusat awal Tertutup
Pembaruan gerakan Pusat awal ditambah translasi sementara Tidak berubah Tertutup
Penyelesaian sukses Pusat akhir Diperbarui sekali Dibuka dan ditutup secara sinkron
Pembatalan Pratinjau direset Tidak berubah Tidak pernah dibuka
Urungkan atau ulangi Pusat dipulihkan Diperbarui melalui setter yang dapat dibalik Dikelola oleh UndoManager

Pendekatan ini memungkinkan lingkaran bergerak dengan mulus tanpa menjadikan setiap bingkai pratinjau sebagai edit permanen. Seretan yang selesai menjadi satu operasi model bernama.

Tentukan pemilik riwayat urung

Contoh ini memberi setiap kanvas modelnya sendiri dan pengelola urung privat. Karena pemilik tersebut mengendalikan setiap pendaftaran, ia menetapkan groupsByEvent = false sekali saat inisialisasi dan secara eksplisit mengelompokkan setiap perpindahan yang ditetapkan. Apple mewajibkan grup ditutup secara eksplisit sebelum pengurungan ketika pengelompokan otomatis dinonaktifkan. Contoh ini menutup grup sebelum metode penetapan selesai. Lihat groupsByEvent.

Editor dokumen yang sudah ada mungkin sudah memiliki pengelola urung dokumen. SwiftUI menyediakan pengelola kontekstual melalui @Environment(\.undoManager), dan Apple mendokumentasikan bahwa nilainya dapat berupa nil jika konteks tidak mendukung pengurungan. Dalam arsitektur tersebut, tetap gunakan pengelola milik dokumen beserta kebijakan pengelompokan yang sudah ditetapkan; jangan menyalin konfigurasi pengelola privat dari contoh ini ke pengelola bersama. Lihat EnvironmentValues.undoManager.

Dengan pengelola bersama dan pengelompokan otomatis yang aktif, mendaftarkan satu perpindahan invers saat penyelesaian tetap mencegah entri riwayat untuk setiap gerakan. Subgrup eksplisit dapat mengemas perpindahan tersebut, tetapi tidak menjamin pemisahan dari grup yang membungkusnya. Jika edit lain berbagi grup pembungkus itu, semuanya dapat diurungkan bersama. Menjamin transaksi tingkat atas yang terpisah memerlukan koordinasi dari pemilik riwayat bersama, bukan penutupan grup oleh tampilan anak.

Buat contoh SwiftUI lengkap

Buat proyek app SwiftUI macOS dengan target penerapan macOS 14 atau yang lebih baru. Ganti deklarasi app dan tampilan konten yang dibuat otomatis dengan dua file di bawah. Pertahankan hanya satu deklarasi @main. Contoh ini menggunakan tombol lokal Undo dan Redo agar kepemilikannya jelas; integrasi menu app dan rantai responder merupakan langkah integrasi terpisah.

Model ini menggunakan setter yang dapat dibalik: ambil posisi saat ini, daftarkan closure yang memulihkannya, lalu terapkan penggantinya. Ketika closure itu dijalankan saat pengurungan, setter yang sama mendaftarkan inversnya sebagai pengulangan. Apple menunjukkan pola pendaftaran timbal balik ini dalam Mendaftarkan Operasi Urung. Dokumentasi terkini juga menandai UndoManager dan penangan pendaftarannya sebagai terisolasi pada aktor utama, sehingga model contoh ini menggunakan @MainActor. Lihat UndoManager.

Letakkan model lengkap berikut di 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 sengaja berada dalam cakupan sinkron yang sama dengan beginUndoGrouping(). Swift menjalankan tindakan tertunda saat cakupan itu berakhir, termasuk ketika terjadi pengembalian lebih awal atau galat dilemparkan. Tindakan tertunda tidak dapat mencakup callback gestur yang dipanggil secara terpisah. Pasangan ini menjaga agar grup model tetap seimbang pada jalur keluar alur kontrol normal. Lihat Alur Kontrol — Tindakan Tertunda.

Closure urung bekerja pada parameter target, bukan menangkap self. Apple mendokumentasikan bahwa pengelola menyimpan referensi tanpa pemilikan ke target dan merekomendasikan penggunaan parameter yang diberikan untuk menghindari siklus retain. Kanvas mempertahankan model selama digunakan, dan model memiliki riwayatnya sendiri. Lihat registerUndo(withTarget:handler:).

Letakkan app dan kanvas di 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)
    }
}

Gestur ini menggunakan satu ruang koordinat kanvas bernama, sehingga translasi diukur terhadap papan, bukan terhadap lingkaran yang bergerak. Apple mendefinisikan translation sebagai perpindahan total dari awal gestur. Tetapkan nilai itu ke pratinjau; jangan berulang kali menambahkannya ke model selama gerakan berlangsung. Lihat DragGesture dan translation.

Status gestur opsional juga menunjukkan apakah pratinjau sedang aktif. Kedua tombol riwayat lokal dinonaktifkan selama pratinjau. Saat selesai dengan sukses, nilai akhir gestur menyediakan translasi yang ditetapkan. Saat dibatalkan, status gestur direset dan pusat tersimpan tetap tidak berubah; tidak ada grup terbuka yang perlu dibersihkan.

Contoh ini sengaja hanya memiliki satu penulis. Tidak ada perintah lain yang mengubah lingkaran selama seretan. Jika Anda menambahkan gerakan papan ketik, penghapusan, atau perintah edit lainnya, tentukan apakah tiap perintah tidak tersedia selama seretan atau terlebih dahulu membatalkan pratinjau. Arahkan semua perintah melalui kebijakan itu; menonaktifkan kedua tombol ini saja tidak mengatur jalur perintah tambahan.

Verifikasi satu seretan, satu kali urungkan, dan pengulangan yang berfungsi

Gunakan koordinat ilustratif untuk memeriksa perhitungannya. Dimulai dari (120, 100), translasi kumulatif sebesar (10, 5), (25, 12), dan (40, 20) menghasilkan pusat pratinjau (130, 105), (145, 112), dan (160, 120). Pusat yang ditetapkan tetap di (120, 100) sampai gestur selesai, lalu berubah sekali menjadi (160, 120).

Pengurungan memanggil setter terdaftar dengan (120, 100). Sebelum memulihkannya, setter menangkap (160, 120) dan mendaftarkan posisi itu untuk pengulangan. Jangan menekan pendaftaran di dalam callback pengurungan: pendaftaran timbal balik itulah yang mempertahankan fungsi pengulangan. Apple menjelaskan perilaku ini dalam Mendaftarkan Operasi Urung.

Kode ini tidak membuat grup eksplisit tambahan di dalam setter yang dapat dibalik. UndoManager mengendalikan pengelompokan selama pemutaran ulang riwayat; Apple mendokumentasikan bahwa redo() mencatat operasi yang diputar ulang ke tumpukan urung sebagai satu grup. Nama tindakan ditetapkan melalui setActionName, menggunakan label sintetis Move Shape. Lihat redo() dan setActionName(_:).

Periksa perilaku berikut di app Mac:

  1. Selesaikan satu seretan dengan beberapa perubahan arah. Satu kali Undo seharusnya memulihkan posisi awalnya.
  2. Pilih Redo. Bentuk seharusnya kembali ke posisi akhir seretan yang telah selesai.
  3. Selesaikan dua seretan terpisah. Dua tindakan urungkan seharusnya memulihkan dua posisi sebelumnya dalam urutan terbalik.
  4. Selesaikan seretan yang dikenali pada posisi awalnya. Pengaman tanpa perubahan seharusnya tidak membuat entri riwayat.
  5. Uji jalur pembatalan atau pelepasan tampilan di app yang memuatnya. Pratinjau seharusnya hilang tanpa perpindahan yang ditetapkan.
  6. Urungkan suatu perpindahan, lalu selesaikan perpindahan lain. Cabang pengulangan sebelumnya seharusnya hilang; Apple mendokumentasikan bahwa pendaftaran urung baru menghapus tumpukan pengulangan.

Perilaku terakhir ini mengikuti Arsitektur Undo Manager dari Apple. Perhitungan koordinat di atas diperiksa dengan kalkulasi Python kecil yang deterministik. Kode sumber Swift diperiksa, tetapi tidak dikompilasi atau dijalankan di sini karena lingkungan ini tidak memiliki kompiler Swift atau SDK macOS.

Terapkan batas yang sama pada kanvas AppKit

Untuk NSView yang sudah ada, pertahankan urutan peristiwa AppKit. Referensi Apple terkini mendefinisikan mouseDragged(with:) sebagai pergerakan saat tombol kiri ditekan dan mouseUp(with:) sebagai pelepasan tombol tersebut. Panduan peristiwa yang diarsipkan menjelaskan urutan tiga metode dan konversi koordinat yang diperlukan oleh tampilan khusus. Lihat mouseDragged(with:), mouseUp(with:), dan Menangani Peristiwa Tetikus.

Petakan rancangan yang sama ke metode-metode tersebut:

  • Di mouseDown(with:), uji benturan pada bentuk sintetis dan simpan pusat awalnya serta lokasi penunjuk yang dikonversi ke koordinat kanvas.
  • Di mouseDragged(with:), hitung perpindahan dari lokasi penunjuk yang tersimpan itu dan gambar ulang pratinjau sementara.
  • Di mouseUp(with:), hitung perpindahan akhir, hapus status pelacakan, dan panggil metode penetapan sinkron milik model sekali.
  • Di jalur pembatalan atau pembongkaran milik pemilik kanvas, buang pratinjau dan hapus status pelacakan tanpa menetapkan perubahan.

Gunakan satu pemilik peristiwa untuk interaksi tersebut. Jika SwiftUI menyematkan kanvas AppKit, pilih gestur SwiftUI atau penangan tetikus AppKit untuk menggerakkan dan menetapkan posisi. Jika keduanya mendaftarkan perpindahan yang sama, akan tercipta operasi duplikat. Jaga konsistensi koordinat gambar, termasuk sistem koordinat terbalik atau transformasi kanvas.

Panduan Apple yang diarsipkan juga menjelaskan bahwa perintah papan ketik dapat dikirim selama pendekatan pelacakan tiga metode. Karena itu, integrasi menu harus mematuhi kebijakan seretan aktif yang sama dengan tombol lokal. Pertahankan riwayat dokumen pada pemilik dokumennya dan pastikan tindakan menu mengarah ke pengelola milik pemilik tersebut.

Diagnosis kegagalan pengelompokan tanpa menutup grup milik pihak lain

Jika satu seretan menghasilkan banyak langkah urung, periksa apakah setter model mendaftar selama pembaruan gerakan. Pindahkan pendaftaran itu ke penetapan akhir. Jika beberapa seretan terurungkan bersama, periksa kebijakan pengelompokan otomatis pengelola dan grup pembungkus apa pun yang dibuat pemiliknya. Jika pengulangan tidak tersedia setelah pengurungan, periksa apakah jalur pemulihan mendaftarkan operasi invers.

Jika pengelompokan melempar pengecualian, periksa kepemilikan sebelum mengubah jumlah pemanggilan penutup. Apple mendokumentasikan bahwa groupingLevel == 0 berarti tidak ada grup yang terbuka, dan bahwa undo() memicu pengecualian ketika masih ada lebih dari satu grup yang terbuka. Perulangan yang menutup grup sampai level mencapai nol dapat menutup grup milik pemanggil lain. Gunakan level sebagai petunjuk diagnostik dan tegakkan kontrak pemilik. Lihat groupingLevel dan undo().

Untuk kanvas dalam memori ini, batas yang berguna adalah perpindahan yang ditetapkan: pratinjau bebas, tetapkan sekali, daftarkan perubahan yang dapat dibalik, lalu tutup grup sebelum kembali. Dengan begitu, setiap seretan yang selesai menjadi satu langkah urung, sementara pembatalan, pengiriman peristiwa, dan kepemilikan riwayat tetap jelas.

Sumber