Ordered Collection Diffing

Patrick Montalto

Determining and applying differences between states often requires a lot of error-prone, complex code. However, Swift 5.1 introduces ordered collection diffing by way of SE-0240. This change adds native support for diffing and patching functionality for various collection types. Many state management patterns can benefit from this improvement, particularly applications with undo/redo stacks or those which sync content to/from a service.

Let’s examine a simple use case for one of the new methods introduced with this change, difference(from:). This method returns the difference needed to produce a collection’s ordered elements from another given collection, as long as the collections consist of Equatable elements. We can then switch over the diff, handling the remove and insert cases as we wish.

let diff = newData.difference(from: data)

for change in diff {
  switch change {
  case let .remove(offset, element, associatedWith):
    // Do something with removal
  case let .insert(offset, element, associatedWith):
    // Do something with insertion
  }
}

Within the .remove and .insert cases we’ll also have access to the associated values offset, element, and an optional associatedWith. For .remove, the offset value is the offset of the element to be removed in the original state of the collection. For .insert, the offset value is that of the inserted element in the final state of the collection after the difference is fully applied. For both cases, associatedWith is the value of the offset of the complimentary change, allowing us to track pairs of changes at the same time. We won’t concern ourselves with this optional value for now, and instead focus on the offset value.

Diffing with UITableViews

Let’s explore the usefulness of this new method in regards to UITableViews. In our demo application, we have a UITableView which contains cells of numbers. We can pull to refresh the data source and update the UITableViewCells upon receiving the new data. We’ll use this new diffing method in conjunction with performBatchUpdates(_:completion:) to insert and delete the appropriate UITableViewCells. Since deletes are processed before inserts in batch operations using performBatchUpdates(_:completion:), mirroring that of difference(from:), there are no inconsistencies between the indices generated by the offsets of our diff.

Note: In order to call difference(from:), we’re required to add an availability check for iOS 9999, which in Swift 5.1 will always return true. This represents a future iOS version that has not yet been released.

 private func fetchNewData() {
    // Simulate a two second long network request
    networkQueue.asyncAfter(deadline: .now() + 2) { [weak self] in
      if #available(iOS 9999, *) {
        DispatchQueue.main.async {
          guard let self = self else { return }
          var deletedIndexPaths = [IndexPath]()
          var insertedIndexPaths = [IndexPath]()
          let newData = self.simulateNewData()
          let diff = newData.difference(from: self.data)

          // Gather the the index paths to be deleted and inserted via the diff
          for change in diff {
            switch change {
            case let .remove(offset, _, _):
              deletedIndexPaths.append(IndexPath(row: offset, section: 0))
            case let .insert(offset, _, _):
              insertedIndexPaths.append(IndexPath(row: offset, section: 0))
            }
          }

          self.data = newData

          self.tableView.performBatchUpdates({
            self.tableView.deleteRows(at: deletedIndexPaths, with: .fade)
            self.tableView.insertRows(at: insertedIndexPaths, with: .right)
          }, completion: { completed in
            self.refreshControl.endRefreshing()
            print("All done updating!")
          })
        }
      }
    }
  }
}

Aside from using difference(from:), it’s useful to call performBatchUpdates(_:completion:) when you want to make multiple changes to the table view in one single animated operation. This is beneficial over just calling reloadData, which can unnecessarily reload all cells, headers, and footers (even those which have not changed). Also, reloadData won’t allow granular control or provide the ability to animate the changes.

Now, pulling to refresh and fetch new data will update the table view by fading out the deleted rows and inserting the new ones from the right:

Updating Table View With Animations

Wrap Up

Swift 5.1 brings many improvements to the language, and native support for ordered collection diffing is a much welcomed addition. This could allow projects which had previously depended on third-party libraries, or other potentially error-prone diffing code, to use this native implementation instead.

For the full example project, check out the repository here. You’ll need to be sure you’re running a version of Xcode which uses a Swift 5.1 toolchain in order to build the application.