Sponsor Link
目次
環境&対象
- macOS Big Sur 11.2.3
- Xcode 12.4
- iOS 14.4
Model
前回モデルとして、struct TODOItem と struct TODOItems を定義しました。どちらも struct で定義し、プロパティも value-type を使っています。

このモデルの情報を表示するための View と ViewModel を作っていきます。
ViewModel
View を SwiftUI で作成するので、ViewModel は、Observable に準拠させ、Model のデータは、@Published として定義します。
こうすることで、Model のデータ変更で画面の再描画が トリガーされることになります。
# アプリ名を LucidMobile にするつもりなので、クラス名を LucidMobileTODOViewModel にしています。
ViewModel の定義
1 2 3 4 5 6 7 8 9 10 11 12 |
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 側のコードは、以下です。
1 2 3 4 5 |
var todoItems: [TODOItem] { itemsDic.map { $0.value }.sorted { $0.title < $1.title } } |
preview 向けの static 関数定義
SwiftUI で View を定義する時に、以下のような preview のコードがデフォルトで定義されます。
1 2 3 4 5 6 7 |
struct <ViewName>_Previews: PreviewProvider { static var previews: some View { <ViewName>() } } |
この preview のコードを使って、Xcode でプレビューできます。プレビュー時にサンプルのデータが入っていると プレビューがわかりやすく、ビューの調整も行いやすくなりますので、以下のようなサンプルデータ用のコードも追加しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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 のコードとなります。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
@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 を配置、右側に、+ ボタンを配置して、それぞれ、追加削除できるようにしたものです。
追加時には、シートを表示して詳細を設定できるようにしています。
特に工夫しているところもなく、普通のビューです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
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 が以下です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
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 } } |
- モデル操作の中心的関数です。(以降で説明します)
- View から呼ばれることを想定した TODOItem 追加メソッドです
- View から呼ばれることを想定した TODOItem 削除メソッドです
- UNDO 周りの関数です
value-type の Model を使った モデル操作と UNDO/REDO の実装説明
WWDC のビデオでも説明していますが、ここが、value-type の Model の見せどころ(?)です。
Model が value-type であることを利用して、操作前の Model をまるっと保存することで UNDO データとします。
複数の箇所で Model 操作を行なってしまうと、不整合が起こるかもしれませんので、ViewModel に changeModel というメソッドを作り集めます。
ポイントは、changeModel の実装です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// (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 } } } |
- changeModel は、引数に モデル変更操作の closure を受ける
- 操作前に、現在のモデルを保存しておく
- 変更操作を実行
- UndoManager に UNDO を登録
- 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
- UNDO は、変更前のモデルを保存しておくだけ
- モデルを丸ごと保存するだけなので、UNDO 操作をシンプルにできる
- モデル操作を closure で 呼び出し側で指定できることで、UNDO 単位も柔軟になる
value-type な Model で作ってみた感想
WWDC のビデオで非常に強調されていた点が、自分で手を動かすことで、よくわかります。
これまで、モデルの複雑化に伴った UNDO の複雑化で非常に苦労していたので、ここまで単純になる UNDO には、驚くしかありません。
ただ、copy-on-write の力を信じ切っているわけではないので、モデル丸ごとコピーによるメモリ使用量等をどこかで検証してみようと思います。
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link