[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その13:UNDO/REDO 実装)

SwiftUI と CoreData を組み合わせたアプリの作り方を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • maxOS Catalina 10.15.7
  • Xcode 12.3
  • iOS 14.2

UNDO/REDO 追加

CoreData は、特別な実装無しに UNDO/REDO ができるように作られています。

今回は、もともと 用意されている機能を使った UNDO/REDO を実装します。

UNDO/REDO 追加方針

UNDO/REDO は、Model の中で処理されることですので、Model 内部の実装とします。

UI は、ソート切り替えと同様に、Toolbar に UNDO/REDO ボタンを配置します。

Model が UNDO/REDO の機能と、 UNDO/REDO できるかの情報を提供するようにします。
その情報に応じて、ボタンが Enable/Disable になるようにします。

UNDO/REDO 実装

Foundation から提供される UndoManager が実際の UNDO/REDO の処理を担当します。

CoreData は、すでに UNDO/REDO 対応されていて、NSManagedObjectContext の保有する undoManager プロパティが UndoManager です。

macOS では、デフォルトで UndoManager を持つのですが、iOS では、デフォルトでは nil が設定されていて UNDO/REDO できなくなっています。

起動直後に このプロパティに、UndoManager をセットすることで、その後の操作が、UNDO/REDO できるようになります。

まずは、テストから記述していきます。

Model の UNDO/REDO テスト

DB の基本操作である CRUD(生成/参照/更新/削除) のうち、参照以外の 生成/更新/削除 を対象として 以下のテストを書きます。

  • 直前の操作が UNDO/REDO された状態になっているか
  • canUNDO, canREDO が適切な値か
test_undoRedo_Create_undoredoCorrectly

    func test_undoRedo_Create_undoredoCorrectly() throws {
        let model = TODOItemStore(true)
        // (1)
        if let undoMgr = model.container.viewContext.undoManager {
            undoMgr.groupsByEvent = false
        }

        // (2)
        _ = model.createTODOItem("Item", "Detail", false, .middle)

        // (3)
        // model should be canUndo, should NOT be canRedo
        XCTAssertTrue(model.canUndo)
        XCTAssertFalse(model.canRedo)
        XCTAssertEqual(model.filteredItems().count, 1)

        // (4)
        // undo (cancel last creation)
        model.undo()
        XCTAssertEqual(model.filteredItems().count, 0)
        XCTAssertFalse(model.canUndo)
        XCTAssertTrue(model.canRedo)

        // (5)
        model.redo()
        XCTAssertEqual(model.filteredItems().count, 1)
        XCTAssertTrue(model.canUndo)
        XCTAssertFalse(model.canRedo)
    }
コード解説
  1. 通常は、MainLoop で UNDO/REDO の単位が管理されるのですが、DB レイヤーの UnitTest なので、false にセットします
  2. テスト用の要素を作成
  3. canUNDO/canREDO が適切な情報を返すかテスト
  4. UNDO 後に、適切な状態になっているかテスト
  5. REDO 後に、適切な状態になっているかテスト

以下、削除・更新 それぞれのテストです。

test_undoRedo_Remove_undoredoCorrectly

    func test_undoRedo_Remove_undoredoCorrectly() throws {
        let model = TODOItemStore(true)
        if let undoMgr = model.container.viewContext.undoManager {
            undoMgr.groupsByEvent = false
        }

        let item = model.createTODOItem("Item", "Detail", false, .middle)

        // model should be canUndo, should NOT be canRedo
        XCTAssertTrue(model.canUndo)
        XCTAssertFalse(model.canRedo)

        // remove
        model.removeTODOItem(item)
        model.save()
        XCTAssertEqual(model.filteredItems().count, 0)
        XCTAssertTrue(model.canUndo)
        XCTAssertFalse(model.canRedo)

        model.undo()
        XCTAssertEqual(model.filteredItems().count, 1)
        XCTAssertTrue(model.canUndo)
        XCTAssertTrue(model.canRedo)

        model.redo()
        XCTAssertEqual(model.filteredItems().count, 0)
        XCTAssertTrue(model.canUndo)
        XCTAssertFalse(model.canRedo)
    }
test_undoRedo_Update_undoredoCorrectly

    func test_undoRedo_Update_undoredoCorrectly() throws {
        let model = TODOItemStore(true)
        if let undoMgr = model.container.viewContext.undoManager {
            undoMgr.groupsByEvent = false
        }

        let item = model.createTODOItem("Item", "Detail", false, .middle)
        XCTAssertEqual(model.filteredItems().count, 1)

        let updatedItem = try XCTUnwrap(model.updateItem(item.id!, title: "UpdatedItem", detail: "UpdatedDetail", isDone: false, priority: .highest))
        XCTAssertEqual(updatedItem.title, "UpdatedItem")
        XCTAssertEqual(updatedItem.detail, "UpdatedDetail")
        XCTAssertEqual(updatedItem.priority, .highest)
        XCTAssertEqual(model.filteredItems().count, 1)

        model.undo()
        XCTAssertEqual(model.filteredItems().count, 1)
        XCTAssertTrue(model.canUndo)
        XCTAssertTrue(model.canRedo)
        
        let undoItem = try XCTUnwrap(model.refetchItem(item.id!))
        XCTAssertEqual(undoItem.title, "Item")
        XCTAssertEqual(undoItem.detail, "Detail")
        XCTAssertEqual(undoItem.priority, .middle)
        
        model.redo()
        let redoItem = try XCTUnwrap(model.refetchItem(item.id!))
        XCTAssertEqual(redoItem.title, "UpdatedItem")
        XCTAssertEqual(redoItem.detail, "UpdatedDetail")
        XCTAssertEqual(redoItem.priority, .highest)
    }

テストが失敗することを確認して、(というかコンパイル通りません。)Model を実装していきます。

Model の UNDO/REDO 実装

実装するポイントは2つです。

  • 初期化時に、UndoManager をセットする
  • UndoManager とやりとりするメソッドを作る

init で、undoManager をセットします。(iOS では、デフォルトでは nil です)

example

// MARK: TODOItemStore dependent on CoreData
struct TODOItemStore {
    static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItemStore", category: "TODOItemStore")

    let container: NSPersistentContainer
    
    var undoManager: UndoManager? {
        guard let undoManager = container.viewContext.undoManager else { return nil }
        return undoManager
    }

    init(_ inMemory:Bool ) {
        container = NSPersistentContainer(name: "MyTODO")
        if inMemory {
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
        // (1)
        // set undo/redo manager
        if container.viewContext.undoManager == nil {
            container.viewContext.undoManager = UndoManager()
        }
        
        container.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
    }
    // .. snip ..    
コード解説
  1. UndoManager をセット

UndoManager 関連のメソッドを作成

TODOItemStore extension for UNDO/REDO

extension TODOItemStore {
    func undo() {
        guard let undoManager = container.viewContext.undoManager else { return }
        undoManager.undo()
        save()
    }
    func redo() {
        guard let undoManager = container.viewContext.undoManager else { return }
        undoManager.redo()
    }
    var canUndo:Bool {
        guard let undoManager = container.viewContext.undoManager else { return false }
        return undoManager.canUndo
    }
    var canRedo:Bool {
        guard let undoManager = container.viewContext.undoManager else { return false }
        return undoManager.canRedo
    }
}
UNDO/REDO の実行以外に、ボタンを enable/disable するために canUndo/canRedo も実装します。

このコードで、先ほど作成したテストがパスすることを確認できます。

ViewModel/View の UNDO/REDO テスト

モデルのテストと同様に、CRUD のうちの CUD を対象としたテストを作成します。

test_undoRedo_Create_ShouldBeVanishedRecreated

    func test_undoRedo_Create_ShouldBeVanishedRecreated() throws {
        let app = XCUIApplication()
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        
        let mainPage = TODOListPageObject(app)
        XCTAssertEqual(mainPage.todoListRows.count, 0)
        XCTAssertFalse(mainPage.canUndo)
        XCTAssertFalse(mainPage.canRedo)

        // (1)
        // add
        sleep(1)
        mainPage.addButtonTap().typeTitle("Item").typeDetail("Detail").selectPriority(2).tapOk()
        XCTAssertEqual(mainPage.todoListRows.count, 1)
        XCTAssertTrue(mainPage.canUndo)
        XCTAssertFalse(mainPage.canRedo)
        // (2)
        XCTAssertTrue(mainPage.canUndo)
        mainPage.tapUndo()
        XCTAssertEqual(mainPage.todoListRows.count, 0)
        XCTAssertFalse(mainPage.canUndo)
        XCTAssertTrue(mainPage.canRedo)

        // (3)
        XCTAssertTrue(mainPage.canRedo)
        mainPage.tapRedo()
        XCTAssertEqual(mainPage.todoListRows.count, 1)
        XCTAssertTrue(mainPage.canUndo)
        XCTAssertFalse(mainPage.canRedo)
    }
コード解説
  1. アイテムを1つ追加して、UNDO/REDO ボタンのステータスをテスト
  2. UNDO して、状態をテスト
  3. REDO して、状態をテスト

以下のコードは、削除、更新のそれぞれをテスト。

test_undoRedo_Delete_ShouldBeRecoveredAndVanishAgain

    func test_undoRedo_Delete_ShouldBeRecoveredAndVanishAgain() throws {
        let app = XCUIApplication()
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        
        let mainPage = TODOListPageObject(app)

        // add
        sleep(1)
        mainPage.addButtonTap().typeTitle("Item").typeDetail("Detail").selectPriority(2).tapOk()

        // then remove
        mainPage.removeItemWithSwipe(index: 0)
        XCTAssertEqual(mainPage.todoListRows.count, 0)

        // undo
        XCTAssertTrue(mainPage.canUndo)
        mainPage.tapUndo()
        XCTAssertEqual(mainPage.todoListRows.count, 1)
        XCTAssertTrue(mainPage.canUndo) // because of 1st operation(create)
        XCTAssertTrue(mainPage.canRedo)

        XCTAssertTrue(mainPage.canRedo)
        mainPage.tapRedo()
        XCTAssertEqual(mainPage.todoListRows.count, 0)
        XCTAssertTrue(mainPage.canUndo)
        XCTAssertFalse(mainPage.canRedo)
    }

TODOItem のプロパティを更新したときの確認は、都度 DetailView に移動して確認する必要があります。

test_undoRedo_Update_ShouldBeUpdatedAccordingly

    func test_undoRedo_Update_ShouldBeUpdatedAccordingly() throws {
        let app = XCUIApplication()
        app.launchArguments.append("TestWithInMemory")
        app.launch()
        
        let mainPage = TODOListPageObject(app)

        // add
        sleep(1)
        mainPage.addButtonTap().typeTitle("Item").typeDetail("Detail").selectPriority(2).tapOk()

        // then update
        var detailPage = mainPage.rowPageObjectAtIndex(at: 0).tapToDetailVew()
        detailPage.typeTitle("UpdatedItem").typeDetail("UpdatedDetail").selectPriority(4).tapOk()
        
        // undo
        XCTAssertTrue(mainPage.canUndo)
        mainPage.tapUndo()
        // test item property
        detailPage = mainPage.rowPageObjectAtIndex(at: 0).tapToDetailVew()
        XCTAssertEqual(detailPage.titleText, "Item")
        XCTAssertEqual(detailPage.detailText, "Detail")
        XCTAssertEqual(detailPage.selectedPriority, 2)
        detailPage.tapCancel() // should NOT be tapOK(), it would mean another change on model
        
        // redo
        XCTAssertTrue(mainPage.canRedo)
        mainPage.tapRedo()
        detailPage = mainPage.rowPageObjectAtIndex(at: 0).tapToDetailVew()
        XCTAssertEqual(detailPage.titleText, "UpdatedItem")
        XCTAssertEqual(detailPage.detailText, "UpdatedDetail")
        XCTAssertEqual(detailPage.selectedPriority, 4)
        detailPage.tapOk()
    }

ViewModel/View の UNDO/REDO 実装

ViewModel には、Model 同様 UNDO/REDO 関連のメソッドを追加。

View には、ツールバーを使った UNDO/REDO ボタンを追加

MyTODOViewModel extension

// MARK: UNDO/REDO
extension MyTODOViewModel {
    var canUndo:Bool {
        return todoItemStore.canUndo
    }
    var canRedo:Bool {
        return todoItemStore.canRedo
    }
    func undo() {
        todoItemStore.undo()
    }
    func redo() {
        todoItemStore.redo()
    }
}
MyTODOMainView に追加した Toolbar

                ToolbarItemGroup(placement: .bottomBar) {
                    Button(action: viewModel.undo, label: { Text("Undo")})
                        .disabled(!viewModel.canUndo)
                    Button(action: viewModel.redo, label: { Text("Redo")})
                        .disabled(!viewModel.canRedo)
                }

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です