Sponsor Link
環境&対象
- 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 の定義
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 側のコードは、以下です。
var todoItems: [TODOItem] {
itemsDic.map { $0.value }.sorted { $0.title $1.title }
}
preview 向けの static 関数定義
SwiftUI で View を定義する時に、以下のような preview のコードがデフォルトで定義されます。
struct <ViewName>_Previews: PreviewProvider {
static var previews: some View {
<ViewName>()
}
}
この preview のコードを使って、Xcode でプレビューできます。プレビュー時にサンプルのデータが入っていると プレビューがわかりやすく、ビューの調整も行いやすくなりますので、以下のようなサンプルデータ用のコードも追加しておきます。
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 のコードとなります。
@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 を配置、右側に、+ ボタンを配置して、それぞれ、追加削除できるようにしたものです。
追加時には、シートを表示して詳細を設定できるようにしています。
特に工夫しているところもなく、普通のビューです。
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 が以下です。
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)
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