[译]UndoManager教程

栏目: IOS · 发布时间: 5年前

内容简介:注: 本教程基于 Xcode 10 和 iOS 12。金无足赤,人无完人。只要你实现了 UndoManager,你的用户也没有必要做完人。UndoMananger 为 app 提供了一种简单的 undo/redo 机制。通过让事物逐步“局部化”,你还能在一定程度上减少在推断中偶然出现的缺陷。

注: 本教程基于 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:

[译]UndoManager教程

这个 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) {
// 1
  switch Section(at: indexPath) {
// 2
  case .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]
// 3
  case .glasses:
    person.face.glasses = true
// 4
  case .facialHair:
    person.face.facialHair.insert(Person.FacialHair.allCases[indexPath.row])
// 5
  case .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
  }
// 6
  collectionView.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 true
  default:
    return false
  }
}

override func collectionView(_ collectionView: UICollectionView,
                             didDeselectItemAt indexPath: IndexPath) {
    
  switch Section(at: indexPath) {
  // 2
  case .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: // 3
    person.face.glasses = false
  default:
    break
  }
  collectionView.reloadData()
}

在上面的委托方法中:

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

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

[译]UndoManager教程

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

UndoManager 介绍

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

Undo 栈示例 1

[译]UndoManager教程

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

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

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

    答案:Bob,Sam

  2. 第二次 undo 之后呢?

    答案:Bob, Kathy

  3. 第三次之后呢?

    答案:Bob, Kathy, Mike

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

[译]UndoManager教程

Undo 栈示例 2

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

[译]UndoManager教程

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

undo 详情视图

在 PersonDetailViewController.swift 底部加入:

// MARK: - Model & State Types

extension PersonDetailViewController {
// 1
  private func personDidChange(from fromPerson: Person) {
// 2
    collectionView?.reloadData() 
// 3
    undoManager.registerUndo(withTarget: self) { target in
      let currentFromPerson: Person = self.person
      self.person = fromPerson
      self.personDidChange(from: currentFromPerson)
    }
// 4
    // Update button UI 
    DispatchQueue.main.async {
      self.undoButton.isEnabled = self.undoManager.canUndo
      self.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(_:smiley: 底部添加:

becomeFirstResponder()

在 viewWillDisappear(_:smiley: 底部添加:

resignFirstResponder()

在 viewWillDisappear(_:smiley: 后面添加:

override var canBecomeFirstResponder: Bool {
  return true
}

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

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

[译]UndoManager教程

注意点击 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,看看有什么变化:

[译]UndoManager教程

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

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

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

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

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

[译]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: - Equatable

extension 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 对象使用 ==(_:_:smiley:,而无需让他们实现 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: Person
  let to: Person
    
  fileprivate init(from: Person, to: Person) {
    self.from = from
    self.to = to
  }
// 2
  var 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) {
  // 2
  var person: Person = self.person
  // 3
  let oldPerson = person
  // 4
  mutatePerson(&person)
  // 5
  let personDiff = oldPerson.diffed(with: person)
  personDidChange(diff: personDiff)
}

上述代码解释如下:

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

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

modifyPerson { person in

将 personDidChange(from: fromPerson) 替换为:

}

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

同样,在 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(_:smiley: 方法开头,添加:

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 {
// 1
  enum PeopleChange {
    case inserted(Person)
    case removed(Person)
    case updated(Person)
    case none
  }
// 2 
  let peopleChange: PeopleChange
  let from: PeopleModel
  let to: PeopleModel
    
  fileprivate init(peopleChange: PeopleChange, from: PeopleModel, to: PeopleModel) {
    self.peopleChange = peopleChange
    self.from = from
    self.to = to
  }
}

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

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

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

// 1
func changedPerson(in other: PeopleModel) -> Person? {
// 2
  if people.count != other.people.count {
    let largerArray = other.people.count > people.count ? other.people : people
    let smallerArray = other.people == largerArray ? people : other.people
    return largerArray.first(where: { firstPerson -> Bool in
      !smallerArray.contains(where: { secondPerson -> Bool in
        firstPerson.tag == secondPerson.tag
      })
    })
// 3
  } else {
    return other.people.enumerated().compactMap({ index, person in
      if 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
// 2
  if 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)
    }
  }
//3
  return 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 Types

extension PeopleListViewController {
// 1
  private func peopleModelDidChange(diff: PeopleModel.Diff) {
// 2
    switch 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
    }
// 3
    peopleModel = 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(_:smiley: 方法,现在再在 peopleModelDidChange(diff:) 方法前面添加一个 modifyModel(_:smiley: 方法:

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

这段代码解释如下:

  1. modifyModel(_:smiley: 使用一个闭包参数,这个闭包接收一个可变的 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 in
  model.people[selectedIndex] = updatedPerson
}

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

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

struct PeopleModel {

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

[译]UndoManager教程

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

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

self.modifyModel { model in
  model.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 in
  model.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 了,同时改动应该能够在两个控制器之间同步:

[译]UndoManager教程

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

取消联系人修改

在 peopleModelDidChange(diff:) 末尾添加:

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

在这里,你:

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

在 undoTapped() 中加入:

undoManager.undo()

在 redoTapped() 中加入:

undoManager.redo()

分别调用了 undo 和 redo 方法。

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

becomeFirstResponder()

在 viewWillDisappear(_:smiley: 中添加:

resignFirstResponder()

在 viewWillDisappear(_:smiley: 下面添加:

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


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

PHP+MySQL八大动态Web应用实战

PHP+MySQL八大动态Web应用实战

Jono Bacom / 吴连河、李剑 / 电子工业出版社 / 2008-6 / 68.00元

本书详细介绍了利用PHP+MySQL开发常见类型Web应用程序的完整设计和编码技术,并对整体设计与关键代码给予了细致、深入的剖析。其内容注重实践,提供了翔实完整的实战代码;思路独树一帜,突破过多描述语言细节的窠臼;行文风趣幽默,轻松调侃中将项目的完整设计过程分析得一清二楚。书中的示例项目完整而实用,读者甚至无需任何改动即可在实际中加以运用。. 本书适合对PHP/MySQL有初步了解但缺乏完整......一起来看看 《PHP+MySQL八大动态Web应用实战》 这本书的介绍吧!

SHA 加密
SHA 加密

SHA 加密工具

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具