diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 7f8a046..7b78194 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 293D26F1270870A000333635 /* GroupOutlineViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 293D26F0270870A000333635 /* GroupOutlineViewItem.swift */; }; DD61CF5624717B1D00CA0BE9 /* DiffableDataSourceSnapshot+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD61CF5524717B1D00CA0BE9 /* DiffableDataSourceSnapshot+Additions.swift */; }; DD61CF582471882A00CA0BE9 /* MasterOutlineViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD61CF572471882A00CA0BE9 /* MasterOutlineViewItem.swift */; }; DDCAB953246475AB00E5AA41 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCAB952246475AB00E5AA41 /* MainViewController.swift */; }; @@ -22,6 +23,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 293D26F0270870A000333635 /* GroupOutlineViewItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupOutlineViewItem.swift; sourceTree = ""; }; DD61CF5524717B1D00CA0BE9 /* DiffableDataSourceSnapshot+Additions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiffableDataSourceSnapshot+Additions.swift"; sourceTree = ""; }; DD61CF572471882A00CA0BE9 /* MasterOutlineViewItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MasterOutlineViewItem.swift; sourceTree = ""; }; DDCAB952246475AB00E5AA41 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; @@ -50,6 +52,27 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 293D26EE2706F38B00333635 /* Left Panel */ = { + isa = PBXGroup; + children = ( + DDCAB9542464771600E5AA41 /* MasterViewController.swift */, + DD61CF572471882A00CA0BE9 /* MasterOutlineViewItem.swift */, + 293D26F0270870A000333635 /* GroupOutlineViewItem.swift */, + ); + name = "Left Panel"; + sourceTree = ""; + }; + 293D26EF2706F39A00333635 /* Right Panel */ = { + isa = PBXGroup; + children = ( + DDCAB9562464785700E5AA41 /* DetailViewController.swift */, + DDCAB9582464796400E5AA41 /* EmptyViewController.swift */, + DDCAB95A24647B7200E5AA41 /* SingleViewController.swift */, + DDCAB95C24647BBF00E5AA41 /* MultiViewController.swift */, + ); + name = "Right Panel"; + sourceTree = ""; + }; DDCB690F245DDAFD00489647 = { isa = PBXGroup; children = ( @@ -73,16 +96,12 @@ DDCB691B245DDAFD00489647 /* AppDelegate.swift */, DDCB691F245DDB0000489647 /* Assets.xcassets */, DDCB6925245DDB0000489647 /* Demo.entitlements */, - DDCAB9562464785700E5AA41 /* DetailViewController.swift */, + DDCAB952246475AB00E5AA41 /* MainViewController.swift */, + 293D26EE2706F38B00333635 /* Left Panel */, + 293D26EF2706F39A00333635 /* Right Panel */, DD61CF5524717B1D00CA0BE9 /* DiffableDataSourceSnapshot+Additions.swift */, - DDCAB9582464796400E5AA41 /* EmptyViewController.swift */, DDCB6924245DDB0000489647 /* Info.plist */, DDCB6921245DDB0000489647 /* Main.storyboard */, - DDCAB952246475AB00E5AA41 /* MainViewController.swift */, - DD61CF572471882A00CA0BE9 /* MasterOutlineViewItem.swift */, - DDCAB9542464771600E5AA41 /* MasterViewController.swift */, - DDCAB95C24647BBF00E5AA41 /* MultiViewController.swift */, - DDCAB95A24647B7200E5AA41 /* SingleViewController.swift */, ); path = Demo; sourceTree = ""; @@ -175,6 +194,7 @@ DDCAB95B24647B7200E5AA41 /* SingleViewController.swift in Sources */, DD61CF5624717B1D00CA0BE9 /* DiffableDataSourceSnapshot+Additions.swift in Sources */, DDCAB95D24647BBF00E5AA41 /* MultiViewController.swift in Sources */, + 293D26F1270870A000333635 /* GroupOutlineViewItem.swift in Sources */, DDCB691C245DDAFD00489647 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Demo/Demo/DetailViewController.swift b/Demo/Demo/DetailViewController.swift index 7579291..06472f8 100644 --- a/Demo/Demo/DetailViewController.swift +++ b/Demo/Demo/DetailViewController.swift @@ -20,7 +20,6 @@ final class DetailViewController: NSViewController { let viewController = NSTabViewController() viewController.tabStyle = .unspecified viewController.transitionOptions = [] - viewController.view.wantsLayer = false viewController.tabViewItems = [emptyViewController, singleViewController, multiViewController] .map(NSTabViewItem.init(viewController:)) return viewController diff --git a/Demo/Demo/DiffableDataSourceSnapshot+Additions.swift b/Demo/Demo/DiffableDataSourceSnapshot+Additions.swift index 3132aa3..f9d1071 100644 --- a/Demo/Demo/DiffableDataSourceSnapshot+Additions.swift +++ b/Demo/Demo/DiffableDataSourceSnapshot+Additions.swift @@ -2,47 +2,62 @@ import Foundation import OutlineViewDiffableDataSource extension DiffableDataSourceSnapshot { - + /// Appends items from the text view. mutating func fillItem(_ selectedItem: Item?, with string: String) { let lines = string.components(separatedBy: .newlines) + for line in lines { + // Each line should have a "parent (/ child)?". Since this is a free-form text field, we need to find + // existing items in the side bar let titles = line.components(separatedBy: "/").map { $0.trimmingCharacters(in: .whitespaces) }.filter { $0.isEmpty == false } + + guard !titles.isEmpty else { continue } + + // Use title as the unique ID in this case + let rootItem = getItemForID(titles[0]) ?? MasterGroupOutlineViewItem(id: titles[0], title: titles[0]) + let sideBarItem = getItemForID(titles[0]) ?? MasterOutlineViewItem(id: titles[0], title: titles[0]) + + let foundRootItem = containsItem(rootItem) + let foundSidebarItem = containsItem(sideBarItem) + switch titles.count { - case 1: - let groupItem = GroupOutlineViewItem(id: titles[0], title: titles[0]) - let masterItem = MasterOutlineViewItem(title: titles[0]) - if containsItem(groupItem) == false, containsItem(masterItem) == false { - if selectedItem == nil { - appendItems([groupItem]) - } else { - appendItems([masterItem], into: selectedItem) + case 1: + // No child specified + if foundRootItem == false, foundSidebarItem == false { + if let selectedItem = selectedItem { + appendItems([sideBarItem], into: selectedItem) + } + else { + appendItems([rootItem], into: nil) + } } - } - case 2: - let parentGroupItem = GroupOutlineViewItem(id: titles[0], title: titles[0]) - let parentMasterItem = MasterOutlineViewItem(title: titles[0]) - var parentItem: NSObject? - if containsItem(parentGroupItem) == false, containsItem(parentMasterItem) == false { - if selectedItem == nil { - appendItems([parentGroupItem]) - parentItem = parentGroupItem - - } else { - appendItems([parentMasterItem], into: selectedItem) - parentItem = parentMasterItem + case 2: + let childItemToAdd = MasterOutlineViewItem(id: titles[1], title: titles[1]) + + if containsItem(childItemToAdd) == false { + // Parent / Child specified. Find + var parentItemToUse: OutlineViewItem? + + if foundRootItem == false, foundSidebarItem == false { + if selectedItem == nil { + appendItems([rootItem]) + parentItemToUse = rootItem + + } else { + appendItems([sideBarItem], into: selectedItem) + parentItemToUse = sideBarItem + } + } else if foundRootItem { + parentItemToUse = rootItem + } else if foundSidebarItem { + parentItemToUse = sideBarItem + } + + appendItems([childItemToAdd], into: parentItemToUse) } - } else if containsItem(parentGroupItem) { - parentItem = parentGroupItem - } else if containsItem(parentMasterItem) { - parentItem = parentMasterItem - } - let childItem = MasterOutlineViewItem(title: titles[1]) - if containsItem(childItem) == false { - appendItems([childItem], into: parentItem) - } - default: - continue + default: + continue } } } diff --git a/Demo/Demo/EmptyViewController.swift b/Demo/Demo/EmptyViewController.swift index c1ac5d2..e155d9a 100644 --- a/Demo/Demo/EmptyViewController.swift +++ b/Demo/Demo/EmptyViewController.swift @@ -4,7 +4,6 @@ import OutlineViewDiffableDataSource /// The number of controls for an empty outline view selection. final class EmptyViewController: NSViewController { - /// Multiline text editor for the outline contents. private lazy var scrollableEditor: NSScrollView = { let scrollView = NSTextView.scrollablePlainDocumentContentTextView() @@ -56,17 +55,25 @@ extension EmptyViewController { guard let textView = scrollableEditor.documentView as? NSTextView, textView.string.isEmpty else { return } textView.string = """ - Parent 1 / Child 11 - Parent 1 / Child 12 - Parent 1 / Child 13 - Parent 2 - Parent 2 / Child 21 - Child 21 / Leaf 211 - Child 21 / Leaf 212 - Parent 3 / Child 31 - Parent 3 / Child 32 - Parent 3 / Child 33 + Cars / Toyota + Cars / Honda + Cars / Tesla + Phones + Phones / Samsung + Samsung / Samsung Note + Samsung / Samsung Nexus + Samsung Nexus / Nexus 5 + OS / macOS + OS / Windows + OS / Linux """ + +// Parent 3 +// Parent 3 / Child 31 +// Child 31 / Child 33 +// Child 33 / Child 32 + + fillSidebar(nil) } } @@ -77,9 +84,11 @@ private extension EmptyViewController { /// Replaces the whole tree with the given contents. @IBAction func fillSidebar(_ sender: Any?) { guard let textView = scrollableEditor.documentView as? NSTextView else { return } + + // Create a new snapshot from entered Parent / Child items. var snapshot: DiffableDataSourceSnapshot = .init() snapshot.fillItem(nil, with: textView.string) - snapshotBinding.wrappedValue = snapshot + snapshotBinding.wrappedValue = snapshot } /// Replaces text with sidebar contents. @@ -89,8 +98,8 @@ private extension EmptyViewController { var items: [String] = [] snapshot.enumerateItems { item, parentItem in items.append([ - (parentItem as? GroupOutlineViewItem)?.title ?? (parentItem as? MasterOutlineViewItem)?.title, - (item as? GroupOutlineViewItem)?.title ?? (item as? MasterOutlineViewItem)?.title, + (parentItem as? MasterGroupOutlineViewItem)?.title ?? (parentItem as? MasterOutlineViewItem)?.title, + (item as? MasterGroupOutlineViewItem)?.title ?? (item as? MasterOutlineViewItem)?.title, ].compactMap { $0 }.joined(separator: " / ")) } textView.string = items.joined(separator: "\n") diff --git a/Sources/OutlineViewItem+Group.swift b/Demo/Demo/GroupOutlineViewItem.swift similarity index 77% rename from Sources/OutlineViewItem+Group.swift rename to Demo/Demo/GroupOutlineViewItem.swift index 9c19215..168646a 100644 --- a/Sources/OutlineViewItem+Group.swift +++ b/Demo/Demo/GroupOutlineViewItem.swift @@ -1,37 +1,22 @@ import AppKit +import OutlineViewDiffableDataSource /// Default root item with buttons ‘Show’ and ‘Hide’, not intended for subclassing. -public final class GroupOutlineViewItem: NSObject, OutlineViewItem { - - /// Unique identifier for diffing. - public let id: String - +public final class MasterGroupOutlineViewItem: GroupOutlineViewItem { /// Display string. public let title: String - /// Show as Group. - public let isGroup: Bool = true - - /// Deny selection. - public let isSelectable: Bool = false - /// Creates a “standard” root item for the sidebar. public init(id: String, title: String) { - self.id = id self.title = title + super.init(id: id) } /// Returns an appropriate cell view type. - public func cellViewType(for tableColumn: NSTableColumn?) -> NSTableCellView.Type { GroupTableCellView.self } + public override func cellViewType(for tableColumn: NSTableColumn?) -> NSTableCellView.Type { GroupTableCellView.self } /// Necessary for sets. public override var hash: Int { title.hash } - - /// Necessary for outline view reloading. - public override func isEqual(_ object: Any?) -> Bool { - guard let groupItem = object as? GroupOutlineViewItem else { return false } - return groupItem.id == id - } } // MARK: - Private API @@ -86,7 +71,7 @@ private final class GroupTableCellView: NSTableCellView { /// Retrieves new title from the associated group item. override var objectValue: Any? { didSet { - if let label = textField, let groupItem = objectValue as? GroupOutlineViewItem { + if let label = textField, let groupItem = objectValue as? MasterGroupOutlineViewItem { label.stringValue = groupItem.title } } diff --git a/Demo/Demo/MasterOutlineViewItem.swift b/Demo/Demo/MasterOutlineViewItem.swift index d06f281..212ed07 100644 --- a/Demo/Demo/MasterOutlineViewItem.swift +++ b/Demo/Demo/MasterOutlineViewItem.swift @@ -2,25 +2,21 @@ import AppKit import OutlineViewDiffableDataSource /// Sidebar iitems. -class MasterOutlineViewItem: NSObject, OutlineViewItem { - +class MasterOutlineViewItem: OutlineViewItem { /// Visible string. let title: String /// Creates a new item ready for insertion into the sidebar. - init(title: String) { self.title = title } - + init(id: String, title: String) { + self.title = title + super.init(id: id) + } + /// Returns a private cell view type. - func cellViewType(for tableColumn: NSTableColumn?) -> NSTableCellView.Type { MasterCellView.self } + override func cellViewType(for tableColumn: NSTableColumn?) -> NSTableCellView.Type { MasterCellView.self } - /// Necessary for sets. + /// Necessary for supporting drag-n-drop and expand-collapse. override var hash: Int { title.hash } - - /// Necessary for outline view reloading. - override func isEqual(_ object: Any?) -> Bool { - guard let masterItem = object as? MasterOutlineViewItem else { return false } - return masterItem.title == title - } } // MARK: - Private API diff --git a/Demo/Demo/MasterViewController.swift b/Demo/Demo/MasterViewController.swift index f1b98c8..3c05a5d 100644 --- a/Demo/Demo/MasterViewController.swift +++ b/Demo/Demo/MasterViewController.swift @@ -5,14 +5,14 @@ import OutlineViewDiffableDataSource /// Sidebar contents. final class MasterViewController: NSViewController { - + /// An outline view enclosed into the scroll view. private lazy var scrollableOutlineView: (scrollView: NSScrollView, outlineView: NSOutlineView) = { - + let outlineColumn = NSTableColumn() outlineColumn.resizingMask = .autoresizingMask outlineColumn.isEditable = false - + let outlineView = NSOutlineView() outlineView.headerView = nil outlineView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle @@ -24,7 +24,7 @@ final class MasterViewController: NSViewController { outlineView.usesAutomaticRowHeights = true outlineView.selectionHighlightStyle = .sourceList outlineView.floatsGroupRows = false - + let scrollView = NSScrollView() scrollView.documentView = outlineView scrollView.hasVerticalScroller = true @@ -32,34 +32,55 @@ final class MasterViewController: NSViewController { scrollView.drawsBackground = false return (scrollView, outlineView) }() - + /// Diffable data source similar to `NSCollectionViewDiffableDataSource`. private lazy var dataSource: OutlineViewDiffableDataSource = { let source = OutlineViewDiffableDataSource(outlineView: scrollableOutlineView.outlineView) - source.draggingHandlers = OutlineViewDiffableDataSource.DraggingHandlers(validateDrop: { _, drop in - + source.draggingHandlers = OutlineViewDiffableDataSource.DraggingHandlers(validateDrop: { dataSource, drop in + // Option-, Control- and Command- modifiers are disabled guard drop.operation.contains(.move) else { return nil } - + // Dragging on, before and after self is denied guard drop.draggedItems.allSatisfy({ $0 !== drop.targetItem }) else { return nil } - + + // Cannot drag Grouped Rows on, before, after non-grouped items + guard drop.draggedItems.allSatisfy({ + if !$0.isGroup { + return true + } + return drop.targetItem.isGroup && drop.type != .on + }) else { return nil } + + // Target cannot be a child + guard drop.draggedItems.allSatisfy({ + dataSource.snapshot().isItemAncestor($0, of: drop.targetItem) == false + }) else { return nil } + return drop - }, acceptDrop: { sender, drop in - - var snapshot = sender.snapshot() - snapshot.deleteItems(drop.draggedItems) - switch drop.type { - case .on: - snapshot.appendItems(drop.draggedItems, into: drop.targetItem) - case .before: - snapshot.insertItems(drop.draggedItems, beforeItem: drop.targetItem) - case .after: - snapshot.insertItems(drop.draggedItems, afterItem: drop.targetItem) + }, acceptDrop: { dataSource, drop in + + var snapshot = dataSource.snapshot() + + drop.draggedItems.forEach { droppedItem in + switch drop.type { + case .on: + // Remove only the dragged items from our snapshot, not their children as we're going to append / re-insert these + snapshot.moveItem(droppedItem, into: drop.targetItem) + case .before: + snapshot.moveItem(droppedItem, beforeItem: drop.targetItem) + case .after: + snapshot.moveItem(droppedItem, afterItem: drop.targetItem) + } + } + + dataSource.applySnapshot(snapshot, animatingDifferences: shouldAnimate) { + // Testing + //dataSource.reloadData() } - sender.applySnapshot(snapshot, animatingDifferences: shouldAnimate) return true }) + return source }() } @@ -67,7 +88,7 @@ final class MasterViewController: NSViewController { // MARK: - extension MasterViewController { - + /// Master is a container for the scroll view. override func loadView() { view = scrollableOutlineView.scrollView @@ -77,16 +98,18 @@ extension MasterViewController { // MARK: - Internal API extension MasterViewController { - + /// Read-write snapshot of the sidebar data. var snapshotBinding: Binding { .init(get: { [dataSource] in dataSource.snapshot() }, set: { [dataSource] snapshot in dataSource.applySnapshot(snapshot, animatingDifferences: shouldAnimate) + + self.expandAllItems(nil) }) } - + /// Read-only selection. var selectionPublisher: AnyPublisher<[MasterOutlineViewItem], Never> { NotificationCenter.default @@ -104,7 +127,7 @@ extension MasterViewController { // MARK: - Actions extension MasterViewController { - + /// Expands all outline view items. @IBAction func expandAllItems(_ sender: Any?) { NSAnimationContext.runAnimationGroup { context in @@ -112,7 +135,7 @@ extension MasterViewController { scrollableOutlineView.outlineView.animator().expandItem(nil, expandChildren: true) } } - + /// Collapses all outline view items. @IBAction func collapseAllItems(_ sender: Any?) { NSAnimationContext.runAnimationGroup { context in diff --git a/Package.swift b/Package.swift index b06291d..2933bfb 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,13 @@ -// swift-tools-version:5.2 +// swift-tools-version:5.3 import PackageDescription let package = Package( name: "OutlineViewDiffableDataSource", - platforms: [.macOS(.v10_15)], + platforms: [.macOS(.v10_11)], products: [.library(name: "OutlineViewDiffableDataSource", targets: ["OutlineViewDiffableDataSource"])], + dependencies: [ + // Dependencies declare other packages that this package depends on. + ], targets: [ .target(name: "OutlineViewDiffableDataSource", dependencies: [], path: "Sources"), .testTarget(name: "OutlineViewDiffableDataSourceTests", dependencies: ["OutlineViewDiffableDataSource"], path: "Tests"), diff --git a/README.md b/README.md index 7e2e33f..6db32d8 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,12 @@ mutating func reloadItems(_ items: [Item]) -> Bool Not sure how this ends, but the `DiffableDataSourceSnapshot` is a value type. Just like its “official” counterpart, this struct can be built and applied from the background thread, as far as I know 😅 +## Requirements + +SPM: macOS 10.11 + +Demo: macOS 10.15 + ## Demo App The Demo Mac app is a playground for breaking the library. The snapshot API is 100% covered by Tests, but I would much appreciate any help with QA. diff --git a/Sources/Diff/CollectionChanges.swift b/Sources/Diff/CollectionChanges.swift new file mode 100644 index 0000000..121c3fa --- /dev/null +++ b/Sources/Diff/CollectionChanges.swift @@ -0,0 +1,409 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2015 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +/// A collection of insertions and removals that describe the difference +/// between two ordered collection states. +public struct CollectionChanges { + /// A single change to a collection. + @frozen + public enum Change { + /// An insertion. + /// + /// The `offset` value is the offset of the inserted element in the final + /// state of the collection after the difference is fully applied. + /// A non-`nil` `associatedWith` value is the offset of the complementary + /// change. + case insert(offset: Int, element: ChangeElement, associatedWith: Int?) + + /// A removal. + /// + /// The `offset` value is the offset of the element to be removed in the + /// original state of the collection. A non-`nil` `associatedWith` value is + /// the offset of the complementary change. + case remove(offset: Int, element: ChangeElement, associatedWith: Int?) + + // Internal common field accessors + internal var _offset: Int { + get { + switch self { + case .insert(offset: let o, element: _, associatedWith: _): + return o + case .remove(offset: let o, element: _, associatedWith: _): + return o + } + } + } + internal var _element: ChangeElement { + get { + switch self { + case .insert(offset: _, element: let e, associatedWith: _): + return e + case .remove(offset: _, element: let e, associatedWith: _): + return e + } + } + } + internal var _associatedOffset: Int? { + get { + switch self { + case .insert(offset: _, element: _, associatedWith: let o): + return o + case .remove(offset: _, element: _, associatedWith: let o): + return o + } + } + } + } + + /// The insertions contained by this difference, from lowest offset to + /// highest. + public let insertions: [Change] + + /// The removals contained by this difference, from lowest offset to highest. + public let removals: [Change] + + /// The public initializer calls this function to ensure that its parameter + /// meets the conditions set in its documentation. + /// + /// - Parameter changes: a collection of `CollectionChanges.Change` + /// instances intended to represent a valid state transition for + /// `CollectionChanges`. + /// + /// - Returns: whether the parameter meets the following criteria: + /// + /// 1. All insertion offsets are unique + /// 2. All removal offsets are unique + /// 3. All associations between insertions and removals are symmetric + /// + /// Complexity: O(`changes.count`) + private static func _validateChanges( + _ changes : Changes + ) -> Bool where Changes.Element == Change { + if changes.isEmpty { return true } + + var insertAssocToOffset = Dictionary() + var removeOffsetToAssoc = Dictionary() + var insertOffset = Set() + var removeOffset = Set() + + for change in changes { + let offset = change._offset + if offset < 0 { return false } + + switch change { + case .remove(_, _, _): + if removeOffset.contains(offset) { return false } + removeOffset.insert(offset) + case .insert(_, _, _): + if insertOffset.contains(offset) { return false } + insertOffset.insert(offset) + } + + if let assoc = change._associatedOffset { + if assoc < 0 { return false } + switch change { + case .remove(_, _, _): + if removeOffsetToAssoc[offset] != nil { return false } + removeOffsetToAssoc[offset] = assoc + case .insert(_, _, _): + if insertAssocToOffset[assoc] != nil { return false } + insertAssocToOffset[assoc] = offset + } + } + } + + return removeOffsetToAssoc == insertAssocToOffset + } + + /// Creates a new collection difference from a collection of changes. + /// + /// To find the difference between two collections, use the + /// `changes(from:)` method declared on the `BidirectionalCollection` + /// protocol. + /// + /// The collection of changes passed as `changes` must meet these + /// requirements: + /// + /// - All insertion offsets are unique + /// - All removal offsets are unique + /// - All associations between insertions and removals are symmetric + /// + /// - Parameter changes: A collection of changes that represent a transition + /// between two states. + /// + /// - Complexity: O(*n* * log(*n*)), where *n* is the length of the + /// parameter. + public init?( + _ changes: Changes + ) where Changes.Element == Change { + guard CollectionChanges._validateChanges(changes) else { + return nil + } + + self.init(_validatedChanges: changes) + } + + /// Internal initializer for use by algorithms that cannot produce invalid + /// collections of changes. These include the Myers' diff algorithm, + /// self.inverse(), and the move inferencer. + /// + /// If parameter validity cannot be guaranteed by the caller then + /// `CollectionChanges.init?(_:)` should be used instead. + /// + /// - Parameter c: A valid collection of changes that represent a transition + /// between two states. + /// + /// - Complexity: O(*n* * log(*n*)), where *n* is the length of the + /// parameter. + internal init( + _validatedChanges changes: Changes + ) where Changes.Element == Change { + let sortedChanges = changes.sorted { (a, b) -> Bool in + switch (a, b) { + case (.remove(_, _, _), .insert(_, _, _)): + return true + case (.insert(_, _, _), .remove(_, _, _)): + return false + default: + return a._offset < b._offset + } + } + + // Find first insertion via binary search + let firstInsertIndex: Int + if sortedChanges.isEmpty { + firstInsertIndex = 0 + } else { + var range = 0...sortedChanges.count + while range.lowerBound != range.upperBound { + let i = (range.lowerBound + range.upperBound) / 2 + switch sortedChanges[i] { + case .insert(_, _, _): + range = range.lowerBound...i + case .remove(_, _, _): + range = (i + 1)...range.upperBound + } + } + firstInsertIndex = range.lowerBound + } + + removals = Array(sortedChanges[0.. Self { + return CollectionChanges(_validatedChanges: self.map { c in + switch c { + case .remove(let o, let e, let a): + return .insert(offset: o, element: e, associatedWith: a) + case .insert(let o, let e, let a): + return .remove(offset: o, element: e, associatedWith: a) + } + }) + } +} + +/// A CollectionChanges is itself a Collection. +/// +/// The enumeration order of `Change` elements is: +/// +/// 1. `.remove`s, from highest `offset` to lowest +/// 2. `.insert`s, from lowest `offset` to highest +/// +/// This guarantees that applicators on compatible base states are safe when +/// written in the form: +/// +/// ``` +/// for c in diff { +/// switch c { +/// case .remove(offset: let o, element: _, associatedWith: _): +/// arr.remove(at: o) +/// case .insert(offset: let o, element: let e, associatedWith: _): +/// arr.insert(e, at: o) +/// } +/// } +/// ``` +extension CollectionChanges: Collection { + public typealias Element = Change + + /// The position of a collection difference. + @frozen + public struct Index { + // Opaque index type is isomorphic to Int + @usableFromInline + internal let _offset: Int + + internal init(_offset offset: Int) { + _offset = offset + } + } + + public var startIndex: Index { + return Index(_offset: 0) + } + + public var endIndex: Index { + return Index(_offset: removals.count + insertions.count) + } + + public func index(after index: Index) -> Index { + return Index(_offset: index._offset + 1) + } + + public subscript(position: Index) -> Element { + if position._offset < removals.count { + return removals[removals.count - (position._offset + 1)] + } + return insertions[position._offset - removals.count] + } + + public func index(before index: Index) -> Index { + return Index(_offset: index._offset - 1) + } + + public func formIndex(_ index: inout Index, offsetBy distance: Int) { + index = Index(_offset: index._offset + distance) + } + + public func distance(from start: Index, to end: Index) -> Int { + return end._offset - start._offset + } +} + +extension CollectionChanges.Index: Equatable { + @inlinable + public static func == ( + lhs: CollectionChanges.Index, + rhs: CollectionChanges.Index + ) -> Bool { + return lhs._offset == rhs._offset + } +} + +extension CollectionChanges.Index: Comparable { + @inlinable + public static func < ( + lhs: CollectionChanges.Index, + rhs: CollectionChanges.Index + ) -> Bool { + return lhs._offset < rhs._offset + } +} + +extension CollectionChanges.Index: Hashable { + @inlinable + public func hash(into hasher: inout Hasher) { + hasher.combine(_offset) + } +} + +extension CollectionChanges.Change: Equatable where ChangeElement: Equatable {} + +extension CollectionChanges: Equatable where ChangeElement: Equatable {} + +extension CollectionChanges.Change: Hashable where ChangeElement: Hashable {} + +extension CollectionChanges: Hashable where ChangeElement: Hashable {} + +extension CollectionChanges where ChangeElement: Hashable { + /// Returns a new collection difference with associations between individual + /// elements that have been removed and inserted only once. + /// + /// - Returns: A collection difference with all possible moves inferred. + /// + /// - Complexity: O(*n*) where *n* is the number of collection differences. + public func inferringMoves() -> CollectionChanges { + let uniqueRemovals: [ChangeElement:Int?] = { + var result = [ChangeElement:Int?](minimumCapacity: Swift.min(removals.count, insertions.count)) + for removal in removals { + let element = removal._element + if result[element] != .none { + result[element] = .some(.none) + } else { + result[element] = .some(removal._offset) + } + } + return result.filter { (_, v) -> Bool in v != .none } + }() + + let uniqueInsertions: [ChangeElement:Int?] = { + var result = [ChangeElement:Int?](minimumCapacity: Swift.min(removals.count, insertions.count)) + for insertion in insertions { + let element = insertion._element + if result[element] != .none { + result[element] = .some(.none) + } else { + result[element] = .some(insertion._offset) + } + } + return result.filter { (_, v) -> Bool in v != .none } + }() + + return CollectionChanges(_validatedChanges: map({ (change: Change) -> Change in + switch change { + case .remove(offset: let offset, element: let element, associatedWith: _): + if uniqueRemovals[element] == nil { + return change + } + if let assoc = uniqueInsertions[element] { + return .remove(offset: offset, element: element, associatedWith: assoc) + } + case .insert(offset: let offset, element: let element, associatedWith: _): + if uniqueInsertions[element] == nil { + return change + } + if let assoc = uniqueRemovals[element] { + return .insert(offset: offset, element: element, associatedWith: assoc) + } + } + return change + })) + } +} + +extension CollectionChanges.Change: Codable where ChangeElement: Codable { + private enum _CodingKeys: String, CodingKey { + case offset + case element + case associatedOffset + case isRemove + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: _CodingKeys.self) + let offset = try values.decode(Int.self, forKey: .offset) + let element = try values.decode(ChangeElement.self, forKey: .element) + let associatedOffset = try values.decode(Int?.self, forKey: .associatedOffset) + let isRemove = try values.decode(Bool.self, forKey: .isRemove) + if isRemove { + self = .remove(offset: offset, element: element, associatedWith: associatedOffset) + } else { + self = .insert(offset: offset, element: element, associatedWith: associatedOffset) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: _CodingKeys.self) + switch self { + case .remove(_, _, _): + try container.encode(true, forKey: .isRemove) + case .insert(_, _, _): + try container.encode(false, forKey: .isRemove) + } + + try container.encode(_offset, forKey: .offset) + try container.encode(_element, forKey: .element) + try container.encode(_associatedOffset, forKey: .associatedOffset) + } +} + +extension CollectionChanges: Codable where ChangeElement: Codable {} diff --git a/Sources/Diff/Diffing.swift b/Sources/Diff/Diffing.swift new file mode 100644 index 0000000..0bf9dbc --- /dev/null +++ b/Sources/Diff/Diffing.swift @@ -0,0 +1,361 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2015 - 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +// MARK: Diff application to RangeReplaceableCollection + +extension CollectionChanges { + fileprivate func _fastEnumeratedApply( + _ consume: (Change) throws -> Void + ) rethrows { + let totalRemoves = removals.count + let totalInserts = insertions.count + var enumeratedRemoves = 0 + var enumeratedInserts = 0 + + while enumeratedRemoves < totalRemoves || enumeratedInserts < totalInserts { + let change: Change + if enumeratedRemoves < removals.count && enumeratedInserts < insertions.count { + let removeOffset = removals[enumeratedRemoves]._offset + let insertOffset = insertions[enumeratedInserts]._offset + if removeOffset - enumeratedRemoves <= insertOffset - enumeratedInserts { + change = removals[enumeratedRemoves] + } else { + change = insertions[enumeratedInserts] + } + } else if enumeratedRemoves < totalRemoves { + change = removals[enumeratedRemoves] + } else if enumeratedInserts < totalInserts { + change = insertions[enumeratedInserts] + } else { + // Not reached, loop should have exited. + preconditionFailure() + } + + try consume(change) + + switch change { + case .remove(_, _, _): + enumeratedRemoves += 1 + case .insert(_, _, _): + enumeratedInserts += 1 + } + } + } +} + +// Error type allows the use of throw to unroll state on application failure +private enum _ApplicationError : Error { case failed } + +extension RangeReplaceableCollection { + /// Applies the given difference to this collection. + /// + /// - Parameter difference: The difference to be applied. + /// + /// - Returns: An instance representing the state of the receiver with the + /// difference applied, or `nil` if the difference is incompatible with + /// the receiver's state. + /// + /// - Complexity: O(*n* + *c*), where *n* is `self.count` and *c* is the + /// number of changes contained by the parameter. + public func applying(_ difference: CollectionChanges) -> Self? { + + func append( + into target: inout Self, + contentsOf source: Self, + from index: inout Self.Index, count: Int + ) throws { + let start = index + if !source.formIndex(&index, offsetBy: count, limitedBy: source.endIndex) { + throw _ApplicationError.failed + } + target.append(contentsOf: source[start..( + from other: C, + by areEquivalent: (C.Element, Element) -> Bool + ) -> CollectionChanges + where C.Element == Self.Element { + return _myers(from: other, to: self, using: areEquivalent) + } +} + +extension BidirectionalCollection where Element: Equatable { + /// Returns the difference needed to produce this collection's ordered + /// elements from the given collection. + /// + /// This function does not infer element moves. If you need to infer moves, + /// call the `inferringMoves()` method on the resulting difference. + /// + /// - Parameters: + /// - other: The base state. + /// + /// - Returns: The difference needed to produce this collection's ordered + /// elements from the given collection. + /// + /// - Complexity: Worst case performance is O(*n* * *m*), where *n* is the + /// count of this collection and *m* is `other.count`. You can expect + /// faster execution when the collections share many common elements, or + /// if `Element` conforms to `Hashable`. + public func changes( + from other: C + ) -> CollectionChanges where C.Element == Self.Element { + return changes(from: other, by: ==) + } +} + +// MARK: Internal implementation + +// _V is a rudimentary type made to represent the rows of the triangular matrix +// type used by the Myer's algorithm. +// +// This type is basically an array that only supports indexes in the set +// `stride(from: -d, through: d, by: 2)` where `d` is the depth of this row in +// the matrix `d` is always known at allocation-time, and is used to preallocate +// the structure. +private struct _V { + + private var a: [Int] +#if INTERNAL_CHECKS_ENABLED + private let isOdd: Bool +#endif + + // The way negative indexes are implemented is by interleaving them in the empty slots between the valid positive indexes + @inline(__always) private static func transform(_ index: Int) -> Int { + // -3, -1, 1, 3 -> 3, 1, 0, 2 -> 0...3 + // -2, 0, 2 -> 2, 0, 1 -> 0...2 + return (index <= 0 ? -index : index &- 1) + } + + init(maxIndex largest: Int) { +#if INTERNAL_CHECKS_ENABLED + assert(largest >= 0) + isOdd = largest % 2 == 1 +#endif + a = [Int](repeating: 0, count: largest + 1) + } + + subscript(index: Int) -> Int { + get { +#if INTERNAL_CHECKS_ENABLED + assert(isOdd == (index % 2 != 0)) +#endif + return a[_V.transform(index)] + } + set(newValue) { +#if INTERNAL_CHECKS_ENABLED + assert(isOdd == (index % 2 != 0)) +#endif + a[_V.transform(index)] = newValue + } + } +} + +private func _myers( + from old: C, to new: D, + using cmp: (C.Element, D.Element) -> Bool +) -> CollectionChanges +where +C: BidirectionalCollection, +D: BidirectionalCollection, +C.Element == D.Element +{ + + // Core implementation of the algorithm described at http://www.xmailserver.org/diff2.pdf + // Variable names match those used in the paper as closely as possible + func _descent(from a: UnsafeBufferPointer, to b: UnsafeBufferPointer) -> [_V] { + let n = a.count + let m = b.count + let max = n + m + + var result = [_V]() + var v = _V(maxIndex: 1) + v[1] = 0 + + var x = 0 + var y = 0 + iterator: for d in 0...max { + let prev_v = v + result.append(v) + v = _V(maxIndex: d) + + // The code in this loop is _very_ hot—the loop bounds increases in terms + // of the iterator of the outer loop! + for k in stride(from: -d, through: d, by: 2) { + if k == -d { + x = prev_v[k &+ 1] + } else { + let km = prev_v[k &- 1] + + if k != d { + let kp = prev_v[k &+ 1] + if km < kp { + x = kp + } else { + x = km &+ 1 + } + } else { + x = km &+ 1 + } + } + y = x &- k + + while x < n && y < m { + if !cmp(a[x], b[y]) { + break; + } + x &+= 1 + y &+= 1 + } + + v[k] = x + + if x >= n && y >= m { + break iterator + } + } + if x >= n && y >= m { + break + } + } + + assert(x >= n && y >= m) + + return result + } + + // Backtrack through the trace generated by the Myers descent to produce the changes that make up the diff + func _formChanges( + from a: UnsafeBufferPointer, + to b: UnsafeBufferPointer, + using trace: [_V] + ) -> [CollectionChanges.Change] { + var changes = [CollectionChanges.Change]() + changes.reserveCapacity(trace.count) + + var x = a.count + var y = b.count + for d in stride(from: trace.count &- 1, to: 0, by: -1) { + let v = trace[d] + let k = x &- y + let prev_k = (k == -d || (k != d && v[k &- 1] < v[k &+ 1])) ? k &+ 1 : k &- 1 + let prev_x = v[prev_k] + let prev_y = prev_x &- prev_k + + while x > prev_x && y > prev_y { + // No change at this position. + x &-= 1 + y &-= 1 + } + + assert((x == prev_x && y > prev_y) || (y == prev_y && x > prev_x)) + if y != prev_y { + changes.append(.insert(offset: prev_y, element: b[prev_y], associatedWith: nil)) + } else { + changes.append(.remove(offset: prev_x, element: a[prev_x], associatedWith: nil)) + } + + x = prev_x + y = prev_y + } + + return changes + } + + /* Splatting the collections into contiguous storage has two advantages: + * + * 1) Subscript access is much faster + * 2) Subscript index becomes Int, matching the iterator types in the algorithm + * + * Combined, these effects dramatically improves performance when + * collections differ significantly, without unduly degrading runtime when + * the parameters are very similar. + * + * In terms of memory use, the linear cost of creating a ContiguousArray (when + * necessary) is significantly less than the worst-case n² memory use of the + * descent algorithm. + */ + func _withContiguousStorage( + for values: C, + _ body: (UnsafeBufferPointer) throws -> R + ) rethrows -> R { + if let result = try values.withContiguousStorageIfAvailable(body) { return result } + let array = ContiguousArray(values) + return try array.withUnsafeBufferPointer(body) + } + + return _withContiguousStorage(for: old) { a in + return _withContiguousStorage(for: new) { b in + return CollectionChanges(_formChanges(from: a, to: b, using:_descent(from: a, to: b)))! + } + } +} diff --git a/Sources/DiffableDataSourceSnapshot.swift b/Sources/DiffableDataSourceSnapshot.swift index 5e36700..7e00d0f 100644 --- a/Sources/DiffableDataSourceSnapshot.swift +++ b/Sources/DiffableDataSourceSnapshot.swift @@ -1,35 +1,47 @@ import Foundation import os -/// Container for the tree of items. +/// The internal data source snapshot used by `OutlineViewDiffableDataSource`. Stores and represents the tree of nodes. +/// All operations are to be performed on a snapshot directly before being passed on `outlineDataSource.apply(...)` +/// +/// As items of type `OutlineViewItem` are inserted / removed / moved etc, the snapshot manages and updates an internal map for performing lookups. +/// Each item is stored as a `DiffableDataSourceSnapshot.Node` to keep track of its parent / children. When an item is moved to a new parent, +/// the internal map is subsequently updated to reflect this. public struct DiffableDataSourceSnapshot { /// Shortcut for outline view objects. - public typealias Item = NSObject + public typealias Item = OutlineViewItem /// Shortcut for outline view object IDs. - typealias ItemID = UUID + public typealias ItemID = String - /// Used to store tree nodes for items. + /// Represents a single node of the tree. private struct Node: Hashable { + let itemID: ItemID + + /// Node parent's `itemID` + var parentID: ItemID? - /// Parent of the stored item. - var parent: ItemID? - - /// Children of the stored item. - var children: [ItemID] + /// `itemID`s of node's children + var childrenIDs: [ItemID] + } + + public enum NodePosition { + case on + case before + case after } - /// Identifiers associated with items. - private var idsForItems: [Item: ItemID] = [:] + /// Maps items to their identifiers + private var mapItemToID: [Item: ItemID] = [:] - /// Items associated with identifiers. - private var itemsForIds: [ItemID: Item] = [:] + /// Maps identifiers to their items + private var mapIDToItem: [ItemID: Item] = [:] - /// Tree nodes with stored items. - private var nodesForIds: [ItemID: Node] = [:] + /// Maps `itemID`s to `Node` + private var mapIDToNode: [ItemID: Node] = [:] - /// Root nodes with stored items. + /// ItemIDs of root items private var rootIds: [ItemID] = [] /// Used to remember reloaded items until flush. @@ -45,60 +57,112 @@ public extension DiffableDataSourceSnapshot { /// Total number of stored items. var numberOfItems: Int { - nodesForIds.count + mapIDToNode.count } - /// Stored items sorted from top to bottom. + /// Stored items sorted from top to bottom. Generated from `getSortedIndexedNodes()`. func sortedItems() -> [Item] { - indexedIds().map(\.itemId).compactMap(itemForId) + getSortedIndexedNodes().map(\.itemId).compactMap(getItemForID) } /// Returns true if the given item is in the snapshot. /// - Parameter item: The item to check. func containsItem(_ item: Item) -> Bool { - idForItem(item) != nil + getIDForItem(item) != nil } /// Returns the number of children for the given parent item. /// - Parameter parentItem: Pass nil to retrieve the number of root items. func numberOfItems(in parentItem: Item?) -> Int { - guard let parentItem = parentItem else { return rootIds.count } - guard let parentNode = idForItem(parentItem).flatMap(nodeForId) else { - os_log(.error, log: errors, "Cannot find parent item “%s”", String(describing: parentItem)) + guard let parentItem = parentItem else { + return rootIds.count + } + + guard let parentNode = getIDForItem(parentItem).flatMap(getNodeForID) else { + #if DEBUG + print("numberOfItems - Cannot find parent item \(String(describing: parentItem))") + #endif return 0 } - return parentNode.children.count + return parentNode.childrenIDs.count } /// Returns children for the given parent item. /// - Parameter parentItem: Pass nil to retrieve the number of root items. func childrenOfItem(_ parentItem: Item?) -> [Item] { - guard let parentItem = parentItem else { return rootIds.compactMap(itemForId) } - guard let parentNode = idForItem(parentItem).flatMap(nodeForId) else { - os_log(.error, log: errors, "Cannot find parent item “%s”", String(describing: parentItem)) + guard let parentItem = parentItem else { + return rootIds.compactMap(getItemForID) + } + + guard let parentNode = getIDForItem(parentItem).flatMap(getNodeForID) else { +#if DEBUG + print("childrenOfItem - Cannot find parent item \(String(describing: parentItem))") + #endif return [] } - return parentNode.children.compactMap(itemForId) + return parentNode.childrenIDs.compactMap(getItemForID) } /// Returns parent for the given child item. Root items have `nil` as a parent. /// - Parameter childItem: Child item added to the snapshot before. func parentOfItem(_ childItem: Item) -> Item? { - guard let childId = idForItem(childItem), let childNode = nodeForId(childId) else { - os_log(.error, log: errors, "Cannot find item “%s”", String(describing: childItem)) + guard let childId = getIDForItem(childItem), let childNode = getNodeForID(childId) else { +#if DEBUG + print("Cannot find item \(String(describing: childItem))") + #endif return nil } - return childNode.parent.flatMap(itemForId) + return childNode.parentID.flatMap(getItemForID) + } + + + /// Returns `true` if the given item is a child of a given item or its sub-items + /// - Parameters: + /// - item: item to check + /// - anotherItem: another item + func isItemDescendant(_ item: Item, of anotherItem: Item) -> Bool { + if item == anotherItem { return true } + + let childrenOfOther = childrenOfItem(anotherItem) + if childrenOfOther.contains(item) { + return true + } + else { + for childOfOther in childrenOfOther { + return isItemDescendant(item, of: childOfOther) + } + } + return false + } + + /// Returns `true` if the given item is a parent or a grand parent of a given item + /// - Parameters: + /// - item: item to check + /// - anotherItem: another item + func isItemAncestor(_ item: Item, of anotherItem: Item) -> Bool { + if item == anotherItem { return true } + + if let parentOfOther = parentOfItem(anotherItem) { + if item == parentOfOther { + return true + } + else { + return isItemAncestor(item, of: parentOfOther) + } + } + return false } /// Returns index of the given child item in its parent, or `nil` if the given item is not in the snapshot. /// - Parameter childItem: Child item added to the snapshot before. func indexOfItem(_ childItem: Item) -> Int? { - guard let childId = idForItem(childItem), let childNode = nodeForId(childId) else { - os_log(.error, log: errors, "Cannot find item “%s”", String(describing: childItem)) + guard let childId = getIDForItem(childItem), let childNode = getNodeForID(childId) else { +#if DEBUG + print("Cannot find item \(String(describing: childItem))") + #endif return nil } - let children = childIdsOfItemWithId(childNode.parent) + let children = getChildIDsForParentID(childNode.parentID) return children.firstIndex(of: childId) } @@ -108,32 +172,7 @@ public extension DiffableDataSourceSnapshot { /// - Returns: False if items cannot be added e.g. because the parent is not in the snapshot. @discardableResult mutating func appendItems(_ newItems: [Item], into parentItem: Item? = nil) -> Bool { - guard validateNewItems(newItems) else { return false } - guard let parentItem = parentItem else { - let newIds = newItems.map { newItem -> ItemID in - let newId = ItemID() - itemsForIds[newId] = newItem - idsForItems[newItem] = newId - nodesForIds[newId] = .init(parent: nil, children: []) - return newId - } - rootIds.append(contentsOf: newIds) - return true - } - guard let parentId = idForItem(parentItem), var parentNode = nodeForId(parentId) else { - os_log(.error, log: errors, "Cannot find parent item “%s”", String(describing: parentItem)) - return false - } - let newIds = newItems.map { newItem -> ItemID in - let newId = ItemID() - itemsForIds[newId] = newItem - idsForItems[newItem] = newId - nodesForIds[newId] = .init(parent: parentId, children: []) - return newId - } - parentNode.children.append(contentsOf: newIds) - nodesForIds[parentId] = parentNode - return true + return insertItems(newItems, into: parentItem) } /// Inserts items before the given item. @@ -142,7 +181,7 @@ public extension DiffableDataSourceSnapshot { /// - Returns: False if items cannot be inserted e.g. because the target item is not in the snapshot. @discardableResult mutating func insertItems(_ newItems: [Item], beforeItem: Item) -> Bool { - insertItems(newItems, aroundItem: beforeItem) { $0 } + insertItems(newItems, nextTo: beforeItem, atPosition: .before) } /// Inserts items after the given item. @@ -151,38 +190,50 @@ public extension DiffableDataSourceSnapshot { /// - Returns: False if items cannot be inserted e.g. because the target item is not in the snapshot. @discardableResult mutating func insertItems(_ newItems: [Item], afterItem: Item) -> Bool { - insertItems(newItems, aroundItem: afterItem) { $0 + 1 } + insertItems(newItems, nextTo: afterItem, atPosition: .after) } /// Deletes given items and their children. /// - Parameter existingItems: Items added to the snapshot before. /// - Returns: False if items cannot be deleted e.g. because some of them are not in the snapshot. @discardableResult - mutating func deleteItems(_ existingItems: [Item]) -> Bool { - guard validateExistingItems(Set(existingItems)) else { return false } - - var affectedIds = Set(existingItems.compactMap(idForItem)) - enumerateItemIds { indexedId in - guard let parentId = indexedId.parentId, affectedIds.contains(parentId) else { return } - affectedIds.insert(indexedId.itemId) + mutating func deleteItems(_ existingItems: [Item], withChildren: Bool = true) -> Bool { + guard contains(existingItems) else { return false } + + var itemIdsToRemove = Set(existingItems.compactMap(getIDForItem)) + + if withChildren { + enumerateIndexedNodes { indexedNode in + if let parentId = indexedNode.parentId, itemIdsToRemove.contains(parentId) { + itemIdsToRemove.insert(indexedNode.itemId) + } + } } - let affectedParentIds = Set(affectedIds.map { nodeForId($0)?.parent }) - let affectedItems = affectedIds.compactMap(itemForId) - affectedIds.forEach { affectedId in - itemsForIds.removeValue(forKey: affectedId) - nodesForIds.removeValue(forKey: affectedId) + // Grab a copy before removing these + let affectedParentIds = Set(itemIdsToRemove.map { getNodeForID($0)?.parentID }) + let itemsToRemove = itemIdsToRemove.compactMap(getItemForID) + + // Now remove all items that have deleted + itemIdsToRemove.forEach { affectedId in + mapIDToItem.removeValue(forKey: affectedId) + mapIDToNode.removeValue(forKey: affectedId) } - affectedItems.forEach { affectedItem in - idsForItems.removeValue(forKey: affectedItem) + + itemsToRemove.forEach { affectedItem in + mapItemToID.removeValue(forKey: affectedItem) } - idsPendingReload.subtract(affectedIds) - affectedParentIds.forEach { - guard let affectedParentId = $0 else { - rootIds.removeAll { affectedIds.contains($0) } + idsPendingReload.subtract(itemIdsToRemove) + + affectedParentIds.forEach { affectedParentId in + guard let affectedParentId = affectedParentId else { + // remove root item + rootIds.removeAll { itemIdsToRemove.contains($0) } return } - nodesForIds[affectedParentId]?.children.removeAll { affectedIds.contains($0) } + + // Now remove items from affected parents + mapIDToNode[affectedParentId]?.childrenIDs.removeAll { itemIdsToRemove.contains($0) } } return true } @@ -197,8 +248,9 @@ public extension DiffableDataSourceSnapshot { /// - Returns: False if items cannot be reloaded e.g. because some of them are not in the snapshot. @discardableResult mutating func reloadItems(_ items: [Item]) -> Bool { - guard validateExistingItems(Set(items)) else { return false } - let ids = items.compactMap(idForItem) + guard contains(items) else { return false } + + let ids = items.compactMap(getIDForItem) idsPendingReload.formUnion(ids) return true } @@ -206,27 +258,44 @@ public extension DiffableDataSourceSnapshot { /// Returns all items marked for reloading and forgets them. mutating func flushReloadedItems() -> [Item] { guard idsPendingReload.isEmpty == false else { return [] } + var result: [Item] = [] - enumerateItemIds { indexedItemId in - let itemId = indexedItemId.itemId - guard idsPendingReload.contains(itemId), let item = itemForId(itemId) else { return } + enumerateIndexedNodes { indexedNode in + let itemId = indexedNode.itemId + guard idsPendingReload.contains(itemId), let item = getItemForID(itemId) else { return } result.append(item) } idsPendingReload.removeAll() return result } - /// Returns true if the given item can be moved next to the target item. + /// Returns `true` if the given item can be moved next to the target item. /// - Parameter item: Item added to the snapshot before. /// - Parameter targetItem: The target item added to the snapshot before. - func canMoveItem(_ item: Item, aroundItem targetItem: Item) -> Bool { - guard validateExistingItems([item, targetItem]) else { return false } - guard let itemId = idForItem(item), let targetItemId = idForItem(targetItem), itemId != targetItemId else { - os_log(.error, log: errors, "Cannot move items around themselves") + /// - Returns: Returns `false` if either the `item` or the `targetItem` do not exist in the snapshot + func canMoveItem(_ item: Item, nextTo targetItem: Item) -> Bool { + guard contains([item, targetItem]) else { return false } + + guard let itemId = getIDForItem(item), + let targetItemId = getIDForItem(targetItem), + itemId != targetItemId else { +#if DEBUG + print("Cannot move items around themselves") + #endif return false } - let parentIds = sequence(first: targetItemId) { self.nodeForId($0)?.parent } - return parentIds.allSatisfy { $0 != itemId } + + let not = (!) + return not(isItemAncestor(item, of: targetItem)) + } + + /// Returns `true` if the given item can be moved into the target item. + /// - Parameter item: Item added to the snapshot before. + /// - Parameter targetItem: The target item added to the snapshot before. + /// - Returns: Returns `false` if either the `item` or the `targetItem` do not exist in the snapshot + func canMoveItem(_ item: Item, into targetItem: Item) -> Bool { + // Same checks performed + return canMoveItem(item, nextTo: targetItem) } /// Moves the given item above the target item. @@ -235,7 +304,7 @@ public extension DiffableDataSourceSnapshot { /// - Returns: False if the given item cannot be moved e.g. because it’s parent of the target item. @discardableResult mutating func moveItem(_ item: Item, beforeItem: Item) -> Bool { - moveItem(item, aroundItem: beforeItem) { $0 } + moveItem(item, nextTo: beforeItem, atPosition: .before) } /// Moves the given item below the target item. @@ -244,7 +313,16 @@ public extension DiffableDataSourceSnapshot { /// - Returns: False if the given item cannot be moved e.g. because it’s parent of the target item. @discardableResult mutating func moveItem(_ item: Item, afterItem: Item) -> Bool { - moveItem(item, aroundItem: afterItem) { $0 + 1 } + moveItem(item, nextTo: afterItem, atPosition: .after) + } + + /// Moves the given item into target item. + /// - Parameter item: Item added to the snapshot before. + /// - Parameter afterItem: The target item to move into (i.e. target will become item's new parent) + /// - Returns: False if the given item cannot be moved e.g. because it’s parent of the target item. + @discardableResult + mutating func moveItem(_ item: Item, into targetParent: Item) -> Bool { + moveItem(item, nextTo: targetParent, atPosition: .on) } /// Enumerates all items from top to bottom. @@ -252,9 +330,9 @@ public extension DiffableDataSourceSnapshot { /// - Parameter item: Enumerated item. /// - Parameter parentItem: Parent item if available. func enumerateItems(using block: (_ item: Item, _ parentItem: Item?) -> Void) { - enumerateItemIds { id in - if let item = itemForId(id.itemId) { - block(item, id.parentId.flatMap(itemForId)) + enumerateIndexedNodes { id in + if let item = getItemForID(id.itemId) { + block(item, id.parentId.flatMap(getItemForID)) } } } @@ -264,9 +342,8 @@ public extension DiffableDataSourceSnapshot { extension DiffableDataSourceSnapshot { - /// Container for sorting. - struct IndexedID: Hashable { - + /// A fully indexed Node representation for sorting / diffing. Contains an `indexPath`. + struct IndexedNode: Hashable { /// Item identifier. let itemId: ItemID @@ -274,46 +351,49 @@ extension DiffableDataSourceSnapshot { let parentId: ItemID? /// Full path to the item. - let itemPath: IndexPath + let indexPath: IndexPath /// Only IDs should be equal. static func == (lhs: Self, rhs: Self) -> Bool { - lhs.itemId == rhs.itemId + lhs.itemId == rhs.itemId && lhs.parentId == rhs.parentId } /// Only ID means as hash. func hash(into hasher: inout Hasher) { hasher.combine(itemId) + hasher.combine(parentId) } } - /// Identifiers of stored items sorted from top to bottom. - func indexedIds() -> [IndexedID] { - var result: [IndexedID] = [] - enumerateItemIds { indexedId in - result.append(indexedId) + /// A linear sorted list, from top to bottom, of indexed nodes (i.e. nodes that include an `IndexPath`) + func getSortedIndexedNodes() -> [IndexedNode] { + var result: [IndexedNode] = [] + enumerateIndexedNodes { indexedNode in + result.append(indexedNode) } return result } /// Returns an identifier of the stored item if available. /// - Parameter item: Stored item. - func idForItem(_ item: Item) -> ItemID? { - idsForItems[item] + func getIDForItem(_ item: Item) -> ItemID? { + mapItemToID[item] } /// Returns a stored item for the given identifier if available. /// - Parameter id: Identifier of the item to return. - func itemForId(_ id: ItemID) -> Item? { - itemsForIds[id] + public func getItemForID(_ id: ItemID) -> Item? { + mapIDToItem[id] } /// Returns identifiers of children for the given parent identifier. /// - Parameter parentId: Pass nil to retrieve root item identifiers. - func childIdsOfItemWithId(_ parentId: ItemID?) -> [ItemID] { - guard let parentNode = parentId.flatMap(nodeForId) else { return rootIds } - return parentNode.children + func getChildIDsForParentID(_ parentId: ItemID?) -> [ItemID] { + if let parentNode = parentId.flatMap(getNodeForID) { + return parentNode.childrenIDs + } + return rootIds } } @@ -323,33 +403,42 @@ private extension DiffableDataSourceSnapshot { /// Returns a node for the given identifier if available. /// - Parameter id: Identifier of the node to return. - private func nodeForId(_ id: ItemID) -> Node? { - nodesForIds[id] + private func getNodeForID(_ id: ItemID) -> Node? { + mapIDToNode[id] } - /// Returns true if this snapshot does not have any passed items. - /// - Parameter newItems: New items not yet added to the snapshot. - func validateNewItems(_ newItems: [Item]) -> Bool { - guard Set(newItems).count == newItems.count else { - os_log(.error, log: errors, "Repeating items cannot be added") + /// Returns `true` if this snapshot does not have any passed items. + /// - Parameter items: Items not yet added to the snapshot. + func validateItemsNotFound(_ items: [Item]) -> Bool { + // Items must all be distinct + guard Set(items).count == items.count else { +#if DEBUG + print("Non-unique items cannot be added") + #endif return false } - let existingIds = newItems.compactMap(idForItem) - guard existingIds.isEmpty else { - let ids = existingIds.map(\.uuidString).joined(separator: ", ") - os_log(.error, log: errors, "Items with IDs “%s” have already been added", ids) + + let existingItems = items.map { $0.id }.compactMap(getItemForID) + guard existingItems.isEmpty else { + let ids = existingItems.map { $0.id }.joined(separator: ", ") +#if DEBUG + print("Items with IDs “\(ids)” have already been added") + #endif return false } return true } - /// Returns true if this snapshot has got all passed items. - /// - Parameter existingItems: Items already added to the snapshot. - func validateExistingItems(_ existingItems: Set) -> Bool { - let missingItems = existingItems.subtracting(idsForItems.keys) + /// Returns `true` if this snapshot has got all passed items. + /// - Parameter items: Items to validate + func contains(_ items: [Item]) -> Bool { + let missingItems = Set(items.map{ $0.id }).subtracting(mapItemToID.values) + guard missingItems.isEmpty else { let strings = missingItems.map(String.init(describing:)).joined(separator: ", ") - os_log(.error, log: errors, "Items [%s] have not been added", strings) +#if DEBUG + print("Items [\(strings)] have not been added") + #endif return false } return true @@ -357,55 +446,113 @@ private extension DiffableDataSourceSnapshot { /// Recursively goes through the whole tree and runs a callback with node. /// - Parameter block: Callback for every node in the tree. - /// - Parameter indexedId: Container for sorting. - func enumerateItemIds(using block: (_ indexedId: IndexedID) -> Void) { + /// - Parameter indexedNode: Node representation for sorting / diffing + func enumerateIndexedNodes(using block: (_ indexedNode: IndexedNode) -> Void) { func enumerateChildrenOf(_ parentId: ItemID?, parentPath: IndexPath) { - childIdsOfItemWithId(parentId).enumerated().forEach { offset, itemId in + getChildIDsForParentID(parentId).enumerated().forEach { offset, itemId in let itemPath = parentPath.appending(offset) - let indexedId = IndexedID(itemId: itemId, parentId: parentId, itemPath: itemPath) - block(indexedId) + let indexedNode = IndexedNode(itemId: itemId, parentId: parentId, indexPath: itemPath) + + block(indexedNode) + enumerateChildrenOf(itemId, parentPath: itemPath) } } enumerateChildrenOf(nil, parentPath: .init()) } - /// Inserts items next to the target item using a calculator for the insertion index. + /// Inserts items next to a target item given a target position **OR** inserts (appends) items into a parent item. /// - Parameter newItems: The list of items to insert. - /// - Parameter targetItem: The target item added to the snapshot before. - /// - Parameter indexFrom: Calculator for the insertion index. - /// - Parameter targetIndex: Current index of the target item. + /// - Parameter targetItem: The target item added to the snapshot before. If this is given, `parentItem` must be `nil` + /// - Parameter position: Target position + /// - Parameter parentItem: Append to a parent item. If this is given, `targetItem` should be `nil`. /// - Returns: False if items cannot be inserted e.g. because some of them are already in the snapshot. - mutating func insertItems(_ newItems: [Item], aroundItem targetItem: Item, using indexFrom: (_ targetIndex: Int) -> Int) -> Bool { - guard validateNewItems(newItems) else { return false } - guard let targetItemId = idForItem(targetItem), let targetNode = nodeForId(targetItemId) else { - os_log(.error, log: errors, "Cannot find item “%s”", String(describing: targetItem)) + mutating func insertItems(_ newItems: [Item], nextTo targetItem: Item? = nil, atPosition position: NodePosition = .before, into parentItemToAppendTo: Item? = nil) -> Bool { + guard validateItemsNotFound(newItems) else { return false } - guard let parentId = targetNode.parent else { - let newIds = newItems.map { newItem -> ItemID in - let newId = ItemID() - itemsForIds[newId] = newItem - idsForItems[newItem] = newId - nodesForIds[newId] = .init(parent: nil, children: []) - return newId - } - let targetIndex = rootIds.firstIndex(of: targetItemId).unsafelyUnwrapped - let insertionIndex = indexFrom(targetIndex) - rootIds.insert(contentsOf: newIds, at: insertionIndex) - return true + + if targetItem != nil && parentItemToAppendTo != nil { + fatalError("Cannot pass both a target item to insert next to, as well as a parent item to append to. Must used one of the two.") } + + // MARK: Appending + // Find the parent node if adding to a parent + var appendToParentId = parentItemToAppendTo.flatMap(getIDForItem) + var appendToParentNode = appendToParentId.flatMap(getNodeForID) + + // Ignore if parent item (to append to) passed but not found + if parentItemToAppendTo != nil && appendToParentNode == nil { +#if DEBUG + print("insertItems - Cannot find parent item \(String(describing: parentItemToAppendTo))") + #endif + return false + } + + // MARK: Inserting + let targetItemId = targetItem.flatMap(getIDForItem) + let targetItemNode = targetItemId.flatMap(getNodeForID) + + if targetItem != nil && targetItemNode == nil { +#if DEBUG + print("Cannot find item \(String(describing: targetItem))") + #endif + return false + } + + // If we're inserting next to an item, update the parent we need to use + if let targetItemNode = targetItemNode { + appendToParentId = targetItemNode.parentID + appendToParentNode = appendToParentId.flatMap(getNodeForID) + } + + + // Shared logic + // Update our maps let newIds = newItems.map { newItem -> ItemID in - let newId = ItemID() - itemsForIds[newId] = newItem - idsForItems[newItem] = newId - nodesForIds[newId] = .init(parent: parentId, children: []) + let newId = newItem.id + mapIDToItem[newId] = newItem + mapItemToID[newItem] = newId + + // Create a node + mapIDToNode[newId] = .init(itemID: newId, parentID: appendToParentId, childrenIDs: []) + return newId } - var parentNode = nodeForId(parentId).unsafelyUnwrapped - let targetIndex = parentNode.children.firstIndex(of: targetItemId).unsafelyUnwrapped - parentNode.children.insert(contentsOf: newIds, at: indexFrom(targetIndex)) - nodesForIds[parentId] = parentNode + + // Inserting? + if let targetItemId = targetItemId { + + // Inserting into a parent? + if let parentId = appendToParentId, appendToParentNode != nil { + let targetIndex = appendToParentNode!.childrenIDs.firstIndex(of: targetItemId) ?? .zero + let insertionIndex = position == .before ? targetIndex : targetIndex + 1 + + appendToParentNode!.childrenIDs.insert(contentsOf: newIds, at: insertionIndex) + + // Update the parent node map + mapIDToNode[parentId] = appendToParentNode! + } + else { + let targetIndex = rootIds.firstIndex(of: targetItemId).unsafelyUnwrapped + let insertionIndex = position == .before ? targetIndex : targetIndex + 1 + rootIds.insert(contentsOf: newIds, at: insertionIndex) + } + } + else { + // Apppend IDs + if let parentId = appendToParentId, appendToParentNode != nil { + appendToParentNode!.childrenIDs.append(contentsOf: newIds) + + // Update the parent node map + mapIDToNode[parentId] = appendToParentNode! + } + else { + // No parent, adding to the root + rootIds.append(contentsOf: newIds) + } + } + return true } @@ -415,40 +562,49 @@ private extension DiffableDataSourceSnapshot { /// - Parameter indexFrom: Calculator for the insertion index. /// - Parameter targetIndex: Current index of the target item. /// - Returns: False if the item cannot be moved e.g. because it’s a parent of the target item. - mutating func moveItem(_ item: Item, aroundItem targetItem: Item, using indexFrom: (_ targetIndex: Int) -> Int) -> Bool { - guard canMoveItem(item, aroundItem: targetItem) else { return false } + mutating func moveItem(_ item: Item, nextTo targetItem: Item, atPosition position: NodePosition = .before) -> Bool { + if position == .on && !canMoveItem(item, into: targetItem) { + return false + } + else if !canMoveItem(item, nextTo: targetItem) { + return false + } // Remove item from old parent - let itemId = idForItem(item).unsafelyUnwrapped - var itemNode = nodeForId(itemId).unsafelyUnwrapped - if let oldParentId = itemNode.parent { - var oldParentNode = nodeForId(oldParentId).unsafelyUnwrapped - oldParentNode.children.removeAll { $0 == itemId } - nodesForIds[oldParentId] = oldParentNode + let movingItemId = getIDForItem(item).unsafelyUnwrapped + var movingItemNode = getNodeForID(movingItemId).unsafelyUnwrapped + if let oldParentId = movingItemNode.parentID { + var oldParentNode = getNodeForID(oldParentId).unsafelyUnwrapped + oldParentNode.childrenIDs.removeAll { $0 == movingItemId } + mapIDToNode[oldParentId] = oldParentNode } else { - rootIds.removeAll { $0 == itemId } + rootIds.removeAll { $0 == movingItemId } } // Insert item into new parent - let targetItemId = idForItem(targetItem).unsafelyUnwrapped - let targetItemNode = nodeForId(targetItemId).unsafelyUnwrapped - if let newParentId = targetItemNode.parent { - itemNode.parent = newParentId - var newParentNode = nodeForId(newParentId).unsafelyUnwrapped - let targetIndex = newParentNode.children.firstIndex(of: targetItemId).unsafelyUnwrapped - let insertionIndex = indexFrom(targetIndex) - newParentNode.children.insert(itemId, at: insertionIndex) - nodesForIds[newParentId] = newParentNode + let targetItemId = getIDForItem(targetItem).unsafelyUnwrapped + let targetItemNode = getNodeForID(targetItemId).unsafelyUnwrapped + + // Use target as the new parent if moving into target, else use target's parent + let newParentId = position == .on ? targetItemId : targetItemNode.parentID + + if let newParentId = newParentId { + movingItemNode.parentID = newParentId + + var newParentNode = getNodeForID(newParentId).unsafelyUnwrapped + let targetIndex = newParentNode.childrenIDs.firstIndex(of: targetItemId) ?? .zero + let insertionIndex = position == .before || position == .on ? targetIndex : targetIndex + 1 + + newParentNode.childrenIDs.insert(movingItemId, at: insertionIndex) + mapIDToNode[newParentId] = newParentNode } else { - itemNode.parent = nil + movingItemNode.parentID = nil + let targetIndex = rootIds.firstIndex(of: targetItemId).unsafelyUnwrapped - let insertionIndex = indexFrom(targetIndex) - rootIds.insert(itemId, at: insertionIndex) + let insertionIndex = position == .before ? targetIndex : targetIndex + 1 + rootIds.insert(movingItemId, at: insertionIndex) } - nodesForIds[itemId] = itemNode + mapIDToNode[movingItemId] = movingItemNode return true } } - -/// Local handle for logging errors. -private let errors: OSLog = .init(subsystem: "OutlineViewDiffableDataSource", category: "DiffableDataSourceSnapshot") diff --git a/Sources/OutlineViewDiffableDataSource.swift b/Sources/OutlineViewDiffableDataSource.swift index 329ee40..fbb8030 100644 --- a/Sources/OutlineViewDiffableDataSource.swift +++ b/Sources/OutlineViewDiffableDataSource.swift @@ -1,37 +1,37 @@ import AppKit /// Offers a diffable interface for providing content for `NSOutlineView`. It automatically performs insertions, deletions, and moves necessary to transition from one model-state snapshot to another. +/// This relies on a `DiffableDataSourceSnapshot`, which serves as a shadow data source, describing each node of the tree. open class OutlineViewDiffableDataSource: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { - + /// Shortcut for outline view objects. public typealias Item = DiffableDataSourceSnapshot.Item - + /// Tree with data. - private var diffableSnapshot: DiffableDataSourceSnapshot - + private var currentSnapshot: DiffableDataSourceSnapshot + /// Associated outline view. private weak var outlineView: NSOutlineView? - + /// Re-targeting API for drag-n-drop. public struct ProposedDrop { - /// Dropping type. public enum `Type` { case on, before, after } - + /// Dropping type. public var type: Type - + /// Target item. public var targetItem: Item - + /// Items being dragged. public var draggedItems: [Item] - + /// Proposed operation. public var operation: NSDragOperation - + /// Creates a new item drag-n-drop “proposal”. public init(type: Type, targetItem: Item, draggedItems: [Item], operation: NSDragOperation) { self.type = type @@ -40,61 +40,67 @@ open class OutlineViewDiffableDataSource: NSObject, NSOutlineViewDataSource, NSO self.operation = operation } } - + /// Callbacks for drag-n-drop. public typealias DraggingHandlers = ( validateDrop: (_ sender: OutlineViewDiffableDataSource, _ drop: ProposedDrop) -> ProposedDrop?, acceptDrop: (_ sender: OutlineViewDiffableDataSource, _ drop: ProposedDrop) -> Bool ) - + /// Assign non-nil value to enable drag-n-drop. public var draggingHandlers: DraggingHandlers? - + /// Creates a new data source as well as a delegate for the given outline view. /// - Parameter outlineView: Outline view without a data source and without a delegate. public init(outlineView: NSOutlineView) { - self.diffableSnapshot = .init() - + self.currentSnapshot = .init() + super.init() - + precondition(outlineView.dataSource == nil) precondition(outlineView.delegate == nil) - outlineView.dataSource = self outlineView.delegate = self - outlineView.usesAutomaticRowHeights = true self.outlineView = outlineView outlineView.registerForDraggedTypes(outlineView.registeredDraggedTypes + [.itemID]) } - + deinit { self.outlineView?.dataSource = nil self.outlineView?.delegate = nil } - + // MARK: - NSOutlineViewDataSource - + /// Uses diffable snapshot. public func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { - diffableSnapshot.numberOfItems(in: item as? Item) + let count = currentSnapshot.numberOfItems(in: item as? Item) + return count } - + /// Uses diffable snapshot. public func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { - diffableSnapshot.childrenOfItem(item as? Item)[index] + let childItem = currentSnapshot.childrenOfItem(item as? Item)[index] + return childItem } - + /// Uses diffable snapshot. public func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { guard let item = item as? OutlineViewItem else { return true } - return item.isExpandable + return item.isExpandable && currentSnapshot.childrenOfItem(item).count > 0 } - + + // MARK: Drag & Drop + /// Enables dragging for items which return Pasteboard representation. public func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? { - guard let item = item as? Item, let itemId = diffableSnapshot.idForItem(item) else { return nil } - return NSPasteboardItem(pasteboardPropertyList: itemId.uuidString, ofType: .itemID) + guard let item = item as? Item, + let itemId = currentSnapshot.getIDForItem(item) else { return nil } + + let pasteboardItem = NSPasteboardItem() + pasteboardItem.setString(itemId, forType: .itemID) + return pasteboardItem } - + /// This override is necessary to disable special mouse down behavior in the outline view. public override func responds(to aSelector: Selector?) -> Bool { if draggingHandlers == nil && aSelector == #selector(outlineView(_:pasteboardWriterForItem:)) { @@ -103,90 +109,98 @@ open class OutlineViewDiffableDataSource: NSObject, NSOutlineViewDataSource, NSO return super.responds(to: aSelector) } } - + /// Enables drag-n-drop validation. public func outlineView( _ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int ) -> NSDragOperation { - + // Calculate proposed change if allowed and take decision from the client handler guard let proposedDrop = proposedDrop(using: info, proposedItem: item, proposedChildIndex: index), - let handlers = draggingHandlers, let drop = handlers.validateDrop(self, proposedDrop) else { return [] } + let handlers = draggingHandlers, let drop = handlers.validateDrop(self, proposedDrop) else { return [] } switch drop.type { - - // Re-target drop on item - case .on: - if drop.operation.isEmpty == false { - outlineView.setDropItem(drop.targetItem, dropChildIndex: NSOutlineViewDropOnItemIndex) - } - return drop.operation - - // Re-target drop before item - case .before: - if drop.operation.isEmpty == false, let childIndex = diffableSnapshot.indexOfItem(drop.targetItem) { - let parentItem = diffableSnapshot.parentOfItem(drop.targetItem) - outlineView.setDropItem(parentItem, dropChildIndex: childIndex) - } - return drop.operation - - // Re-target drop after item - case .after: - if drop.operation.isEmpty == false, let childIndex = diffableSnapshot.indexOfItem(drop.targetItem) { - let parentItem = diffableSnapshot.parentOfItem(drop.targetItem) - outlineView.setDropItem(parentItem, dropChildIndex: childIndex + 1) - } - return drop.operation + + // Re-target drop on item + case .on: + if drop.operation.isEmpty == false { + outlineView.setDropItem(drop.targetItem, dropChildIndex: NSOutlineViewDropOnItemIndex) + } + return drop.operation + + // Re-target drop before item + case .before: + if drop.operation.isEmpty == false, let childIndex = currentSnapshot.indexOfItem(drop.targetItem) { + let parentItem = currentSnapshot.parentOfItem(drop.targetItem) + outlineView.setDropItem(parentItem, dropChildIndex: childIndex) + } + return drop.operation + + // Re-target drop after item + case .after: + if drop.operation.isEmpty == false, let childIndex = currentSnapshot.indexOfItem(drop.targetItem) { + let parentItem = currentSnapshot.parentOfItem(drop.targetItem) + outlineView.setDropItem(parentItem, dropChildIndex: childIndex + 1) + } + return drop.operation } } - + /// Accepts drag-n-drop after validation. public func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool { guard let drop = proposedDrop(using: info, proposedItem: item, proposedChildIndex: index), let handlers = draggingHandlers else { return false } return handlers.acceptDrop(self, drop) } - + // MARK: - NSOutlineViewDelegate - + /// Enables special appearance for group items. public func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool { guard let item = item as? OutlineViewItem else { return false } return item.isGroup } - + /// Creates a cell view for the given item, public func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { guard let item = item as? OutlineViewItem else { return nil } + let cellViewType = item.cellViewType(for: tableColumn) let cellViewTypeIdentifier = NSUserInterfaceItemIdentifier(NSStringFromClass(cellViewType)) let cachedCellView = outlineView.makeView(withIdentifier: cellViewTypeIdentifier, owner: self) + let cellView = cachedCellView as? NSTableCellView ?? { let newCellView = cellViewType.init() newCellView.identifier = cellViewTypeIdentifier return newCellView }() + cellView.objectValue = item + return cellView } - + /// Filters selectable items. public func outlineView(_ outlineView: NSOutlineView, selectionIndexesForProposedSelection proposedSelectionIndexes: IndexSet) -> IndexSet { proposedSelectionIndexes.filteredIndexSet { guard let item = outlineView.item(atRow: $0) as? OutlineViewItem else { return false } + return item.isSelectable } } - + /// Creates a row view for the given item, public func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { guard let item = item as? OutlineViewItem else { return nil } + let rowViewType = item.rowViewType ?? NSTableRowView.self let rowViewTypeIdentifier = NSUserInterfaceItemIdentifier(NSStringFromClass(rowViewType)) let cachedRowView = outlineView.makeView(withIdentifier: rowViewTypeIdentifier, owner: self) + let rowView = cachedRowView as? NSTableRowView ?? { let newRowView = rowViewType.init() newRowView.identifier = rowViewTypeIdentifier return newRowView }() + return rowView } } @@ -194,134 +208,178 @@ open class OutlineViewDiffableDataSource: NSObject, NSOutlineViewDataSource, NSO // MARK: - Public API public extension OutlineViewDiffableDataSource { - - /// Returns current state of the data source. This property is thread-safe. + + /// Returns current state of the data source. func snapshot() -> DiffableDataSourceSnapshot { - if Thread.isMainThread { - return diffableSnapshot - } else { - return DispatchQueue.main.sync { diffableSnapshot } - } + assert(Thread.isMainThread, "Should be called on the main thread") + + return currentSnapshot } - - /// Applies the given snapshot to this data source in background. + + /// Performs a `reloadData` + func reloadData() { + // ensure data source is set + outlineView?.dataSource = self + outlineView?.reloadData() + } + + /// Applies the given snapshot to this data source. /// - Parameter snapshot: Snapshot with new data. /// - Parameter animatingDifferences: Pass false to disable animations. /// - Parameter completionHandler: Called asynchronously in the main thread when the new snapshot is applied. func applySnapshot(_ snapshot: DiffableDataSourceSnapshot, animatingDifferences: Bool, completionHandler: (() -> Void)? = nil) { - + assert(Thread.isMainThread, "Should be called on the main thread") + // Source and Destination let oldSnapshot = self.snapshot() let newSnapshot = snapshot - + + // Update our final snapshot before we animate + currentSnapshot = newSnapshot + // Apply changes immediately if animation is disabled - guard animatingDifferences else { - func apply() { - diffableSnapshot = newSnapshot - outlineView?.reloadData() - completionHandler?() - } - if Thread.isMainThread { - apply() - } else { - DispatchQueue.main.async(execute: apply) - } + // When a data source is not set, reload the first time + guard animatingDifferences, let _ = outlineView?.dataSource else { + reloadData() + completionHandler?() return } - + // Calculate changes - let oldIndexedIds = oldSnapshot.indexedIds() - let newIndexedIds = newSnapshot.indexedIds() - let difference = newIndexedIds.difference(from: oldIndexedIds) + let oldIndexedNodes = oldSnapshot.getSortedIndexedNodes() + let newIndexedNodes = newSnapshot.getSortedIndexedNodes() + + let difference = newIndexedNodes.changes(from: oldIndexedNodes) + + // Some threshold - won't make sense to animate this many changes + let changeThreshold = 300 + let totalChanges = difference.insertions.count + difference.removals.count + + guard totalChanges < changeThreshold else { + reloadData() + completionHandler?() + return + } + let differenceWithMoves = difference.inferringMoves() - - // Apply changes changes - func apply() { - differenceWithMoves.forEach { - switch $0 { - - case .insert(_, let inserted, let indexBefore): - if let indexBefore = indexBefore { - // Move outline view item - let oldIndexedItemId = oldIndexedIds[indexBefore] - let oldParent = oldIndexedItemId.parentId.flatMap(oldSnapshot.itemForId) - let oldIndex = oldIndexedItemId.itemPath.last.unsafelyUnwrapped - let newParent = inserted.parentId.flatMap(newSnapshot.itemForId) - let newIndex = inserted.itemPath.last.unsafelyUnwrapped - outlineView?.moveItem(at: oldIndex, inParent: oldParent, to: newIndex, inParent: newParent) - - } else { - // Insert outline view item - let insertionIndexes = IndexSet(integer: inserted.itemPath.last.unsafelyUnwrapped) - let parentItem = inserted.parentId.flatMap(newSnapshot.itemForId) - outlineView?.insertItems(at: insertionIndexes, inParent: parentItem, withAnimation: [.effectFade, .slideDown]) - } - - case .remove(_, let before, let indexAfter): - if indexAfter == nil { - // Delete outline view item - let deletionIndexes = IndexSet(integer: before.itemPath.last.unsafelyUnwrapped) - let oldParentItem = before.parentId.flatMap(oldSnapshot.itemForId) - outlineView?.removeItems(at: deletionIndexes, inParent: oldParentItem, withAnimation: [.effectFade, .slideDown]) - } + + // Animate with completion + NSAnimationContext.runAnimationGroup({ context in + context.duration = animationDuration + + self.outlineView?.beginUpdates() + + // Collect a list of parent items that had their children added / removed. Outline view will not automatically + // reload the parent item (and more importantly its expansion state). Once we're done, we'll walk over this + // list and reload any node that's still there + var parentItemsToReload = Set() + + // Apply changes. Called from within `beginUpdates` and `endUpdates` + differenceWithMoves.forEach { move in + switch move { + + case .insert(_, let inserted, let previousOffset): + if let previousOffset = previousOffset { + // Move outline view item from old to new position + // previousOffset here is the offset in our flattened list of indexed IDs + let oldIndexedNode = oldIndexedNodes[previousOffset] + + let oldParentItem = oldIndexedNode.parentId.flatMap(oldSnapshot.getItemForID) + let oldIndex = oldIndexedNode.indexPath.last.unsafelyUnwrapped + + let newParentItem = inserted.parentId.flatMap(newSnapshot.getItemForID) + let newIndex = inserted.indexPath.last.unsafelyUnwrapped + + outlineView?.moveItem(at: oldIndex, inParent: oldParentItem, to: newIndex, inParent: newParentItem) + + if let oldParentItem = oldParentItem { + parentItemsToReload.insert(oldParentItem.id) + } + if let newParentItem = newParentItem { + parentItemsToReload.insert(newParentItem.id) + } + } + else { + // Insert outline view item + let insertionIndex = IndexSet(integer: inserted.indexPath.last.unsafelyUnwrapped) + let parentItem = inserted.parentId.flatMap(newSnapshot.getItemForID) + + // Before inserting an item, reload the parent item to avoid glitches where the expansion arrow would + // overlap the item + outlineView?.reloadItem(parentItem, reloadChildren: false) + outlineView?.insertItems(at: insertionIndex, inParent: parentItem, withAnimation: [.effectFade, .slideDown]) + } + + case .remove(_, let before, let associatedWith): + // A non-nil associatedWith value is the offset of the complementary change. + // A missing associatedWith value indicates this item was removed entirely + if associatedWith == nil { + // Delete outline view item from its parent + let deletionIndex = IndexSet(integer: before.indexPath.last.unsafelyUnwrapped) + let oldParentItem = before.parentId.flatMap(oldSnapshot.getItemForID) + + outlineView?.removeItems(at: deletionIndex, inParent: oldParentItem, withAnimation: [.effectFade, .slideDown]) + + if let oldParentItem = oldParentItem { + parentItemsToReload.insert(oldParentItem.id) + } + } + else { + // the item moved since it's got a valid "index after". We handle moves in `.insert` so this can be + // ignored + } } } - } - - // Animate with completion - func applyWithAnimation() { - NSAnimationContext.runAnimationGroup({ context in - context.duration = animationDuration - self.outlineView?.beginUpdates() - self.diffableSnapshot = newSnapshot - apply() - self.outlineView?.endUpdates() - }, completionHandler: completionHandler) - } - if Thread.isMainThread { - applyWithAnimation() - } else { - DispatchQueue.main.sync(execute: applyWithAnimation) - } + + // Reload parents now + parentItemsToReload.forEach { parentItemIDToReload in + if let itemInNewSnapshot = newSnapshot.getItemForID(parentItemIDToReload) { + outlineView?.reloadItem(itemInNewSnapshot, reloadChildren: false) + } + } + + self.outlineView?.endUpdates() + }, completionHandler: completionHandler) } } // MARK: - Private API private extension OutlineViewDiffableDataSource { - + /// Calculates proposed drop for the given input. func proposedDrop(using info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> ProposedDrop? { guard let pasteboardItems = info.draggingPasteboard.pasteboardItems, - pasteboardItems.isEmpty == false else { return nil } - + pasteboardItems.isEmpty == false else { return nil } + // Retrieve dragged items let draggedItems: [Item] = pasteboardItems.compactMap { pasteboardItem in - guard let propertyList = pasteboardItem.propertyList(forType: .itemID) as? String, - let itemId = DiffableDataSourceSnapshot.ItemID(uuidString: propertyList) else { return nil } - return diffableSnapshot.itemForId(itemId) + guard let itemId = pasteboardItem.string(forType: .itemID) else { + return nil + } + return currentSnapshot.getItemForID(itemId) } guard draggedItems.count == pasteboardItems.count else { return nil } - + // Drop on the item - let parentItem = item as? NSObject + let parentItem = item as? OutlineViewItem if index == NSOutlineViewDropOnItemIndex { return parentItem.map { .init(type: .on, targetItem: $0, draggedItems: draggedItems, operation: info.draggingSourceOperationMask) } } - + // Drop into the item - let childItems = diffableSnapshot.childrenOfItem(parentItem) + let childItems = currentSnapshot.childrenOfItem(parentItem) guard childItems.isEmpty == false else { return nil } - + // Use “before” or “after” depending on index return index > 0 - ? .init(type: .after, targetItem: childItems[index - 1], draggedItems: draggedItems, operation: info.draggingSourceOperationMask) - : .init(type: .before, targetItem: childItems[index], draggedItems: draggedItems, operation: info.draggingSourceOperationMask) + ? .init(type: .after, targetItem: childItems[index - 1], draggedItems: draggedItems, operation: info.draggingSourceOperationMask) + : .init(type: .before, targetItem: childItems[index], draggedItems: draggedItems, operation: info.draggingSourceOperationMask) } } private extension NSPasteboard.PasteboardType { - + /// Custom dragging type. static let itemID: NSPasteboard.PasteboardType = .init("OutlineViewDiffableDataSource.ItemID") } diff --git a/Sources/OutlineViewItem.swift b/Sources/OutlineViewItem.swift index b1c53d2..c58892c 100644 --- a/Sources/OutlineViewItem.swift +++ b/Sources/OutlineViewItem.swift @@ -1,41 +1,50 @@ import AppKit /// Outline view cannot work with structs, identifiers are necessary for diffing and diagnostics, hashing is necessary for supporting drag-n-drop and expand-collapse. -public protocol OutlineViewItem: NSObjectProtocol { - - /// Used to allow or deny selection for this item. - var isSelectable: Bool { get } - - /// Used to show or hide the expansion arrow. - var isExpandable: Bool { get } - - /// Can be used for root items with ‘Show’ and ‘Hide’ buttons. - var isGroup: Bool { get } - - /// Called to create a cell view of the custom type. +open class OutlineViewItem: NSObject { + + /// Unique identifier for diffing. + public let id: String + + /// Used to allow or deny selection for this item. Any item can be selected by default. + open var isSelectable = true + + /// Used to show or hide the expansion arrow. Any node with a child is expandable by default. Setting this to `false` will disable expansion irrespectively. + open var isExpandable = true + + /// Can be used for root items with ‘Show’ and ‘Hide’ buttons. Not a group item by default. + open var isGroup: Bool { false } + + public init(id: String) { + self.id = id + + super.init() + } + + /// Called to create a cell view of the custom type. Returns an empty cell view by default. /// - Parameter tableColumn: Optional column that the view will be inserted into. - func cellViewType(for tableColumn: NSTableColumn?) -> NSTableCellView.Type - - /// Called to create a row view of the custom type. - var rowViewType: NSTableRowView.Type? { get } + open func cellViewType(for tableColumn: NSTableColumn?) -> NSTableCellView.Type { + NSTableCellView.self + } + + /// Called to create a row view of the custom type. Use a standard row view type by default. + open var rowViewType: NSTableRowView.Type? { nil } + + open override var hash: Int { id.hash } + + open override func isEqual(_ object: Any?) -> Bool { + guard let otherItem = object as? OutlineViewItem else { return false } + return otherItem.id == id + } } -// MARK: - - -public extension OutlineViewItem { - - /// Any item can be selected by default. - var isSelectable: Bool { true } - - /// Any item can be expanded and collapsed by default. - var isExpandable: Bool { true } - - /// No group items by default. - var isGroup: Bool { false } - - /// Returns an empty cell view by default. - func cellViewType(for tableColumn: NSTableColumn?) -> NSTableCellView.Type { NSTableCellView.self } - - /// Use a standard row view type by default. - var rowViewType: NSTableRowView.Type? { nil } +open class GroupOutlineViewItem: OutlineViewItem { + /// Show as Group. + public override var isGroup: Bool { true } + + /// Deny selection. + open override var isSelectable: Bool { + get { false } + set { } + } } diff --git a/Tests/DiffableDataSourceSnapshotTests.swift b/Tests/DiffableDataSourceSnapshotTests.swift index 4bd276d..6ab91e0 100644 --- a/Tests/DiffableDataSourceSnapshotTests.swift +++ b/Tests/DiffableDataSourceSnapshotTests.swift @@ -1,17 +1,9 @@ import XCTest import OutlineViewDiffableDataSource -private class SnapshotItem: NSObject, OutlineViewItem { - let id: String - init(id: String) { self.id = id } - override var hash: Int { id.hash } - override func isEqual(_ object: Any?) -> Bool { - guard let snapshotItem = object as? SnapshotItem else { return false } - return snapshotItem.id == id - } -} +private class SnapshotItem: OutlineViewItem {} -extension Collection where Element == NSObject { +extension Collection where Element == OutlineViewItem { func snapshotItemIds() -> [String] { compactMap { $0 as? SnapshotItem }.map(\.id) } @@ -168,7 +160,7 @@ final class DiffableDataSourceSnapshotTests: XCTestCase { let a = SnapshotItem(id: "a") let b = SnapshotItem(id: "b") - // WHEN: You try to use one the them as parent + // WHEN: You try to use one of the them as parent var snapshot: DiffableDataSourceSnapshot = .init() XCTAssertFalse(snapshot.appendItems([b], into: a)) @@ -422,11 +414,11 @@ final class DiffableDataSourceSnapshotTests: XCTestCase { XCTAssertTrue(snapshot.appendItems([b1, a2], into: b)) // THEN: Only some items can be moved - XCTAssertFalse(snapshot.canMoveItem(b, aroundItem: b)) - XCTAssertFalse(snapshot.canMoveItem(c, aroundItem: d)) - XCTAssertFalse(snapshot.canMoveItem(d, aroundItem: c)) - XCTAssertFalse(snapshot.canMoveItem(b, aroundItem: a2)) - XCTAssertTrue(snapshot.canMoveItem(b1, aroundItem: a3)) + XCTAssertFalse(snapshot.canMoveItem(b, nextTo: b)) + XCTAssertFalse(snapshot.canMoveItem(c, nextTo: d)) + XCTAssertFalse(snapshot.canMoveItem(d, nextTo: c)) + XCTAssertFalse(snapshot.canMoveItem(b, nextTo: a2)) + XCTAssertTrue(snapshot.canMoveItem(b1, nextTo: a3)) // WHEN: You move some items XCTAssertFalse(snapshot.moveItem(d, beforeItem: c)) @@ -441,6 +433,32 @@ final class DiffableDataSourceSnapshotTests: XCTestCase { XCTAssertEqual(snapshot.childrenOfItem(b).compactMap { $0 as? SnapshotItem }, [b1, b2]) XCTAssertTrue(snapshot.childrenOfItem(c).isEmpty) } + + func testMovingWithChildren() { + + // GIVEN: Some items + let cars = SnapshotItem(id: "Cars") + let models = SnapshotItem(id: "Models") + let camry = SnapshotItem(id: "Toyota Camry") + let mycars = SnapshotItem(id: "My Cars") + let honda = SnapshotItem(id: "Honda") + + // WHEN: You insert them + var snapshot: DiffableDataSourceSnapshot = .init() + XCTAssertTrue(snapshot.appendItems([cars, mycars])) + XCTAssertTrue(snapshot.appendItems([models], into: cars)) + XCTAssertTrue(snapshot.appendItems([camry], into: models)) + XCTAssertTrue(snapshot.appendItems([honda], into: mycars)) + + // WHEN: You move some items + XCTAssertTrue(snapshot.moveItem(models, beforeItem: honda)) + + // THEN: The snapshot is correct + XCTAssertEqual(snapshot.childrenOfItem(nil).compactMap { $0 as? SnapshotItem }, [cars, mycars]) + XCTAssertTrue(snapshot.childrenOfItem(cars).isEmpty) + XCTAssertEqual(snapshot.childrenOfItem(mycars).compactMap { $0 as? SnapshotItem }, [models, honda]) + XCTAssertEqual(snapshot.childrenOfItem(models).compactMap { $0 as? SnapshotItem }, [camry]) + } func testMovingNonExisting() { diff --git a/Tests/OutlineViewDiffableDataSource.swift b/Tests/OutlineViewDiffableDataSource.swift index 3e1f222..4c2c468 100644 --- a/Tests/OutlineViewDiffableDataSource.swift +++ b/Tests/OutlineViewDiffableDataSource.swift @@ -3,14 +3,19 @@ import OutlineViewDiffableDataSource final class OutlineViewDiffableDataSourceTests: XCTestCase { - private class OutlineItem: NSObject, OutlineViewItem { + private class OutlineItem: OutlineViewItem { let title: String - init(title: String) { self.title = title } - override var hash: Int { title.hash } - override func isEqual(_ object: Any?) -> Bool { - guard let outlintItem = object as? OutlineItem else { return false } - return outlintItem.title == title + + init(id: String, title: String) { + self.title = title + super.init(id: id) + } + + convenience init(title: String) { + self.init(id: title, title: title) } + + override var hash: Int { title.hash } } private lazy var outlineView: NSOutlineView = { @@ -25,7 +30,8 @@ final class OutlineViewDiffableDataSourceTests: XCTestCase { // GIVEN: Empty data source let dataSource: OutlineViewDiffableDataSource = .init(outlineView: outlineView) - XCTAssertTrue(outlineView.dataSource === dataSource) + // Data source is only set when applying a snapshot. We perform a reloadData the first time. + XCTAssertTrue(outlineView.dataSource == nil) // WHEN: Outline view is loaded outlineView.layoutSubtreeIfNeeded() @@ -85,6 +91,8 @@ final class OutlineViewDiffableDataSourceTests: XCTestCase { // THEN: Outline view is updated outlineView.expandItem(nil, expandChildren: true) + outlineView.layoutSubtreeIfNeeded() + let expandedItems = (0 ..< outlineView.numberOfRows) .map(outlineView.item(atRow:)).compactMap { $0 as? OutlineItem } XCTAssertEqual(expandedItems.map(\.title), [a, a2, a3, b, b1].map(\.title)) @@ -123,6 +131,8 @@ final class OutlineViewDiffableDataSourceTests: XCTestCase { // THEN: Outline view is updated outlineView.expandItem(nil, expandChildren: true) + outlineView.layoutSubtreeIfNeeded() + let expandedItems = (0 ..< outlineView.numberOfRows) .map(outlineView.item(atRow:)).compactMap { $0 as? OutlineItem } XCTAssertEqual(expandedItems.map(\.title), [a, a1, a2, a3, b, b1, b2].map(\.title))