Minimal edits from one collection to another

Overview

Changeset

Changeset – pretty awesome little project
Joel Levin

This is an attempt at implementing the solution outlined in Dave DeLong’s article, Edit distance and edit steps.

A Changeset describes the minimal edits required to go from one Collection of Equatable elements to another.

It has been written primarily to be used in conjunction with UITableView and UICollectionView data sources by detecting additions, deletions, substitutions, and moves between the two sets of data. But it can also be used to compute more general changes between two data sets.

Usage

The following code computes the minimal edits of the canonical example, going from the String collections “kitten” to “sitting”:

let changeset = Changeset(source: "kitten", target: "sitting")

print(changeset)
// 'kitten' -> 'sitting':
//     replace with s at offset 0
//     replace with i at offset 4
//     insert g at offset 6

The following assertion would then succeed:

let edits = [
    Changeset<String>.Edit(operation: .substitution, value: "s", destination: 0),
    Changeset<String>.Edit(operation: .substitution, value: "i", destination: 4),
    Changeset<String>.Edit(operation: .insertion, value: "g", destination: 6),
]
assert(changeset.edits == edits)

If you don’t want the overhead of Changeset itself, which also stores the source and target collections, you can call edits directly (here with example data from Apple’s Table View Programming Guide for iOS):

let source = ["Arizona", "California", "Delaware", "New Jersey", "Washington"]
let target = ["Alaska", "Arizona", "California", "Georgia", "New Jersey", "Virginia"]
let edits = Changeset.edits(from: source, to: target)

print(edits)
// [insert Alaska at offset 0, replace with Georgia at offset 2, replace with Virginia at offset 4]

Note that Changeset uses offsets, not indices, to refer to elements in the collections. This is mainly because Swift collections aren’t guaranteed to use zero-based integer indices. See discussion in issue #37 for more details.

UIKit Integration

The offset values can be used directly in the animation blocks of beginUpdates/endUpdates on UITableView and performBatchUpdates on UICollectionView in that Changeset follows the principles explained under Batch Insertion, Deletion, and Reloading of Rows and Sections in Apple’s guide.

In short; first all deletions and substitutions are made, relative to the source collection, then, relative to the resulting collection, insertions. A move is just a deletion followed by an insertion.

In the iOS framework, two convenience extensions (one on UITableView and one on UICollectionView) have been included to make animated table/collection view updates a breeze. Just call update, like this:

tableView.update(with: changeset.edits)

Custom Comparator

By default a Changeset uses == to compare elements, but you can write your own comparator, illustrated below, where the occurence of an “a” always triggers a change:

let alwaysChangeA: (Character, Character) -> Bool = {
    if $0 == "a" || $1 == "a" {
        return false
    } else {
        return $0 == $1
    }
}
let changeset = Changeset(source: "ab", target: "ab", comparator: alwaysChangeA)

As a result, the changeset will consist of a substitution of the “a” (to another “a”):

let expectedEdits: [Changeset<String>.Edit] = [Changeset.Edit(operation: .substitution, value: "a", destination: 0)]
assert(changeset.edits == expectedEdits)

One possible use of this is when a cell in a UITableView or UICollectionView shouldn’t animate when they change.

Test App

The Xcode project also contains a target to illustrate the usage in an app:

Test App

This uses the extensions mentioned above to animate transitions based on the edits of a Changeset.

License

This project is available under The MIT License.
Copyright © 2015-18, Joachim Bondo. See LICENSE file.

Comments
  • Changeset produces bad edit steps, crashes UICollectionView.

    Changeset produces bad edit steps, crashes UICollectionView.

    I have a couple of examples of edit steps that are incorrect.

    '64927513' -> '917546832': delete 4 at index 1 replace with 1 at index 1 replace with 4 at index 4 move 6 from index 0 to 5 insert 8 at index 6 insert 2 at index 8 UICollectionView error: attempt to delete and reload the same index path (path = 0 - 1)

    '8C9A2574361B' -> '897A34B215C6': replace with 3 at index 4 delete 5 at index 5 move 7 from index 6 to 2 replace with B at index 6 replace with 2 at index 7 replace with 5 at index 9 move C from index 1 to 10 insert 6 at index 11 UICollectionView error: attempt to perform a delete and a move from the same index path (path = 0 - 6)

    opened by bwhiteley 12
  • Add support for building the framework and test targets universally across all supported SDKs

    Add support for building the framework and test targets universally across all supported SDKs

    I'd love to use this with Carthage on my macOS projects, but the project was configured to provide iOS only (this is an Xcode limitation, I know). I've used the xcconfig approach in this PR on a few projects now, and it works quite well, but I'd understand any reluctance to merge it in.

    The main downside is that the iOS Test App now thinks it can build on macOS and presents that option in the Xcode scheme switcher. Both the framework and tests can build across everything presented.

    opened by tonyarnold 7
  • Add test app to demonstrate Changeset with collection view.

    Add test app to demonstrate Changeset with collection view.

    Added an iPhone app target to explore using Changeset while updating UICollectionView.

    A naive implementation is provided for comparison.

    Some of the problems identified in issue #12 are demonstrated. This example app will aid in resolving those problems, and will thereafter serve as a good example for people interested in using Changeset.

    opened by bwhiteley 6
  • Add optional `batchUpdatesDidBegin` block to UIKit extensions

    Add optional `batchUpdatesDidBegin` block to UIKit extensions

    This PR adds a hook to the UIKit extensions on batch updates did begin. This permits users to update their data model inside the performBatchUpdates block – a measure which can prevent crashes with dynamic and/or inconsistent collection layouts.

    opened by ahtierney 5
  • Make Edit a type of a Collection

    Make Edit a type of a Collection

    This set of changes is about making Edit a type of a collection instead of a collection’s equatable elements plus the resulting changed requirements, including tests and test app. Merges into the version 3 branch.

    An Edit needs to have a concept of the collection type so we can ensure we can do arithmetic on the collection’s index distance type, see discussion on #39.

    I wasn’t able to declare …where C.IndexDistance: SignedInteger on Edit, Changeset while doing enumerations and additions/subtractions on offsets in a compatible way. At least I couldn’t figure out how to. So for now, we require Int index distances on the collections.

    Note: I prefer to ensure all commits are compilable (git-bisect), hence all files in one commit.

    FYI: I’m planning a general cleanup for version 3 (including using the C shorthand for Collection types, probably splitting up Edit and Changeset in separate files).

    opened by osteslag 5
  • Suggestion in consideration of UIKit behaviour

    Suggestion in consideration of UIKit behaviour

    I have some thoughts and suggestions, but I'm not sure about if we need them resolved. Why I want to raise the issue is because maybe someone out there might be interested. They are mostly concerning substitution/reload and how UIKit and its documentation seems to treat it inconsistently.

    Expose edit's origin (source) index, at least for substitution.

    My translation from changeset's edit operations to UITableView's row operations is as follow:

    | Edit | Row | | --- | --- | | Insert | Insert | | Delete | Delete | | Move | Move | | Substitution | Reload |

    When executing row operations between beginUpdates and endUpdates, like delete, reload index is expected to be the ones prior to any updates. As stated in its API documentation:

    The indexes that UITableView passes to the method are specified in the state of the table view prior to any updates.

    Changeset provides the index after update instead. Making it unavailable to be mixed with other operations within beginUpdates and endUpdates.

    Makes reducing edits to move optional.

    As of 1.0.x, in order to use it with UITableView's row operation, I have to do something like this.

            var deletedIndexPaths = [NSIndexPath]()
            var insertedIndexPaths = [NSIndexPath]()
            var movedIndexPaths = [(from: NSIndexPath, to: NSIndexPath)]()
            var reloadIndexPaths = [NSIndexPath]()
    
            for edit in changeset.edits {
                switch edit.operation {
                case .Insertion:
                    let insertedIndexPath = NSIndexPath(forRow: edit.destination, inSection: 0)
                    insertedIndexPaths.append(insertedIndexPath)
                case .Deletion:
                    let deletedIndexPath = NSIndexPath(forRow: edit.destination, inSection: 0)
                    deletedIndexPaths.append(deletedIndexPath)
                case .Substitution:
                    let reloadIndexPath = NSIndexPath(forRow: edit.destination, inSection: 0)
                    reloadIndexPaths.append(reloadIndexPath)
                case .Move(origin: let origin):
                    let fromIndexPath = NSIndexPath(forRow: origin, inSection: 0)
                    let toIndexPath = NSIndexPath(forRow: edit.destination, inSection: 0)
                    movedIndexPaths.append((from: fromIndexPath, to: toIndexPath))
                }
            }
    
            self.tableView.beginUpdates()
    
            if deletedIndexPaths.count > 0 {
                self.tableView.deleteRowsAtIndexPaths(deletedIndexPaths, withRowAnimation: .Automatic)
            }
    
            if insertedIndexPaths.count > 0 {
                self.tableView.insertRowsAtIndexPaths(insertedIndexPaths, withRowAnimation: .Automatic)
            }
    
            for (from, to) in movedIndexPaths {
                self.tableView.moveRowAtIndexPath(from, toIndexPath: to)
            }
    
            self.tableView.endUpdates()
    
            if reloadIndexPaths.count > 0 {
                self.tableView.reloadRowsAtIndexPaths(reloadIndexPaths, withRowAnimation: .Automatic)
            }
    

    Reload is outside the beginUpdates and endUpdates for reason stated previous. But if I exposes the origin index for substitution edit operation, theoretically, I probably can wrap all operations in beginUpdates and endUpdates.

    But this is not the case. It seems a little counter intuitive but the exception thrown by UITableView is as follows:

    2016-01-23 22:58:35.504 ChangesetTest[3247:111068] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'attempt to perform an insert and a move to the same index path (<NSIndexPath: 0xc000000003800016> {length = 2, path = 0 - 28})'

    It stated that we are trying to attempt an insert and a move row operation but those code have not change. Funny thing is that if I commented out the reload row operation code (which is possible here as their value will just remain outdated), it will no longer complain.

    I believe this is more of a wording bug, UIKit probably meant insert and reload of the same index. But this I can't be sure.

    The funny things is that the API documentation for move only mention that it can work with insert and delete, but avoid mentioning reload.

    As for UICollectionView, its API documentation for batch update only mention that it can work with insert, delete and move but did not mention about reload. Which will make the current solution for UITableView seems correct.

    If I remove the reduce edit step. I can make it work nicely with reload in table view.

    But the thing about reload is that it is the least obvious row operation visually. And there are several other options that are reasonable as well. For example, we can get visible rows and update the cell manually (no animation), or just not batching it like above (not an issue as well).

    opened by lxcid 5
  • Trees

    Trees

    Hey, I just read the readme. I like this a lot. Do you think this could be applied to trees as well? Imagine I have a tree of lightweight structs. Can I compare two trees and find the minimum changes required going from one to the other?

    opened by hfossli 4
  • Indices are (wrongly?) assumed to be integers and zero-based

    Indices are (wrongly?) assumed to be integers and zero-based

    I have a comment regarding terminology.

    The indices the library computes for the Edit steps are currently always integers and always zero-based (i.e. the first index is 0). This works great for the intended main use case (table and collection view updates) because the index paths of a table or collection view are also zero-based and composed of integers.

    However, it doesn't reflect the reality of Swift's collection types. For example, ArraySlice uses integer indices, but they aren't zero-based. If you compute the changeset for two ArraySlice instances, the result is arguably wrong or at least confusing:

    let source = [2,3,4,5].dropFirst() // ArraySlice<Int>
    let target = [3,4,5,6].dropFirst() // ArraySlice<Int>
    let edits = Changeset.edits(from: source, to: target)
    print(edits)
    // Prints "[delete 3 at index 0, insert 6 at index 2]"
    

    If you tried to apply this changeset literally to the source collection (i.e. delete the element at index 0 from source), youʼd get a crash because source doesnʼt have an "index" 0.

    In a way, a similar issue is when you compute changesets for strings (as most of the unit tests do) because string indices are not integers, so saying something like "insert "a" at index 5" doesn't strictly make sense in Swift.

    I find this a little confusing and misleading. Changeset appears to be generic for any collection, and yet it doesn't follow Swift's conventions where the term "index" has a very specific meaning.

    When I first noticed this "problem" I wanted to suggest replacing the integer indices in Edit and EditOperation with the collection's index type (i.e. something like T.Index). On second thought, this doesn't make sense because a changeset would have to compute "virtual" indices for a collection that doesn't really exist, and since collections are free to invalidate any existing index upon mutation, I don't think you could compute the indices in a generic way. Moreover, using real collection indices would make supporting table/collection view updates harder, not easier.

    After writing all this, I realize that the way this works is actually the best solution for the given problem. Having written this, I'm going to submit it too, even though I don't have a specific suggestion. Feel free to close this issue, and maybe it will help others understand this better when they find it in search.

    My only suggestion would be to make it clear that Changeset and Edit aren't concerned with indices, but with offsets. I think if the debugDescription read "delete x at offset 0" etc. I would have been less confused. This is exemplified by the use of enumerated() to compute the column value in edits(from:to:), since enumerated() computes offsets from 0 and not indices (see http://khanlou.com/2017/03/you-probably-don't-want-enumerated/ for more info on enumerated() and how it's often misinterpreted).

    opened by ole 3
  • Add ability to use a custom comparator instead a default equals - '=='

    Add ability to use a custom comparator instead a default equals - '=='

    It's not enough to use a default values equality for my project and I extended the code with ability to init a Changeset with a custom comparator. If you will find this change helpful, please, consider it to merge to your project.

    opened by pomozoff 3
  • Should deletes be inserted in descending order?

    Should deletes be inserted in descending order?

    I'm using Changeset v2.0, and I've run into an issue where I'm applying a batch delete which produces something akin to:

    [0] { operation = .delete, value = …, destination = 0 }
    [1] { operation = .delete, value = …, destination = 1 }
    [2] { operation = .delete, value = …, destination = 2 }
    [3] { operation = .delete, value = …, destination = 3 }
    

    If I process this in order by applying the deletes to a table, this will (pretty obviously) crash when it tries to remove destination 3 (which in the target collection, no longer exists).

    Is this something that Changeset should be handling for me? I don't recall this being an issue with v1.

    opened by tonyarnold 3
  • Add unreducedEdits property

    Add unreducedEdits property

    • unreduced edits are edits without converting insertion/deletion pairs into .Move edits
    • unreduced edits is also good for manual changes in a UITableView or UICollectionView, since UITableView's only allow insertion, deletion, and update on rows.
    • converted tabs into spaces
    opened by chrisamanse 3
  • Fix Xcode 12 issue about iOS 8 being unsupported.

    Fix Xcode 12 issue about iOS 8 being unsupported.

    Currently with Xcode 12 using Changeset produces warning:

    The iOS Simulator deployment target 'IPHONEOS_DEPLOYMENT_TARGET' is set to 8.0, but the range of supported deployment target versions is 9.0 to 14.0.99.
    

    This PR changes minimum iOS deployment target for CocoaPods to 9.0, and removed platforms from Package.swift.

    Please note, that platforms inside Package.swift do not specify supported platforms, but rather minimum versions of supported platforms, which is only needed if package minimum deployment target is higher than SPM supported deployment targets.

    opened by DenTelezhkin 0
  • Carthage support?

    Carthage support?

    Are there any plans for supporting Carthage? I don't really want to add Pods to my project, and the Swift Package Manager option is currently failing with:

      ~ swift package generate-xcodeproj
    Fetching https://github.com/osteslag/Changeset.git
    Completed resolution in 4.39s
    Cloning https://github.com/osteslag/Changeset.git
    Resolving https://github.com/osteslag/Changeset.git at 3.1.1
    warning: PackageDescription API v3 is deprecated and will be removed in the future; used by package(s): Changeset
    'Changeset' /Users/pcferreira/Projects/OnboardingReactiveSwift/.build/checkouts/Changeset.git--4643115965670364357: error: package has unsupported layout; found loose source files: /Users/pcferreira/Projects/OnboardingReactiveSwift/.build/checkouts/Changeset.git--4643115965670364357/Tests/ChangesetTests.swift
    'OnboardingReactiveSwift' /Users/pcferreira/Projects/OnboardingReactiveSwift: error: product dependency 'Changeset' not found
    
    opened by puelocesar 1
  • How to use Changeset in protocols?

    How to use Changeset in protocols?

    I can't figure out how to make the compiler happy when trying to express something like this:

    public protocol ChangesetApplying: class {
        associatedtype T
        func apply(changeset: Changeset<T>)
    }
    

    The above will complain that Type 'Self.T' does not conform to protocol 'Collection'.

    Changing it to associatedtype T: Collection yields a different error: Type 'Self.T.Iterator.Element' does not conform to protocol 'Equatable'.

    Changing it to associatedtype T: Collection where T.Iterator.Element: Equatable, T.IndexDistance == Int yields the error 'where' clause cannot be attached to an associated type declaration, which appears to be the subject of SE-0142.

    Any ideas?

    opened by pepasflo 2
Releases(v3.2.0)
Owner
Joachim Bondo
Long-time developer (Swift, Objective-C, C, PHP, Pascal) on iOS, iPadOS, macOS, OS X, MacOS, FreeBSD.
Joachim Bondo
💻 A fast and flexible O(n) difference algorithm framework for Swift collection.

A fast and flexible O(n) difference algorithm framework for Swift collection. The algorithm is optimized based on the Paul Heckel's algorithm. Made wi

Ryo Aoyama 3.3k Jan 4, 2023
TwilioChat_iOS - Twilio iOS SDK Implementaion Chat one-one Chat One-Many (Group)

TwilioChat_iOS - Twilio iOS SDK Implementaion Chat one-one Chat One-Many (Group) - Add Participant - Remove Participant Send Attachment Image Android - iOS Tested iOS - iOS Tested iOS - Android Tested React to Message, Delete a Message Read, Delivered, Sent Delete a Conversation Unread Messages Filter

Zeeshan Haider 2 May 23, 2022
An open source iOS app that lets you use one device as a camera and another as a remote control for the camera

Q: What is Open Source Selfie Stick? A: With this free app you can use any iPhone or iPad as a remote control for the camera on any other iPhone or iP

Richard Nelson 43 Jan 5, 2023
A view that takes a set of images, make transition from one to another by using flipping effects.

CDFlipView A view that takes a set of images, make transition from one to another by using flipping effects. Demo Live Demo: https://appetize.io/app/w

Jianbin LIN 99 Aug 27, 2021
A simple shake-one-shake, Convenient for us to integrate the shake-one-shake.

MGSwiftShaker Example To run the example project, clone the repo, and run pod install from the Example directory first.

null 3 Nov 21, 2021
This little app aims to help teach me how to implement more then one API in one single application in a reusable and properly structured manner.

LilAPI App News & Weather This little API app combines two of Jordan Singers Lil Software API's into one app. The goal with this app was to learn how

hallux 1 Oct 13, 2021
Yet another extension to manipulate colors easily in Swift and SwiftUI

DynamicColor provides powerful methods to manipulate colors in an easy way in Swift and SwiftUI. Requirements • Usage • Installation • Contribution •

Yannick Loriot 2.8k Dec 30, 2022
Another network wrapper for URLSession. Built to be simple, small and easy to create tests at the network layer of your application.

Another network wrapper for URLSession. Built to be simple, small and easy to create tests at the network layer of your application. Install Carthage

Ronan Rodrigo Nunes 89 Dec 26, 2022
KeyClip is yet another Keychain library written in Swift.

KeyClip KeyClip is yet another Keychain library written in Swift. Features Multi Types ( String / NSDictionary / NSData ) Error Handling Settings ( kS

Shinichiro Aska 43 Nov 6, 2022
Just another yet FlappyBird-style game.

ScaryFlight Just another yet FlappyBird-style game using SpriteKit. ..... Important Note Guys, please respect the work of each other and please do not

Evgeny Karkan 25 Dec 29, 2022
Just another NES emulator written in Swift

SwiftNes This repo contains all the source code for my experimental 100 days of NES challenge. What is this all about? I'm planning to build a fully w

Tibor Bödecs 44 Dec 24, 2021
PopupWindow is a simple Popup using another UIWindow in Swift

PopupWindow PopupWindow is a simple Popup using another UIWindow Feature PopupWindow can be displayed at the top or bottom of the screen. Popup can se

shinji hayashi 415 Dec 5, 2022
Yet Another Swift Auto Layout DSL

FormationLayout Documentation FormationLayout is the top level layout class for one root view. FormationLayout takes a UIView as its rootView. transla

Evan Liu 53 Mar 31, 2022
Weather forecast app that allows the user to either look for weather at their current location based on the GPS data or search for another city manually.

⛅️ Cloudy Weather forecast app that allows the user to either look for weather at their current location based on the GPS data or search for another c

Burhan 0 Nov 7, 2021
DrawerKit lets an UIViewController modally present another UIViewController in a manner similar to the way Apple's Maps app works.

DrawerKit What is DrawerKit? DrawerKit is a custom view controller presentation mimicking the kind of behaviour in the Apple Maps app. It lets any vie

Babylon Health 773 Dec 27, 2022
Another Virtualization.framework demo project, with focus to iBoot (WIP)

Virtual iBoot Fun This is just another Virtualization.framework sample project (WIP), but with focus on iBoot (iOS/macOS/tvOS/etc. bootloader) For a m

john 119 Dec 7, 2022
Yet another task indicator

SendIndicator Yet another task indicator Requirements Installation Usage License Requirements iOS 8.0+ Swift 3.0+ Xcode 8.0+ Installation CocoaPods Co

Leonardo Cardoso 64 Aug 17, 2022
Yet another animated flat buttons in Swift

DynamicButton is a powerful flat design button written in Swift to display hamburger button like with animated transitions between style updates. It a

Yannick Loriot 1.2k Jan 5, 2023
Another UITabBar & UITabBarController (iOS Tab Bar) replacement, but uses Auto Layout for arranging it's views hierarchy.

GGTabBar GGTabBar is a simple UITabBar & UITabBarController replacement that uses Auto Layout for constructing the GUI. I created it for curiosity, bu

Nicolas Goles 157 Sep 26, 2022
Macho is yet another mach-o file viewer

Mocha is yet another mach-o file viewer. Requirement: Xcode: 13.*, macOS 13.* It's written in SwiftUI, so it requires the latest version of Xcode and

null 128 Dec 28, 2022