[Swift]Value-type なモデルを使った UNDO の実装(その3: ViewModel/View と UNDO の実装)

     
⌛️ 3 min.
Model が value-type であることを意識して、ViewModel と View を作り、最終的に UNDO まで作ります。

環境&対象

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

  • macOS Big Sur 11.2.3
  • Xcode 12.4
  • iOS 14.4

Model

前回モデルとして、struct TODOItem と struct TODOItems を定義しました。どちらも struct で定義し、プロパティも value-type を使っています。

[Swift]Value-type なモデルを使った UNDO の実装(その2: Value-type で モデルを作成)

このモデルの情報を表示するための View と ViewModel を作っていきます。

ViewModel

View を SwiftUI で作成するので、ViewModel は、Observable に準拠させ、Model のデータは、@Published として定義します。

こうすることで、Model のデータ変更で画面の再描画が トリガーされることになります。

# アプリ名を LucidMobile にするつもりなので、クラス名を LucidMobileTODOViewModel にしています。

ViewModel の定義

LucidMobileTODOViewModel


public final class LucidMobileTODOViewModel: ObservableObject {
    @Published var modelData: TODOItems
    var todoItems: [TODOItem] {
        modelData.items
    }

    init(_ todoItems:TODOItems) {
        self.modelData = todoItems
    }
}

モデルは、外部から渡されることとしました。(CoreData や Realm から読み込みたいときは、読み込んだデータを使って、初期化することとしています。)

また、SwiftUI のビューでリスト表示する時には、要素が配列になっていると便利なので、要素を配列として取り出すメソッドを Model, ViewMode の両方に用意しました。

Model 側のコードは、以下です。

Model(TODOItems) に追加したコード


    var todoItems: [TODOItem] {
        itemsDic.map { $0.value }.sorted { $0.title  $1.title }
    }

preview 向けの static 関数定義

SwiftUI で View を定義する時に、以下のような preview のコードがデフォルトで定義されます。

Preview


struct <ViewName>_Previews: PreviewProvider {
    static var previews: some View {
        <ViewName>()
    }
}

この preview のコードを使って、Xcode でプレビューできます。プレビュー時にサンプルのデータが入っていると プレビューがわかりやすく、ビューの調整も行いやすくなりますので、以下のようなサンプルデータ用のコードも追加しておきます。

Preview用モデル作成


public final class LucidMobileTODOViewModel: ObservableObject {
    @Published var modelData: TODOItems
    var todoItems: [TODOItem] {
        modelData.items
    }

    init(_ todoItems: TODOItems) {
        self.modelData = todoItems
    }

    static func previewModel() -> LucidMobileTODOViewModel {
        let item1 = TODOItem("Item01", .middlePriority)
        let item2 = TODOItem("Item02", .lowerPriority)
        var model = TODOItems()
        model.add(item1)
        model.add(item1)

        let viewModel = LucidMobileTODOViewModel(model)
        viewModel.addTODOItem(item1)
        viewModel.addTODOItem(item2)
        return viewModel
    }
}

View (App)

LifeCycle を SwiftUI でつくり、メインのビューの名前を LucidListView とするので、以下のようなコードが、LucidMobileTODOApp のコードとなります。

LucidMobileTODOApp


@main
struct LucidMobileTODOApp: App {
    @StateObject var viewModel = LucidMobileTODOViewModel(TODOItems())
    var body: some Scene {
        WindowGroup {
            LucidListView()
                .environmentObject(viewModel)

        }
    }
}

ViewModel は、@StateObject として定義し、下位のビューには、EnvironmentObject として渡すことにします。

View (LucidListView)

特に value-type な Model であることの特徴はでてこないので、先に View を説明します。

以下は、NavigationBar 左側に EditButton を配置、右側に、+ ボタンを配置して、それぞれ、追加削除できるようにしたものです。
追加時には、シートを表示して詳細を設定できるようにしています。

特に工夫しているところもなく、普通のビューです。

LucidListView


struct LucidListView: View {
    @EnvironmentObject var viewModel: LucidMobileTODOViewModel
    @State private var showNewItemSheet = false
    var body: some View {
        NavigationView {
            VStack {
                List {
                    ForEach(viewModel.todoItems, id: \.id) { item in
                        Text(item.title)
                    }
                    .onDelete { indexSet in
                        guard let index = indexSet.first else { return }
                        let item = viewModel.todoItems[index]
                        viewModel.removeTODOItem(item)
                    }
                }
            }
            .navigationTitle("TODO")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                navBarLeadingEdit
                navBarTrailingAdd
                bottombarUndoRedo
            }
            .sheet(isPresented: $showNewItemSheet) {
                NewTODOItem(viewModel: viewModel, showSheet: $showNewItemSheet)
            }
        }
        .padding(.horizontal)
    }

    var navBarLeadingEdit: some ToolbarContent {
        ToolbarItem(placement: .navigationBarLeading) {
            EditButton()
        }
    }

    var navBarTrailingAdd: some ToolbarContent {
        ToolbarItem(placement: .navigationBarTrailing) {
            Button(action: {
                showNewItemSheet.toggle()
            }, label: {
                Label(title: { Text("AddItem") }, icon: { Image(systemName: "plus") })
            })
        }
    }

    var bottombarUndoRedo: some ToolbarContent {
        ToolbarItemGroup(placement: .bottomBar) {
            Button(action: {
                viewModel.undo()
            }, label: { Image(systemName: "arrow.uturn.backward.circle") })
            .disabled(!viewModel.canUndo)
            Button(action: {
                viewModel.redo()
            }, label: { Image(systemName: "arrow.uturn.forward.circle") })
            .disabled(!viewModel.canRedo)
        }
    }
}

struct NewTODOItem: View {
    @ObservedObject var viewModel: LucidMobileTODOViewModel
    @Binding var showSheet: Bool
    @State private var todoTitle: String = ""
    @State private var todoPrio: TODOItem.Priority = .middlePriority
    var body: some View {
        VStack {
            Spacer()
            TextField("Title", text: $todoTitle)
            Spacer()
            Picker(selection: $todoPrio, label: Text("Priority")) {
                ForEach(TODOItem.Priority.allCases, id: \.self) { prio in
                    Text(prio.description)
                        .tag(prio)
                }
            }
            .pickerStyle(SegmentedPickerStyle())
            Spacer()
            HStack {
                Button(action: {
                    showSheet.toggle()
                }, label: { Text("Cancel") })
                .padding()
                .background(RoundedRectangle(cornerRadius: 5)
                                .stroke())
                Button(action: {
                    /* add new item to model */
                    showSheet.toggle()
                }, label: { Text("Save&Close") })
                .padding()
                .background(RoundedRectangle(cornerRadius: 5)
                                .stroke())
            }
        }
        .padding(40)
    }
}

 struct LucidListView_Previews: PreviewProvider {
    static var previews: some View {
        LucidListView()
            .environmentObject(LucidMobileTODOViewModel.previewModel())
    }
 }

ViewModel (モデル操作と UNDO)

TODOItem の追加削除、UNDO 操作を追加した ViewModel が以下です。

LucidMobileTODOViewModel


public final class LucidMobileTODOViewModel: ObservableObject {
    @Published private(set) var modelData: TODOItems
    let undoManager = UndoManager()

    var todoItems: [TODOItem] {
        modelData.items
    }

    init(_ todoItems: TODOItems) {
        self.modelData = todoItems
    }
    // (1)
    func changeModel(_ operation:(inout TODOItems) -> Void ) {
        let oldModelData = self.modelData
        operation(&modelData)
        undoManager.registerUndo(withTarget: self) { viewModel in
            viewModel.changeModel { modelData in
                modelData = oldModelData
            }
        }
    }

    // (2)
    func addTODOItem(_ item: TODOItem) {
        changeModel { modelData in
            modelData.add(item)
        }
    }
    // (3)
    func removeTODOItem(_ item: TODOItem) {
        changeModel { modelData in
            modelData.remove(item)
        }
    }
    // (4)
    public var canUndo: Bool {
        undoManager.canUndo
    }
    public func undo() {
        undoManager.undo()
    }
    public var canRedo: Bool {
        undoManager.canRedo
    }
    public func redo() {
        undoManager.redo()
    }

    static func previewModel() -> LucidMobileTODOViewModel {
        let item1 = TODOItem("Item01", .middlePriority)
        let item2 = TODOItem("Item02", .lowerPriority)
        var model = TODOItems()
        model.add(item1)
        model.add(item1)

        let viewModel = LucidMobileTODOViewModel(model)
        viewModel.addTODOItem(item1)
        viewModel.addTODOItem(item2)
        return viewModel
    }
}
コード解説
  1. モデル操作の中心的関数です。(以降で説明します)
  2. View から呼ばれることを想定した TODOItem 追加メソッドです
  3. View から呼ばれることを想定した TODOItem 削除メソッドです
  4. UNDO 周りの関数です

value-type の Model を使った モデル操作と UNDO/REDO の実装説明

WWDC のビデオでも説明していますが、ここが、value-type の Model の見せどころ(?)です。

Model が value-type であることを利用して、操作前の Model をまるっと保存することで UNDO データとします。

複数の箇所で Model 操作を行なってしまうと、不整合が起こるかもしれませんので、ViewModel に changeModel というメソッドを作り集めます。
ポイントは、changeModel の実装です。

changeModel 実装


    // (1)
    func changeModel(_ operation:(inout TODOItems) -> Void ) {
        // (2) 
        let oldModelData = self.modelData
        // (3)
        operation(&modelData)
        // (4)
        undoManager.registerUndo(withTarget: self) { viewModel in
            // (5)
            viewModel.changeModel { modelData in
                modelData = oldModelData
            }
        }
    }
コード解説
  1. changeModel は、引数に モデル変更操作の closure を受ける
  2. 操作前に、現在のモデルを保存しておく
  3. 変更操作を実行
  4. UndoManager に UNDO を登録
  5. UNDOの内容は、保存しておいた操作前モデルに戻す という操作

上記は抜粋ではなく、changeModel の全てのコードです。value-type な Model の特徴を使うとUNDO は、このようにシンプルな実装になります。

なお、UndoManager は、ViewModel が保持しています。

value-type Model での UNDO のメリット(1)

コードに長所がそのままあらわれています。コードが非常にシンプルです。

実際 UNDO の実装は複雑になりがちです。特に要素間にリンクを入れる等 モデルが複雑化していると 操作前後の状況をきちんと保存して UNDO しなければいけないので非常に複雑になります。

今回の実装方法では、UNDO の操作は非常にシンプルです。「変更前のモデルに戻す」という操作です。
「1つの独立した要素の削除」を行なった時も、「複雑な複数要素間でのリンク操作」を行なった時も同じです。

この点は、強調しても強調しすぎることができないほど良い点です。

value-type Model での UNDO のメリット(2)

実は、もう1つメリットがあります。UNDO の単位を柔軟に設定できること です。

今回の例で言うと、changeModel の clousre の単位が UNDO の単位です。

個々の操作用に UNDO の操作を作り込んでいると、個別操作を組み合わせての UNDO を想定しなければならず、その点も 不具合の温床となりやすい箇所でした。

今回の実装では、どれだけ大きな変更も、1つの closure で渡せば、1つの固まった操作として UNDO できるようになります。

Model を View から見えないように

細かい点ですが、ViewModel は、Model を直接 @Published してしまっていたので、そのままでは、ビューから変更することもできてしまっていました。

private(set) 指定することで、ビューから見えるけれども変更できない状態にすることができます。
こうすることで、変更による描画更新は行われるが、ビューからのモデル変更を防ぐことができます。

# この設定は、Model が value-type であるかどうかに関わらない設定です。

まとめ:value-type なモデルで作る UNDO

value-type なモデルで作る UNDO
  • UNDO は、変更前のモデルを保存しておくだけ
  • モデルを丸ごと保存するだけなので、UNDO 操作をシンプルにできる
  • モデル操作を closure で 呼び出し側で指定できることで、UNDO 単位も柔軟になる

value-type な Model で作ってみた感想

WWDC のビデオで非常に強調されていた点が、自分で手を動かすことで、よくわかります。

これまで、モデルの複雑化に伴った UNDO の複雑化で非常に苦労していたので、ここまで単純になる UNDO には、驚くしかありません。

ただ、copy-on-write の力を信じ切っているわけではないので、モデル丸ごとコピーによるメモリ使用量等をどこかで検証してみようと思います。

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

コメントを残す

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