原文:UndoManager Tutorial: How to Implement With Swift Value Types

作者:Lyndsey Scott

译者:kmyhy

注: 本教程基于 Xcode 10 和 iOS 12。

金无足赤,人无完人。只要你实现了 UndoManager,你的用户也没有必要做完人。

UndoMananger 为 app 提供了一种简单的 undo/redo 机制。通过让事物逐步“局部化”,你还能在一定程度上减少在推断中偶然出现的缺陷。

在本教程中,你将编写一个 People Keeper 的 app,使用 Swift 的值类型改善你的局部推断(local reasoning),学习如何通过改进局部推断来实现完美的 undo/redo。

注:本教程假设你拥有中级 iOS 和 Swift 开发基础。如果你刚开始学习 iOS / Swift 开发,请先阅读我们的《教你用 Swift 编写 iOS app》系列教程。

开始

通过 Dowload Materials 按钮下载教程代码。编译运行 app:

这个 app 中有一些你可能会遇到并记住的人。点击 Bob、Joa 或 Sam,你将在 cell 下面看到他们的体征、喜好、忌讳。

点击 PeopleListViewContoroller(左图)中的 Bob,会打开 PersonDetailViewController(右图)。右边一系列截图显示了 PersonDetailViewController 的 scroll view 中的内容。

要理解示例代码,请浏览项目文件并仔细阅读其中的注释。添加、编辑联系人的工作就留给你自己去完成了。

修改 app

如果 Sam 剃掉了胡子、Joan 戴起了眼镜怎么办?又或者,在那个特别寒冷的冬天,Bob 突然对那个冬天中的每一样东西都不喜欢了怎么办?在真实背景中,能够修改 People Keeper 中的人物是非常有用的。

实现选中操作

首先,如果在 PersonDetailViewController 中选择某个新特性,那么预览页应该随之改变。为此,在 PersonDetailViewController 的 UICollectionViewDelegate 和 UICollectionViewDataSource 扩展中添加代码:

override func collectionView(_ collectionView: UICollectionView,didSelectItemAt indexPath: IndexPath) {
// 1switch Section(at: indexPath) {
// 2case .hairColor:person.face.hairColor = Person.HairColor.allCases[indexPath.row]case .hairLength:person.face.hairLength = Person.HairLength.allCases[indexPath.row]case .eyeColor:person.face.eyeColor = Person.EyeColor.allCases[indexPath.row]
// 3case .glasses:person.face.glasses = true
// 4case .facialHair:person.face.facialHair.insert(Person.FacialHair.allCases[indexPath.row])
// 5case .likes:person.likes.insert(Person.Topic.allCases[indexPath.row])person.dislikes.remove(Person.Topic.allCases[indexPath.row])case .dislikes:person.dislikes.insert(Person.Topic.allCases[indexPath.row])person.likes.remove(Person.Topic.allCases[indexPath.row])default:break}
// 6collectionView.reloadData()
}

当 cell 被选中,需要做如下动作:

  1. 在 Switch 语句中,根据不同的 section 执行不同的 case 分支。
  2. 如果用户选择的是头发颜色,则根据 index path 的 row 来改变人物的头发颜色为 Person.HairColor。如果用户选择了头发长度或者眼睛颜色,则设置的就是头发长度或眼睛颜色。
  3. 当用户点击眼睛,那么该人物的 glasses 属性就变成 true。
  4. facialHair 是一个 Set 集合,因为它包含许多选项。当用户选择某个胡须类型时,就会添加到这个集合中。
  5. 当用户从喜好和忌讳中选中某一项时,则添加到对应的 likes 或 dislikes 集合。同时,一样东西不可能同时在 likes 和 dislikes 中同时存在,因此当用户喜欢某样东西时,这样东西就会从 disklikes 中移除,反之亦然。
  6. 刷新 Collection view ,更新预览和选中内容的 UI。

实现反选操作

接下来,实现反选操作。在 collectionView(_:didSelectItemAt:) 之下,添加:

// 1
override func collectionView(_ collectionView: UICollectionView,shouldDeselectItemAt indexPath: IndexPath) -> Bool {switch Section(at: indexPath) {case .facialHair, .glasses, .likes, .dislikes:return truedefault:return false}
}override func collectionView(_ collectionView: UICollectionView,didDeselectItemAt indexPath: IndexPath) {switch Section(at: indexPath) {// 2case .facialHair:person.face.facialHair.subtract([Person.FacialHair.allCases[indexPath.row]])case .likes:person.likes.subtract([Person.Topic.allCases[indexPath.row]])case .dislikes:person.dislikes.subtract([Person.Topic.allCases[indexPath.row]])case .glasses: // 3person.face.glasses = falsedefault:break}collectionView.reloadData()
}

在上面的委托方法中:

  1. 这里,定义只有当胡须、眼睛、喜好和忌讳被选中后才能通过再次点击来反选。而其它 section 只有当用户选择了同一类的其它 item 时才会被反选。
  2. 当用户反选了胡须、喜好或忌讳后,将该项从对应的 set 中移除。
  3. 当用户反选眼镜时,将 glasses 设置为 false。

Build & run app。现在你会看到选中后的效果:

现在你已经是一个 People Keeper 技术的高手了。你可以去炫耀这款新式武器了。当某天有个厉害的开发者看到这个 app 时,发现可以用 foundation 中的一个强大的类来保护你的市场地位,抵抗那些邪恶的竞争者……

UndoManager 介绍

UndoManager 是一个通用的 undo 栈,用于简化 app 的状态管理。它可以保存你想保存的任何对象或 UI 状态,通过一个闭包、方法或 invocation ,你可以跟踪和回溯这些状态。如果实现正确,它很容易实现 undo/redo 功能,但经验比较少的开发者很可能在实现 UndoManager 时导致致命的错误。下面两个 undo 栈例子中,一个是有问题的,一个是正确的。

Undo 栈示例 1

Undo 栈 #1 是一系列小步骤,每一步都会修改模型并让视图保持一致。尽管这种策略理论上是可行的,但是随着操作列表的增长,出错的可能性也会增加,因为精确地匹配模型中的每一个变更到视图中会越来越困难。

要理解这个,请做一个练习:

  1. 当你第一次 pop undo 操作栈之后,模型会变成什么样?

    答案:Bob,Sam

  2. 第二次 undo 之后呢?

    答案:Bob, Kathy

  3. 第三次之后呢?

    答案:Bob, Kathy, Mike

无论你的结果是什么,你都可以想象得到,反复删除和插入操作多次后,后续的插入、删除、更新需要对索引进行的计算有多复杂。undo 栈是依赖于顺序的,顺序错误会导致数据模型和视图不一致。这个错误有点眼熟吧:

Undo 栈示例 2

要避免上面的错误,就不要将数据模型和 UI 变更分开记录,而是记录整个模型:

要撤销一个操作,你可以用 undo 栈中的模型替换当前模型。Undo 栈 1 和 栈 2 做同样的工作,但栈 2 是不依赖顺序的,同时出错的可能性更小。

undo 详情视图

在 PersonDetailViewController.swift 底部加入:

// MARK: - Model & State Typesextension PersonDetailViewController {
// 1private func personDidChange(from fromPerson: Person) {
// 2collectionView?.reloadData()
// 3undoManager.registerUndo(withTarget: self) { target inlet currentFromPerson: Person = self.personself.person = fromPersonself.personDidChange(from: currentFromPerson)}
// 4// Update button UI DispatchQueue.main.async {self.undoButton.isEnabled = self.undoManager.canUndoself.redoButton.isEnabled = self.undoManager.canRedo}}
}

上面的代码执行了以下步骤:

  1. personDidChange(from:) 使用之前版本的 person 作为参数。
  2. 刷新 collection view,更新预览和单元格选中状态。
  3. undoManager 注册了一个 undo 操作,当进行撤销操作时,将 self.person 设置为上一次操作的 person,然后递归调用 personDidChange(from:) 方法。personDidChange(from:) 会更新 UI,然后又注册 undo 的 undo …,这样就为 undo 过的操作注册了一个 redo 路径。
  4. 如果 undoManager 能够进行 undo 操作 ——即 canUndo 为 true,那么 enable undo 按钮 —— 否则,disable 它。redo 按钮也是同样的。如果代码在主线程中运行,undo mananger 不会更新状态,除非方法 return。通过 DispatchQueue 块让 UI 刷新等到 undo/redo 操作完成。

然后,在 collectionView(_:didSelectItemAt:) 和 collectionView(_:didDeselectItemAt:) 的头部添加:

let fromPerson: Person = person

保存一份原 person 的实例。

在这两个委托方法的最后,将 collectionView.reloadData() 替换为:

personDidChange(from: fromPerson)

这样就注册了一个能恢复到 fromPerson 的 undo 操作。我们将collectionView?.reloadData() 删除,是因为在personDidChange(from:) 已经调用了它,没有必要调用两次。

在 undoTapped() 方法中加入:

undoManager.undo()

然后在 redoTapped() 中添加:

undoManager.redo()

分别用于进行 undo 和 redo 操作。

实现摇晃手势

接下来,实现通过摇晃设备触发 undo/redo。在 viewDidAppear(_? 底部添加:

becomeFirstResponder()

在 viewWillDisappear(_? 底部添加:

resignFirstResponder()

在 viewWillDisappear(_? 后面添加:

override var canBecomeFirstResponder: Bool {return true
}

当用户摇晃手机进行 undo/redo 时,NSResponder 会在 responder 链中查找下一个能够返回 NSUndoManager 对象的 reponder。当你将 PersonDetailViewController 设置为 first responder 后,它的 undoManager 会负责响应摇晃手势,并用一个 option 表示 undo/redo 操作。

Build & run。切换到 PersonDetailViewController,改变头发颜色,然后点击 undo/redo 或者摇晃手机。

注意点击 undo/redo 时不会改变预览图。

来 debug 一下,在 registerUndo(withTarget:handler:) 闭包开头加上:

print(fromPerson.face.hairColor)
print(self.person.face.hairColor)

再次 build & run。修改头发颜色多次,undo 然后 redo。现在,注意 debug 控制台,你会看到,在 undo/redo 时,两个打印语句都只输出了最终选择的那个颜色。是 UndoManager 出错了吗?

NO! 这个问题是其它代码导致的。

改善局部推理性

局部推理性是一个概念,它能够不依赖于上下文理解代码的片段。

例如在本教程中,你使用了闭包,懒加载、协议扩展和精简代码路径来使你的一部分代码易于理解,那就不需要再去阅读它们的范围之外的代码了——只需要阅读“局部的”代码。

那怎么解决这个 bug 呢?你可以用提升局部推理性来修改这个 bug。通过理解引用类型和值类型之间的区别,你会知道怎样让你的代码拥有更好的局部控制能力。

引用类型和值类型

在 Swift 中,引用类型和值类型是两种不同的“类型”。对于引用类型,比如一个类,对同一实例的不同引用将共享同一内存。值类型不同——比如 struct、enum 和 tuple —— 它们每一个实例都拥有独立的数据。

要理解这对你面临的难题有什么用,请尝试用你刚学的引用类型与值类型之间的区别来回答下列问题:

  1. 如果 Person 是一个类:

    var person = Person()
    person.face.hairColor = .blonde
    var anotherPerson = person
    anotherPerson.face.hairColor = .black
    

    问:person.face.hairColor == ??

    答案:.black

  2. 如果 Person 是一个 struct:

    var person = Person()
    person.face.hairColor = .blonde
    var anotherPerson = person
    anotherPerson.face.hairColor = .black
    

    问:person.face.hairColor == ??

    答案:.blonde

有问题的引用会损害局部推理,因为对象的值可能在你的控制下发生变化,在没有上下文的情况下是不能使用的。

因此在 Person.swift 中,将 Person 类修改为:

struct Person {

这样 Person 就变成值类型了,拥有单独的内存。

Build & run。然后,修改人物的特征,undo 然后 redo,看看有什么变化:

undo 和 redo 选项现在工作正常了。

然后,为 name 的修改添加 undo/redo 能力。回到 PersonDetailViewController.swift ,在 UITextFieldDelegate 扩展中添加:

func textFieldDidEndEditing(_ textField: UITextField) {if let text = textField.text {let fromPerson: Person = personperson.name = textpersonDidChange(from: fromPerson)}
}

当编辑完 text field 后,将新 name 设置给 Person,然后注册 undo 操作。

Build & run。现在,进行名字、特征的修改,undo、redo 等等。大部分功能都正常,但有一个小问题。如果你选择 name 字段,然后按返回键,不进行任何编辑,undo 按钮会激活,说明有一个 undo 操作被注册到 undoManager 中了,虽然你根本未进行任何修改:

为了解决这个问题,你需要对原 name 和新 name 进行比对,只有二者值不同时才注册 undo。但这样做的局部推理就很差了——尤其是当人物的属性列表变大的时候,比较简单的做法是比较整个 person 对象而非比对单一属性。

在 personDidChange(from:) 一开始添加:

if fromPerson == self.person { return }

理论上,这是对老对象和新对象进行了比较,但实际上却会报错:

Binary operator '==' cannot be applied to operands of type 'Person' and 'Person!'

正如它所说,Person 对象并没有内置的 compare 方法,因为其中有几个属性是自定义类型。你必须自己定义比较方法。幸好,struct 有一个简单的解决方法。

让 struct 变成 Equatable 的

回到 Person.swift ,添加一个扩展,让它遵守 Equatable:

// MARK: - Equatableextension Person: Equatable {static func ==(_ firstPerson: Person, _ secondPerson: Person) -> Bool {return firstPerson.name == secondPerson.name &&firstPerson.face == secondPerson.face &&firstPerson.likes == secondPerson.likes &&firstPerson.dislikes == secondPerson.dislikes}
}

现在,如果两个 Person 的名字、面孔、喜好、忌讳相等,那么他们相等,否则不等。

注:你可以对 Face 和 Topic 对象使用 ==(_:_?,而无需让他们实现 Equatable,因为他们仅仅是由 String 构成的对象,而 String 在 Swift 中本来就是 Equatable 对象。

回到 PersonDetailViewController.swift。Build & run。if fromPerson == self.person 上的错误将消失。现在你的这句代码 ok 了,待会还要完全删除它。用一个 diff 取代它,将有利于提升你的局部推理。

创建 diff

在编程语言中,diff 用于比较两个对象是否不同或有多不同。通过创建一个 diff 值类型,可以将(1) 原对象、(2) 修改过的对象、(3) 以及它们的比较方法都放在一个单一的、“局部”的地方。
在 Person 结构体最后添加:

// 1
struct Diff {let from: Personlet to: Personfileprivate init(from: Person, to: Person) {self.from = fromself.to = to}
// 2var hasChanges: Bool {return from != to}
}
// 3
func diffed(with other: Person) -> Diff {return Diff(from: self, to: other)
}

上述代码定义了:

  1. struct Diff 保存了两个 Person,源(from)和目标(to)。
  2. 如果 from to 不同,hasChange 是 true,否则 false。
  3. diffed(with:) 会返回一个 Diff,包含了它的旧值 from 和新值 to。

在 PersonDetailViewController,将 private func personDidChange(from fromPerson: Person) { 替换为:

private func personDidChange(diff: Person.Diff) {

现在参数变成了 Diff 而非仅仅 from 对象。

然后,将 if fromPerson == self.person { return } 换成:

guard diff.hasChanges else { return }

利用了 diff 的 hasChanges 属性。

同时删除先前添加的两句 print 语句。

提升代码的相邻性

在将 personDidChange(from:) 替换为 personDidChange(diff:) 之前,先来看一眼 collectionView(_:didSelectItemAt:) 和 collectionView(_:didDeselectItemAt:) 方法。

在每个方法中,注意 person 对象在类一开始就保存了原始值,但到最后也没有用到它。你可以通过将这个对象的创建移到更近的地方,来提升代码的局部推理性。

在同一扩展的 personDidChange(diff:) 方法之前添加方法:

// 1
private func modifyPerson(_ mutatePerson: (inout Person) -> Void) {// 2var person: Person = self.person// 3let oldPerson = person// 4mutatePerson(&person)// 5let personDiff = oldPerson.diffed(with: person)personDidChange(diff: personDiff)
}

上述代码解释如下:

  1. modifyPerson(_? 使用一个闭包作为参数,该闭包接收一个 Person 对象指针。
  2. var person 保存这个类的当前 Person 对象的可变副本。
  3. oldPerson 保存一个原 person 对象的常量引用
  4. 调用 (inout Person) -> Void 闭包,这个闭包是调用 modifyPerson(_? 时传入的。这句代码负责修改 person 变量。
  5. personDidChange(diff:) 方法更新 UI 并注册 undo 操作,恢复到 fromPerson 数据模型对象。

在 collectionView(_:didSelectItemAt:)、collectionView(_:didDeselectItemAt:) 和 textFieldDidEndEditing(_? 方法中调用 modifyPerson(_?,将 let fromPerson: Person = person 替换为:

modifyPerson { person in

将 personDidChange(from: fromPerson) 替换为:

}

这样就将代码放到了 modifyPerson(_? 闭包中。

同样,在 undoManager 的 registerUndo 闭包中,将 let currentFromPerson: Person = self.person 替换成:

target.modifyPerson { person in

将 self.personDidChange(from: fromPerson) 替换成:

}

这样代码就被一个闭包简化了。这种设计方式将修改代码集中到一处,保证我们 UI 的局部推理性。

选中类中所有代码,点击菜单 Editor > Structure > Re-Indent 重排闭包的缩进。

在 personDidChange(diff:) 的 guard diff.hasChanges else { return } 之后、collectionView?.reloadData() 之前添加:

person = diff.to

将类的 person 属性设置为更新后的 person。

同样,在 target.modifyPerson { person in … } 闭包中,将 self.person = fromPerson 替换为:

person = diff.from

当 undo 时恢复之前的 person。

Build & run。查看人物详情视图,每一样功能都正常了。你的 PersonDetailViewController 代码已经写完了!

现在,点击 < PeopleKeeper 返回按钮。呃……我们的修改去哪里了呢?你必须将修改传给 PeopleListViewController。

修改人员名单

在 PersonDetailViewController 头部添加:

var personDidChange: ((Person) -> Void)?

和 personDidChange(diff:) 方法不同,这个 personDidChange 变量会保存一个闭包,这个闭包用修改后的 person 作为参数。

在 viewWillDisappear(_? 方法开头,添加:

personDidChange?(person)

当 view 消失,返回到主界面时,修改后的 person 会传给这个闭包。

现在需要给这个闭包赋值。

回到 PeopleListViewController, 找到 prepare(for:sender:)。当转换到人员的详情视图时,prepare(for:sender:) 会发送一个 person 对象给目标控制器。同样,你可以在这个方法中添加一个闭包,以接收从目标控制器返回的 person 对象。

在 prepare(for:sender:) 最后添加:

detailViewController?.personDidChange = { updatedPerson in// 暂时空缺: 更新数据模型和 UI
}

这句代码对 detailViewController 的 personDidChange 闭包进行初始化。最终你会在占位注释的地方编写更新数据和 UI 的代码,在这之前,还有一些准备工作要做。

打开 PeopleModel.swift。在 PeopleModel 的最后、类的内部添加:

struct Diff {
// 1enum PeopleChange {case inserted(Person)case removed(Person)case updated(Person)case none}
// 2 let peopleChange: PeopleChangelet from: PeopleModellet to: PeopleModelfileprivate init(peopleChange: PeopleChange, from: PeopleModel, to: PeopleModel) {self.peopleChange = peopleChangeself.from = fromself.to = to}
}

这段代码主要做了以下事情:

  1. Diff 中定义了一个 PeopleChange 枚举,它描述了:1)from 和 to 之间变化是插入、删除、修改还是什么也没做;2)哪一个 person 是被插入、删除或修改的 person。
  2. Diff 中保存了原始值和修改值(PeopleModel),以及 PeopleChange。

要计算出被插入、删除或修改的 person 到底是哪个,要在 Diff 结构体之后添加这个函数:

// 1
func changedPerson(in other: PeopleModel) -> Person? {
// 2if people.count != other.people.count {let largerArray = other.people.count > people.count ? other.people : peoplelet smallerArray = other.people == largerArray ? people : other.peoplereturn largerArray.first(where: { firstPerson -> Bool in!smallerArray.contains(where: { secondPerson -> Bool infirstPerson.tag == secondPerson.tag})})
// 3} else {return other.people.enumerated().compactMap({ index, person inif person != people[index] {return person}return nil}).first}
}

上述代码分解为以下几个步骤:

  1. changedPerson(in:) 对比 self 的当前 PeopleModel 和参数传入的 PeopleModel,然后返回被插入/删除/修改的那个 Person。
  2. 如果两个数组元素个数不等,找出二者中较大的一个,然后在这个数组中找出较小者中不包含的第一个元素。
  3. 如果两个数组元素个数相同,那么应该是修改操作而非插入或删除操作,这时,遍历新数组,找出和老数组中对应元素不同的 person。

在 changedPerson(in:) 下面添加方法:

// 1
func diffed(with other: PeopleModel) -> Diff {var peopleChange: Diff.PeopleChange = .none
// 2if let changedPerson = changedPerson(in: other) {if other.people.count > people.count {peopleChange = .inserted(changedPerson)} else if other.people.count < people.count {peopleChange = .removed(changedPerson)} else {peopleChange = .updated(changedPerson)}}
//3return Diff(peopleChange: peopleChange, from: self, to: other)
}

来看一下上面的代码:

  1. peopleChange 先初始化为 none 表示没有变化。在方法最后会返回这个 peopoleChange。
  2. 如果新数组 size 大于老数组,changedPerson 是插入;如果更小是删除;如果二者 size 相等,那么是修改。在每一种情况中,用 changedPerson(in:) 返回的 person 作为 PeopleChange 的参数。
  3. 用 peopleChange、原始 PeopleModel、新 PeopleModle 构建一个 Diff 并返回。

然后在 PeopleListViewController.swift 最后添加:

// MARK: - Model & State Typesextension PeopleListViewController {
// 1private func peopleModelDidChange(diff: PeopleModel.Diff) {
// 2switch diff.peopleChange {case .inserted(let person):if let index = diff.to.people.index(of: person) {tableView.insertRows(at: [IndexPath(item: index, section: 0)], with: .automatic)}case .removed(let person):if let index = diff.from.people.index(of: person) {tableView.deleteRows(at: [IndexPath(item: index, section: 0)], with: .automatic)}case .updated(let person):if let index = diff.to.people.index(of: person) {tableView.reloadRows(at: [IndexPath(item: index, section: 0)], with: .automatic)}default:return}
// 3peopleModel = diff.to}
}

和 PersonDetailViewController 的 personDidChange(diff:) 一样,peopleModelDidChange(diff:) 方法主要做了以下工作:

  1. peopleModelDidChange(diff:) 使用一个 PeopleModel.Diff 参数,它根据数据模型所发生的改变来更新 UI。
  2. 如果 diff 的 peopleChange 类型是插入,则在 person 所在的位置插入一行。如果 peopleChange 是删除,则删除 person 所在的行。如果 peopleChange 是修改,reload 该行。否则,没有任何改变,退出方法执行,模型和 UI 都不需要更新。
  3. 设置 class 的 peopleModel 为更新后的模型。

刚刚在 PersonDetailViewController 添加了一个 modifyPerson(_? 方法,现在再在 peopleModelDidChange(diff:) 方法前面添加一个 modifyModel(_? 方法:

// 1
private func modifyModel(_ mutations: (inout PeopleModel) -> Void) {
// 2var peopleModel = self.peopleModel
// 3   let oldModel = peopleModel
// 4  mutations(&peopleModel)
// 5tableView.beginUpdates()
// 6let modelDiff = oldModel.diffed(with: peopleModel)peopleModelDidChange(diff: modelDiff)
// 7    tableView.endUpdates()
}

这段代码解释如下:

  1. modifyModel(_? 使用一个闭包参数,这个闭包接收一个可变的 PeopleModel 指针作为参数。
  2. var peopleModel 保存一份 peopleModel 的可变的拷贝。
  3. oldModel 保存原 model 的不可变的引用。
  4. 在老模型上执行 mutations 闭包,产生新 model。
  5. 开始更新 tableView。
  6. peopleModelDidChange(diff:) 根据 modelDiff 的 peopleDiff 负责 tableView 的插入、删除或刷新。
  7. tableView 更新结束。

回到 prepare(for:sender:) 方法,将占位注释替换为:

self.modifyModel { model inmodel.people[selectedIndex] = updatedPerson
}

这会将用户所点击的索引所对应的 person 更新为修改后的版本。

最后一步。将 class PeopleModel { 替换为:

struct PeopleModel {

Build & run。选择某人的详情视图,进行某些修改,然后返回人员列表,改变会传递过来:

接着,你需要为人员列表添加删除、添加功能。

要实现删除,将 tableView(_:editActionsForRowAt:) 的占位注释替换为:

self.modifyModel { model inmodel.people.remove(at: indexPath.row)
}

这会将指定行索引的 person 删除,无论从数据模型还是 UI 上。

要实现插入,需要添加一个 addPersonTapped() 方法:

// 1
tagNumber += 1
// 2
let person = Person(name: "", face: (hairColor: .black, hairLength: .bald, eyeColor: .black, facialHair: [], glasses: false), likes: [], dislikes: [], tag: tagNumber)
// 3
modifyModel { model inmodel.people += [person]
}
// 4
tableView.selectRow(at: IndexPath(item: peopleModel.people.count - 1, section: 0), animated: true, scrollPosition: .bottom)
showPersonDetails(at: IndexPath(item: peopleModel.people.count - 1, section: 0))

代码解释如下:

  1. 类的 tagNumber 属性记录的是 people 模型中的最大 tag 值。因为添加了新的 Person,所以 tagNumber 加 1。
  2. person 刚创建时没有 name、likes 和 dislikes,但是 face 采用默认值。其 tag 值等于当前的 tagNumber。
  3. 将 person 添加到 data model 最后,更新 UI。
  4. 选中新添加的行 —— 也就是最后一行 —— 并跳转到这个 person 的详情视图,以便用户编辑。

Build & run。进行添加,修改等操作。你已经可以从人员名单中添加、删除 person 了,同时改动应该能够在两个控制器之间同步:

但是还没完 —— PeopleListViewController 的 undo/redo 还不能用。是时候做一点反破坏代码来保护你的联系人列表了!

取消联系人修改

在 peopleModelDidChange(diff:) 末尾添加:

// 1
undoManager.registerUndo(withTarget: self) { target in// 2target.modifyModel { model inmodel = diff.from}
}
// 3
DispatchQueue.main.async {self.undoButton.isEnabled = self.undoManager.canUndoself.redoButton.isEnabled = self.undoManager.canRedo
}

在这里,你:

  1. 注册一个 undo 操作,以便撤销对数据模型和 UI 的改变。
  2. 修改 people 模型,用原来的值替换当前值。
  3. Enable/disable undo/redo 按钮。

在 undoTapped() 中加入:

undoManager.undo()

在 redoTapped() 中加入:

undoManager.redo()

分别调用了 undo 和 redo 方法。

最后,为控制器添加摇晃手势。在 viewDidAppear(_? 中添加:

becomeFirstResponder()

在 viewWillDisappear(_? 中添加:

resignFirstResponder()

在 viewWillDisappear(_? 下面添加:

override var canBecomeFirstResponder: Bool {return true
}

这样控制器就能响应摇晃手势进行 undo/redo 了。

OK!Build & run。你可以编辑、添加、撤销、重做、摇晃手机了。

大功告成!

接下来去哪里

下面的 Download Materials 按钮可以下载示例代码供你参考。

要进一步了解 UndoManager API,你可以尝试一下分组撤销、对撤销动作进行命名、使撤销重做无效以及使用内置通知。

要进一步了解值类型,请尝试为 Person 和 PeopleModel 添加属性,让你的 app 更加健壮。

如果你想让 PeopleKeeper 真正能为你所用,请对数据进行持久化。更多信息,请看我们的 “Updated Course: Saving Data in iOS”。

有任何问题、建议或意见,请到论坛发帖。

Download Materials

UndoManager教程相关推荐

  1. GoJS官方教程自学笔记

    GoJS教程 GoJS是一个用于实现交互式图表的JavaScript库. 因为GoJS是一个依赖于HTML5特性的JavaScript库,所以您需要确保您的页面声明它是一个HTML5文档. 当然,你需 ...

  2. 使用Docker搭建svn服务器教程

    使用Docker搭建svn服务器教程 svn简介 SVN是Subversion的简称,是一个开放源代码的版本控制系统,相较于RCS.CVS,它采用了分支管理系统,它的设计目标就是取代CVS.互联网上很 ...

  3. mysql修改校对集_MySQL 教程之校对集问题

    本篇文章主要给大家介绍mysql中的校对集问题,希望对需要的朋友有所帮助! 推荐参考教程:<mysql教程> 校对集问题 校对集,其实就是数据的比较方式. 校对集,共有三种,分别为:_bi ...

  4. mysql备份psb文件怎么打开_Navicat for MySQL 数据备份教程

    原标题:Navicat for MySQL 数据备份教程 一个安全和可靠的服务器与定期运行备份有密切的关系,因为错误有可能随时发生,由攻击.硬件故障.人为错误.电力中断等都会照成数据丢失.备份功能为防 ...

  5. php rabbmq教程_RabbitMQ+PHP 教程一(Hello World)

    介绍 RabbitMQ是一个消息代理器:它接受和转发消息.你可以把它当作一个邮局:当你把邮件放在信箱里时,你可以肯定邮差先生最终会把邮件送到你的收件人那里.在这个比喻中,RabbitMQ就是这里的邮箱 ...

  6. 【置顶】利用 NLP 技术做简单数据可视化分析教程(实战)

    置顶 本人决定将过去一段时间在公司以及日常生活中关于自然语言处理的相关技术积累,将在gitbook做一个简单分享,内容应该会很丰富,希望对你有所帮助,欢迎大家支持. 内容介绍如下 你是否曾经在租房时因 ...

  7. Google Colab 免费GPU服务器使用教程 挂载云端硬盘

    一.前言 二.Google Colab特征 三.开始使用 3.1在谷歌云盘上创建文件夹 3.2创建Colaboratory 3.3创建完成 四.设置GPU运行 五.运行.py文件 5.1安装必要库 5 ...

  8. 理解和实现分布式TensorFlow集群完整教程

    手把手教你搭建分布式集群,进入生产环境的TensorFlow 分布式TensorFlow简介 前一篇<分布式TensorFlow集群local server使用详解>我们介绍了分布式Ten ...

  9. 高级教程: 作出动态决策和 Bi-LSTM CRF 重点

    https://www.zhihu.com/question/35866596 条件随机场 CRF(条件随机场)与Viterbi(维特比)算法原理详解 https://blog.csdn.net/qq ...

最新文章

  1. linux安装ncurses教程,Linux ncurses安装教程(2种方法)
  2. 如何通过 macOS 恢复功能重新安装 macOS
  3. OpenCV 玩九宫格数独(二):knn 数字识别
  4. 前端学习(2773):条件编译和跨端兼容
  5. ajax请求返回结果进入success还是error
  6. leetcode力扣36.有效的数独
  7. C 语言会比 C++ 快?
  8. 阿里云发布聆听平台 全球招募300位MVP
  9. Android 解决ViewPager双层嵌套的滑动问题
  10. C++网络编程实例(初识多线程)
  11. Android应用开发进阶
  12. 欧式二元期权的定价公式及实现
  13. keras入门 ---在小数据集上训练神经网络
  14. APUD命令详解 3GPP USIM 卡文件
  15. 京东饭粒捡漏V1.0.8
  16. Calander使用心得
  17. [nlp] sentiment analysis(情感分析)
  18. 主叫用户、被叫用户、局内呼叫、局间呼叫、发话端局、受话端局 等定义
  19. 32位XP开启直接支持4g内存
  20. 某东网页版自动好评脚本使用教程

热门文章

  1. 2021年危险化学品经营单位安全管理人员考试报名及危险化学品经营单位安全管理人员找解析
  2. 元宇宙游戏项目:Decentraland(治理通证:MANA)
  3. eNSP 配置简单静态路由 实现全网可达
  4. [1164]python用numpy计算均值,方差,标准差
  5. 甲级测绘资质审批常见问题-甲级测绘资质如何办理?
  6. 搜狗输入法乱码 解决
  7. 最新 NCBI 上传测序数据教程 (图文详解)
  8. 你真的会搜索资源吗?我来把我的资源搜索心得告诉你...
  9. qq发消息时键盘挡住了_键盘挡住输入框解决办法
  10. 58到家数据库30条军规解读 【转】