Created
March 28, 2016 15:27
-
-
Save odlp/2c8936f850f8996d9a3a to your computer and use it in GitHub Desktop.
Swift list differ for smarter UITableView refreshes
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
class ListDiffer<T: Equatable> { | |
private let beforeList: [T] | |
private let afterList: [T] | |
private let section: Int | |
private let elementComparison: ((T, T) -> Bool) | |
init(before: [T], after: [T], section: Int = 0, elementComparison: ((T, T) -> Bool) = { $0 == $1 }) { | |
self.beforeList = before | |
self.afterList = after | |
self.section = section | |
self.elementComparison = elementComparison | |
} | |
var deletions: [NSIndexPath] { | |
return beforeList.enumerate().flatMap { (i, el) in | |
guard !collectionContainsElement(self.afterList, el) else { return nil } | |
return NSIndexPath(forRow: i, inSection: section) | |
} | |
} | |
var additions: [NSIndexPath] { | |
return afterList.enumerate().flatMap { (i, el) in | |
guard !collectionContainsElement(self.beforeList, el) else { return nil } | |
return NSIndexPath(forRow: i, inSection: section) | |
} | |
} | |
var moves: [ListDifferMove] { | |
return beforeList.enumerate().flatMap { (beforeIndex, el) in | |
guard let afterIndex = indexOfElementInCollection(el, afterList) else { | |
return nil | |
} | |
if beforeIndex == afterIndex { return nil } | |
return ListDifferMove( | |
from: NSIndexPath(forRow: beforeIndex, inSection: section), | |
to: NSIndexPath(forRow: afterIndex, inSection: section) | |
) | |
} | |
} | |
var needsRefresh: [NSIndexPath] { | |
return beforeList.flatMap({ el -> NSIndexPath? in | |
guard let afterIndex = indexOfElementInCollection(el, afterList) else { return nil } | |
if noUpdateNeeded(el, afterList[afterIndex]) { return nil } | |
return NSIndexPath(forRow: afterIndex, inSection: section) | |
}) | |
} | |
// MARK: - Private | |
private func collectionContainsElement(collection: [T], _ element: T) -> Bool { | |
return indexOfElementInCollection(element, collection) != nil | |
} | |
private func indexOfElementInCollection(element: T, _ collection: [T]) -> Int? { | |
return collection.indexOf { elementComparison($0, element) } | |
} | |
private func noUpdateNeeded(a: T, _ b: T) -> Bool { | |
return a == b | |
} | |
} | |
struct ListDifferMove: Equatable { | |
let from: NSIndexPath | |
let to: NSIndexPath | |
} | |
func ==(lhs: ListDifferMove, rhs: ListDifferMove) -> Bool { | |
return lhs.from == rhs.from | |
&& lhs.to == rhs.to | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import XCTest | |
@testable import ListDiffer | |
class ListDifferTests: XCTestCase { | |
var item1: Item! | |
var item2: Item! | |
override func setUp() { | |
continueAfterFailure = false | |
item1 = Item(id: "123", name: "Foo") | |
item2 = Item(id: "456", name: "Bar") | |
} | |
func testNoChanges() { | |
let differ = ListDiffer(before: [item1], after: [item1]) | |
XCTAssertEqual(differ.deletions, [], "Expected no deletions") | |
XCTAssertEqual(differ.additions, [], "Expected no additions") | |
XCTAssertEqual(differ.moves, [], "Expected no moves") | |
XCTAssertEqual(differ.needsRefresh, [], "Expected no refreshes") | |
} | |
func testDeletion() { | |
let differ = ListDiffer(before: [item1], after: []) | |
XCTAssertEqual(differ.deletions, [NSIndexPath(forRow: 0, inSection: 0)], "Expected deletion at index 0") | |
XCTAssertEqual(differ.additions, [], "Expected no additions") | |
XCTAssertEqual(differ.moves, [], "Expected no moves") | |
XCTAssertEqual(differ.needsRefresh, [], "Expected no refreshes") | |
} | |
func testAddition() { | |
let differ = ListDiffer(before: [], after: [item1]) | |
XCTAssertEqual(differ.deletions, [], "Expected no deletions") | |
XCTAssertEqual(differ.additions, [NSIndexPath(forRow: 0, inSection: 0)], "Expected addition at index 0") | |
XCTAssertEqual(differ.moves, [], "Expected no moves") | |
XCTAssertEqual(differ.needsRefresh, [], "Expected no refreshes") | |
} | |
func testMoves() { | |
let differ = ListDiffer(before: [item1, item2], after: [item2, item1]) | |
XCTAssertEqual(differ.deletions, [], "Expected no deletions") | |
XCTAssertEqual(differ.additions, [], "Expected no additions") | |
XCTAssertEqual(differ.needsRefresh, [], "Expected no refreshes") | |
let expectedMoveA = ListDifferMove(from: NSIndexPath(forRow: 0, inSection: 0), to: NSIndexPath(forRow: 1, inSection: 0)) | |
let expectedMoveB = ListDifferMove(from: NSIndexPath(forRow: 1, inSection: 0), to: NSIndexPath(forRow: 0, inSection: 0)) | |
let moves = differ.moves | |
XCTAssertEqual(moves.count, 2, "Expected 2 moves") | |
XCTAssertTrue(moves.contains(expectedMoveA), "Expected move from index 0 to 1") | |
XCTAssertTrue(moves.contains(expectedMoveB), "Expected move from index 1 to 0") | |
} | |
func testDefaultComparison_IsStrictEquality() { | |
let before = Item(id: "999", name: "Foo") | |
let after = Item(id: "999", name: "Foo updated") | |
XCTAssertNotEqual(before, after, "Items are not strictly equal (different name)") | |
let differ = ListDiffer(before: [before], after: [after]) | |
XCTAssertEqual(differ.deletions.count, 1, "Strict equality considers this a deletion & addition") | |
XCTAssertEqual(differ.additions.count, 1, "Strict equality considers this a deletion & addition") | |
XCTAssertEqual(differ.moves, [], "Expected no moves") | |
XCTAssertEqual(differ.needsRefresh.count, 0, "Strict equality considers this a deletion & addition") | |
} | |
func testSpecifyingComparison_AllowsRefreshesToBeIdentified() { | |
let before = Item(id: "999", name: "Foo") | |
let after = Item(id: "999", name: "Foo updated") | |
let differ = ListDiffer( | |
before: [before], | |
after: [after], | |
elementComparison: { $0.id == $1.id } | |
) | |
XCTAssertEqual(differ.deletions, [], "Expected no deletions") | |
XCTAssertEqual(differ.additions, [], "Expected no additions") | |
XCTAssertEqual(differ.moves, [], "Expected no moves") | |
XCTAssertEqual(differ.needsRefresh, [NSIndexPath(forRow: 0, inSection: 0)], "Expected refreshes at index 0") | |
} | |
func testSpecifyingSection() { | |
let differ = ListDiffer(before: [item1], after: [], section: 99) | |
XCTAssertEqual(differ.deletions.first?.section, 99, "Expected deletion in section 99") | |
} | |
func testNeedsMoveAndUpdate() { | |
let item1After = Item(id: item1.id, name: "Foobar") | |
let differ = ListDiffer( | |
before: [item1, item2], | |
after: [item2, item1After], | |
elementComparison: { $0.id == $1.id } | |
) | |
XCTAssertEqual(differ.deletions.count, 0, "Expected no deletions") | |
XCTAssertEqual(differ.additions.count, 0, "Expected no additions") | |
XCTAssertEqual(differ.needsRefresh, [NSIndexPath(forRow: 1, inSection: 0)], "Expected needs update at index 1") | |
let expectedMoveA = ListDifferMove(from: NSIndexPath(forRow: 0, inSection: 0), to: NSIndexPath(forRow: 1, inSection: 0)) | |
let expectedMoveB = ListDifferMove(from: NSIndexPath(forRow: 1, inSection: 0), to: NSIndexPath(forRow: 0, inSection: 0)) | |
let moves = differ.moves | |
XCTAssertEqual(moves.count, 2, "Expected 2 moves") | |
XCTAssertTrue(moves.contains(expectedMoveA), "Expected move from index 0 to 1") | |
XCTAssertTrue(moves.contains(expectedMoveB), "Expected move from index 1 to 0") | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
class TestTableViewController: UITableViewController { | |
var items = [ | |
Item(id: "123", name: "Foobar") | |
] | |
// Abridged | |
func updateTable(newItems: [Item]) { | |
let differ = ListDiffer( | |
before: self.items, | |
after: newItems, | |
elementComparison: { $0.id == $1.id } | |
) | |
self.items = newItems | |
tableView.beginUpdates() | |
tableView.insertRowsAtIndexPaths(differ.additions, withRowAnimation: .Automatic) | |
tableView.deleteRowsAtIndexPaths(differ.deletions, withRowAnimation: .Automatic) | |
tableView.reloadRowsAtIndexPaths(differ.needsRefresh, withRowAnimation: .Automatic) | |
for move in differ.moves { | |
tableView.moveRowAtIndexPath(move.from, toIndexPath: move.to) | |
} | |
tableView.endUpdates() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment