From 9aeac1549b755d1ac0880159a55a5a2d64a2b008 Mon Sep 17 00:00:00 2001 From: Srdan Rasic Date: Fri, 27 Nov 2015 14:46:37 +0100 Subject: [PATCH] Fix observable binding and add array diff. --- README.md | 25 +++++- ReactiveKit.xcodeproj/project.pbxproj | 14 ++++ ReactiveKit/Info.plist | 2 +- ReactiveKit/Internals/ArrayDiff.swift | 83 +++++++++++++++++++ ReactiveKit/Observable/Observable.swift | 6 +- .../ObservableCollection+Array.swift | 60 ++++++++++++-- ReactiveKitTests/ArrayDiffTests.swift | 61 ++++++++++++++ .../ObservableCollectionSpec.swift | 10 +++ 8 files changed, 249 insertions(+), 12 deletions(-) create mode 100644 ReactiveKit/Internals/ArrayDiff.swift create mode 100644 ReactiveKitTests/ArrayDiffTests.swift diff --git a/README.md b/README.md index 4840e66..755ee97 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,29 @@ The resulting `sortedOptions` is of type `ObservableCollection<[(String, String) > Same threading rules apply for observable collection bindings as for observable bindings. You can safely modify the collection from a background thread and be confident that the UI updates occur on the main thread. +### Array diff + +When you need to replace an array with another array, but need an event to contains fine-grained changes (for example to update table/collection view with nice animations), you can use method `replace:performDiff:`. For example, if you have + +```swift +let numbers: ObservableCollection([1, 2, 3]) +``` + +and you do + +```swift +numbers.replace([0, 1, 3, 4], performDiff: true) +``` + +then the observed event will contain: + +```swift +Assert(event.collection == [0, 1, 3, 4]) +Assert(event.inserts == [0, 3]) +Assert(event.deletes == [1]) +``` + +If that array was bound to a table or a collection view, the view would automatically animate only the changes from the *merge*. Helpful, isn't it. ## Operation @@ -217,7 +240,7 @@ image.bindTo(imageView1) image.bindTo(imageView2) ``` -> Method `shareNext` buffers results of the operation using `ActiveStream` type. To learn more about that, continue reading. +> Method `shareNext` buffers results of the operation using `ObservableBuffer` type. To learn more about that, continue reading. ### Transformations diff --git a/ReactiveKit.xcodeproj/project.pbxproj b/ReactiveKit.xcodeproj/project.pbxproj index e9afcb5..dd88645 100644 --- a/ReactiveKit.xcodeproj/project.pbxproj +++ b/ReactiveKit.xcodeproj/project.pbxproj @@ -107,6 +107,11 @@ EC2C7A461C0314BC006BFEE1 /* StreamSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECBCCDDF1BEB6B9B00723476 /* StreamSpec.swift */; }; EC2C7A471C0314BC006BFEE1 /* OperationSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC835BE11BEC923400463098 /* OperationSpec.swift */; }; EC2C7A481C0314BC006BFEE1 /* ObservableCollectionSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16EABACF1C01AD82008B20BD /* ObservableCollectionSpec.swift */; }; + EC7591EC1C08710A001F31B3 /* ArrayDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7591EB1C08710A001F31B3 /* ArrayDiff.swift */; }; + EC7591ED1C08710A001F31B3 /* ArrayDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7591EB1C08710A001F31B3 /* ArrayDiff.swift */; }; + EC7591EE1C08710A001F31B3 /* ArrayDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7591EB1C08710A001F31B3 /* ArrayDiff.swift */; }; + EC7591EF1C08710A001F31B3 /* ArrayDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7591EB1C08710A001F31B3 /* ArrayDiff.swift */; }; + EC7591F11C0871EF001F31B3 /* ArrayDiffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7591F01C0871EF001F31B3 /* ArrayDiffTests.swift */; }; EC9549B31BF1EFA2000FC2BF /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1648A99E1BF12CE9007A185C /* Result.swift */; }; EC9549B41BF1EFA3000FC2BF /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1648A99E1BF12CE9007A185C /* Result.swift */; }; EC9549B51BF1EFA4000FC2BF /* Result.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1648A99E1BF12CE9007A185C /* Result.swift */; }; @@ -164,6 +169,8 @@ EC0DF29E1BF4909C00DFF3E6 /* MutableObservableCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MutableObservableCollection.swift; sourceTree = ""; }; EC2C7A0C1C02ED71006BFEE1 /* PerformanceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PerformanceTests.swift; sourceTree = ""; }; EC2C7A411C031030006BFEE1 /* ObservableBuffer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ObservableBuffer.swift; path = ReactiveKit/ObservableBuffer/ObservableBuffer.swift; sourceTree = SOURCE_ROOT; }; + EC7591EB1C08710A001F31B3 /* ArrayDiff.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArrayDiff.swift; sourceTree = ""; }; + EC7591F01C0871EF001F31B3 /* ArrayDiffTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ArrayDiffTests.swift; sourceTree = ""; }; EC835BE11BEC923400463098 /* OperationSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OperationSpec.swift; sourceTree = ""; }; EC835C001BECB0FB00463098 /* ReactiveKitPlayground.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = ReactiveKitPlayground.playground; sourceTree = SOURCE_ROOT; }; ECBCCDD01BEB6B9A00723476 /* ReactiveKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReactiveKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -317,6 +324,7 @@ EC835BE11BEC923400463098 /* OperationSpec.swift */, 16EABACF1C01AD82008B20BD /* ObservableCollectionSpec.swift */, EC2C7A0C1C02ED71006BFEE1 /* PerformanceTests.swift */, + EC7591F01C0871EF001F31B3 /* ArrayDiffTests.swift */, 16F0B8791BEFC3A10071847A /* Dependencies */, ECBCCDE11BEB6B9B00723476 /* Info.plist */, ); @@ -341,6 +349,7 @@ children = ( ECBCCDF21BEB6BBE00723476 /* Lock.swift */, ECBCCDF31BEB6BBE00723476 /* Reference.swift */, + EC7591EB1C08710A001F31B3 /* ArrayDiff.swift */, ); path = Internals; sourceTree = ""; @@ -627,6 +636,7 @@ 16C33B341BEFBA2D00A0DBE0 /* Observable.swift in Sources */, 16C33B331BEFBA2D00A0DBE0 /* Stream+Operation.swift in Sources */, EC0DF29B1BF48FF400DFF3E6 /* MutableObservable.swift in Sources */, + EC7591ED1C08710A001F31B3 /* ArrayDiff.swift in Sources */, 16C33B431BEFBA2D00A0DBE0 /* NoError.swift in Sources */, 16C33B321BEFBA2D00A0DBE0 /* OperationSink.swift in Sources */, 16C33B401BEFBA2D00A0DBE0 /* Lock.swift in Sources */, @@ -664,6 +674,7 @@ 16C33B501BEFBA2D00A0DBE0 /* Observable.swift in Sources */, 16C33B4F1BEFBA2D00A0DBE0 /* Stream+Operation.swift in Sources */, EC0DF29C1BF48FF400DFF3E6 /* MutableObservable.swift in Sources */, + EC7591EE1C08710A001F31B3 /* ArrayDiff.swift in Sources */, 16C33B5F1BEFBA2D00A0DBE0 /* NoError.swift in Sources */, 16C33B4E1BEFBA2D00A0DBE0 /* OperationSink.swift in Sources */, 16C33B5C1BEFBA2D00A0DBE0 /* Lock.swift in Sources */, @@ -701,6 +712,7 @@ 16C33B6C1BEFBA2E00A0DBE0 /* Observable.swift in Sources */, 16C33B6B1BEFBA2E00A0DBE0 /* Stream+Operation.swift in Sources */, EC0DF29D1BF48FF400DFF3E6 /* MutableObservable.swift in Sources */, + EC7591EF1C08710A001F31B3 /* ArrayDiff.swift in Sources */, 16C33B7B1BEFBA2E00A0DBE0 /* NoError.swift in Sources */, 16C33B6A1BEFBA2E00A0DBE0 /* OperationSink.swift in Sources */, 16C33B781BEFBA2E00A0DBE0 /* Lock.swift in Sources */, @@ -738,6 +750,7 @@ ECBCCE1F1BEB6BBE00723476 /* Queue.swift in Sources */, EC0DF29A1BF48FF400DFF3E6 /* MutableObservable.swift in Sources */, ECBCCE181BEB6BBE00723476 /* ObservableCollection+Dictionary.swift in Sources */, + EC7591EC1C08710A001F31B3 /* ArrayDiff.swift in Sources */, ECBCCE311BEB6BE100723476 /* Operation.swift in Sources */, ECBCCE331BEB6BE100723476 /* OperationSink.swift in Sources */, ECBCCE191BEB6BBE00723476 /* ObservableCollection+Set.swift in Sources */, @@ -768,6 +781,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + EC7591F11C0871EF001F31B3 /* ArrayDiffTests.swift in Sources */, EC2C7A461C0314BC006BFEE1 /* StreamSpec.swift in Sources */, EC2C7A481C0314BC006BFEE1 /* ObservableCollectionSpec.swift in Sources */, EC2C7A471C0314BC006BFEE1 /* OperationSpec.swift in Sources */, diff --git a/ReactiveKit/Info.plist b/ReactiveKit/Info.plist index 61718cd..81c339a 100644 --- a/ReactiveKit/Info.plist +++ b/ReactiveKit/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 1.0.6 + 1.0.7 CFBundleSignature ???? CFBundleVersion diff --git a/ReactiveKit/Internals/ArrayDiff.swift b/ReactiveKit/Internals/ArrayDiff.swift new file mode 100644 index 0000000..acc32f6 --- /dev/null +++ b/ReactiveKit/Internals/ArrayDiff.swift @@ -0,0 +1,83 @@ +// +// The MIT License (MIT) +// +// Copyright (c) 2015 Srdan Rasic (@srdanrasic) +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +// Created by Dapeng Gao on 20/10/15. +// The central idea of this algorithm is taken from https://github.com/jflinter/Dwifft + +internal enum DiffStep { + case Insert(element: T, index: Int) + case Delete(element: T, index: Int) +} + +extension Array where Element: Equatable { + + internal static func diff(x: [Element], _ y: [Element]) -> [DiffStep] { + + if x.count == 0 { + return zip(y, y.indices).map(DiffStep.Insert) + } + + if y.count == 0 { + return zip(x, x.indices).map(DiffStep.Delete) + } + + // Use dynamic programming to generate a table such that `table[i][j]` represents + // the length of the longest common substring (LCS) between `x[0..] = [] + for var i = xLen, j = yLen; i > 0 || j > 0; { + if i == 0 { + j-- + backtrack.append(.Insert(element: y[j], index: j)) + } else if j == 0 { + i-- + backtrack.append(.Delete(element: x[i], index: i)) + } else if table[i][j] == table[i][j - 1] { + j-- + backtrack.append(.Insert(element: y[j], index: j)) + } else if table[i][j] == table[i - 1][j] { + i-- + backtrack.append(.Delete(element: x[i], index: i)) + } else { + i-- + j-- + } + } + + // Reverse the result + return backtrack.reverse() + } +} diff --git a/ReactiveKit/Observable/Observable.swift b/ReactiveKit/Observable/Observable.swift index 1ffc28d..0c103c1 100644 --- a/ReactiveKit/Observable/Observable.swift +++ b/ReactiveKit/Observable/Observable.swift @@ -31,7 +31,7 @@ public final class Observable: ActiveStream, ObservableType { public var value: Value { didSet { - next(value) + super.next(value) } } @@ -40,6 +40,10 @@ public final class Observable: ActiveStream, ObservableType { super.init() } + public override func next(event: Value) { + self.value = event + } + public override func observe(on context: ExecutionContext? = ImmediateOnMainExecutionContext, observer: Observer) -> DisposableType { let disposable = super.observe(on: context, observer: observer) observer(value) diff --git a/ReactiveKit/ObservableCollection/ObservableCollection+Array.swift b/ReactiveKit/ObservableCollection/ObservableCollection+Array.swift index d3d6db4..dafbca2 100644 --- a/ReactiveKit/ObservableCollection/ObservableCollection+Array.swift +++ b/ReactiveKit/ObservableCollection/ObservableCollection+Array.swift @@ -23,44 +23,50 @@ // extension ObservableCollectionType where Collection == Array { - - public mutating func append(x: Collection.Generator.Element) { + + /// Append `newElement` to the array. + public mutating func append(newElement: Collection.Generator.Element) { var new = collection - new.append(x) + new.append(newElement) next(ObservableCollectionEvent(collection: new, inserts: [collection.count], deletes: [], updates: [])) } - + + /// Insert `newElement` at index `i`. public mutating func insert(newElement: Collection.Generator.Element, atIndex: Int) { var new = collection new.insert(newElement, atIndex: atIndex) next(ObservableCollectionEvent(collection: new, inserts: [atIndex], deletes: [], updates: [])) } - + + /// Insert elements `newElements` at index `i`. public mutating func insertContentsOf(newElements: [Collection.Generator.Element], at index: Collection.Index) { var new = collection new.insertContentsOf(newElements, at: index) next(ObservableCollectionEvent(collection: new, inserts: Array(index.. Collection.Generator.Element { var new = collection let element = new.removeAtIndex(index) next(ObservableCollectionEvent(collection: new, inserts: [], deletes: [index], updates: [])) return element } - + + /// Remove an element from the end of the array in O(1). public mutating func removeLast() -> Collection.Generator.Element { var new = collection let element = new.removeLast() next(ObservableCollectionEvent(collection: new, inserts: [], deletes: [new.count], updates: [])) return element } - + + /// Remove all elements from the array. public mutating func removeAll() { let deletes = Array(0.. Collection.Generator.Element { get { return collection[index] @@ -72,3 +78,39 @@ extension ObservableCollectionType where Collection == Array { } } } + +extension ObservableCollectionType where Collection == Array, Element: Equatable, Index == Int { + + /// Replace current array with the new array and send change events. + /// + /// - Parameters: + /// - newCollection: The array to replace current array with. + /// - performDiff: When `true`, difference between the current array and the new array will be calculated + /// and the sent event will contain exact description of which elements were inserted and which deleted.\n + /// When `false`, the sent event contains current array indices as `deletes` indices and new array indices as + /// `insertes` indices. + /// + /// - Complexity: O(1) if `performDiff == false`. Otherwise O(`collection.count * newCollection.count`). + public mutating func replace(newCollection: Collection, performDiff: Bool) { + if performDiff { + var inserts: [Int] = [] + var deletes: [Int] = [] + + inserts.reserveCapacity(collection.count) + deletes.reserveCapacity(collection.count) + + let diff = Collection.diff(collection, newCollection) + + for diffStep in diff { + switch diffStep { + case .Insert(_, let index): inserts.append(index) + case .Delete(_, let index): deletes.append(index) + } + } + + next(ObservableCollectionEvent(collection: newCollection, inserts: inserts, deletes: deletes, updates: [])) + } else { + replace(newCollection) + } + } +} diff --git a/ReactiveKitTests/ArrayDiffTests.swift b/ReactiveKitTests/ArrayDiffTests.swift new file mode 100644 index 0000000..d4bd06e --- /dev/null +++ b/ReactiveKitTests/ArrayDiffTests.swift @@ -0,0 +1,61 @@ +// +// DiffTests.swift +// Bond +// +// Created by Dapeng Gao on 20/10/15. +// Copyright © 2015 Bond. All rights reserved. +// + +// This test case is taken from https://github.com/jflinter/Dwifft + +import XCTest +@testable import ReactiveKit + +class DiffTests: XCTestCase { + + struct TestCase { + let array1: [Character] + let array2: [Character] + + let expectedDiff: String + + init(_ a: String, _ b: String, _ expectedDiff: String) { + self.array1 = Array(a.characters) + self.array2 = Array(b.characters) + self.expectedDiff = expectedDiff + } + } + + private func encodeDiff(x: [DiffStep]) -> String { + + var result = "" + + for step in x { + switch step { + case let .Insert(e, i): result += "+\(e)@\(i)" + case let .Delete(e, i): result += "-\(e)@\(i)" + } + } + return result + } + + func testDiff() { + + let tests: [TestCase] = [ + TestCase("1234", "23", "-1@0-4@3"), + TestCase("0125890", "4598310", "-0@0-1@1-2@2+4@0-8@4+8@3+3@4+1@5"), + TestCase("BANANA", "KATANA", "-B@0+K@0-N@2+T@2"), + TestCase("1234", "1224533324", "+2@2+4@3+5@4+3@6+3@7+2@8"), + TestCase("thisisatest", "testing123testing", "-h@1-i@2+e@1+t@3-s@5-a@6+n@5+g@6+1@7+2@8+3@9+i@14+n@15+g@16"), + TestCase("HUMAN", "CHIMPANZEE", "+C@0-U@1+I@2+P@4+Z@7+E@8+E@9"), + ] + + for test in tests { + + let diff = Array.diff(test.array1, test.array2) + let stringRepresentation = encodeDiff(diff) + + XCTAssertEqual(stringRepresentation, test.expectedDiff) + } + } +} diff --git a/ReactiveKitTests/ObservableCollectionSpec.swift b/ReactiveKitTests/ObservableCollectionSpec.swift index 247413c..cee29df 100644 --- a/ReactiveKitTests/ObservableCollectionSpec.swift +++ b/ReactiveKitTests/ObservableCollectionSpec.swift @@ -107,6 +107,16 @@ class ObservableCollectionSpec: QuickSpec { expect(observedEvents[1]).to(equal(ObservableCollectionEvent(collection: [1, 20, 3], inserts: [], deletes: [], updates: [1]))) } } + + describe("replace-diff") { + beforeEach { + observableCollection.replace([0, 1, 3, 4], performDiff: true) + } + + it("sends right events") { + expect(observedEvents[1]).to(equal(ObservableCollectionEvent(collection: [0, 1, 3, 4], inserts: [0, 3], deletes: [1], updates: []))) + } + } } }