Skip to content

Commit 9304960

Browse files
Merge pull request #3 from dzmitry-antonenka/feature/moveItem
feat(reordering): added move item API
2 parents c4701f0 + 4f1ad36 commit 9304960

File tree

9 files changed

+408
-40
lines changed

9 files changed

+408
-40
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// swift-tools-version:5.3
1+
// swift-tools-version: 5.7.1
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

44
import PackageDescription

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ Add/Insert/Delete - quick helper methods:
4949
addItems(items, itemChildren: { $0.subitems }, to: listTreeDataSource)
5050
```
5151

52-
Add/Insert/Delete - More grannular control:
52+
Fold (inorder traversal + map into final result):
53+
Use case: changes were made and we need final tree.
54+
```
55+
let folded = listTreeDataSource.fold(ResultItem(leaf:), cons: ResultItem(item:children:))
56+
```
57+
58+
Add/Insert/Delete/Move - More grannular control:
5359
```
5460
// Append:
5561
listTreeDataSource.append(currentToAdd, to: referenceParent)
@@ -61,6 +67,13 @@ listTreeDataSource.insert([insertionAfterItem], after: existingItem)
6167
// Delete:
6268
listTreeDataSource.delete([itemToDelete])
6369
70+
// Move:
71+
// E.g. user drags `existingNode` into `newParent` subitems with 0 index.
72+
// existingNode = listTreeDataSource.items[sourceIdx];
73+
// newParent = listTreeDataSource.items[dropParentIdx];
74+
// toIndex = drop index in newParent;
75+
listTreeDataSource.move(existingNode, toIndex: 0, inParent: newParent)
76+
6477
// NOTE: Reload data source at the end of changes.
6578
listTreeDataSource.reload()
6679
```

Sources/SwiftListTreeDataSource/FilterableListTreeDataSource.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ open class FilterableListTreeDataSource<ItemIdentifierType>: ListTreeDataSource<
9494
super.append(items, to: parent)
9595
needRebuildAllFlattenedItemStore = true
9696
}
97+
public override func move(_ item: ItemIdentifierType, toIndex: Int, inParent: ItemIdentifierType?) {
98+
super.move(item, toIndex: toIndex, inParent: inParent)
99+
needRebuildAllFlattenedItemStore = true
100+
}
97101
public override func insert(_ items: [ItemIdentifierType], after item: ItemIdentifierType) {
98102
super.insert(items, after: item)
99103
needRebuildAllFlattenedItemStore = true

Sources/SwiftListTreeDataSource/ListTreeDataSource.swift

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ open class TreeItem<Item: Hashable>: Hashable, Identifiable {
2323
self.value = value
2424
self.parent = parent
2525
self.subitems = items
26+
self.updateLevel()
27+
}
28+
29+
func updateLevel() {
30+
// Discussion: can be calculated automatically, but cached for performance purposes.
2631
self.level = level(of: self)
2732
}
28-
33+
2934
public func level(of item: TreeItem<Item>) -> Int {
3035
// Traverse up to next parent to find level. root element has `0` level.
3136
var counter: Int = 0
@@ -36,7 +41,19 @@ open class TreeItem<Item: Hashable>: Hashable, Identifiable {
3641
}
3742
return counter
3843
}
39-
44+
45+
public func fold<Result>(_ leaf: (Item) -> Result, cons: (Item, [Result]) -> Result) -> Result {
46+
switch self.subitems {
47+
case []:
48+
return leaf(self.value)
49+
case let nodes:
50+
return cons(
51+
self.value,
52+
nodes.map { $0.fold(leaf, cons: cons) }
53+
)
54+
}
55+
}
56+
4057
public func allParents(of item: TreeItem<Item>) -> [TreeItem<Item>] {
4158
var parents: [TreeItem<Item>] = []
4259
var currentItem: TreeItem<Item> = item
@@ -84,7 +101,18 @@ open class ListTreeDataSource<ItemIdentifierType> where ItemIdentifierType : Has
84101
func setShownFlatItems(_ items: [TreeItemType]) {
85102
self.shownFlatItems = items
86103
}
87-
104+
105+
/// Folds created hierarchical store.
106+
/// - Parameters:
107+
/// - leaf: The leaf case
108+
/// - cons: The cons case
109+
/// - Returns: The folded hierarchical store.
110+
public func fold<Result>(_ leaf: (ItemIdentifierType) -> Result, cons: (ItemIdentifierType, [Result]) -> Result) -> [Result] {
111+
backingStore.map { node in
112+
node.fold(leaf, cons: cons)
113+
}
114+
}
115+
88116
/// Adds the array of `items` to specified `parent`.
89117
/// - Parameters:
90118
/// - items: The array of items to add.
@@ -157,7 +185,40 @@ open class ListTreeDataSource<ItemIdentifierType> where ItemIdentifierType : Has
157185
private func cacheTreeItems(_ treeItems: [TreeItemType]) {
158186
treeItems.forEach { lookupTable[$0.value] = $0 }
159187
}
160-
188+
189+
public func move(_ item: ItemIdentifierType, toIndex: Int, inParent newParent: ItemIdentifierType?) {
190+
guard let existingItem = self.lookupTable[item] else { return; }
191+
let toParentExistingItem = newParent.flatMap { self.lookupTable[$0] }
192+
precondition(existingItem != toParentExistingItem, "Can't move item into itself, cycle: item.parent <-> item")
193+
194+
// Unlink from existing `parent` + subitems.
195+
do {
196+
let matching: (TreeItemType) -> Bool = { $0.id == existingItem.id }
197+
if let existingItemParent = existingItem.parent {
198+
existingItemParent.subitems.removeAll(where: matching)
199+
} else {
200+
backingStore.removeAll(where: matching)
201+
}
202+
existingItem.parent = nil
203+
}
204+
205+
// Link new parent + subitems
206+
do {
207+
if let toParentExistingItem {
208+
toParentExistingItem.subitems.insert(existingItem, at: toIndex)
209+
} else {
210+
backingStore.insert(existingItem, at: toIndex)
211+
}
212+
existingItem.parent = toParentExistingItem
213+
}
214+
215+
// Update level
216+
do {
217+
let items = depthFirstFlattened(items: [existingItem])
218+
items.forEach { $0.updateLevel() }
219+
}
220+
}
221+
161222
private func insert(_ items: [ItemIdentifierType], item: ItemIdentifierType, after: Bool) {
162223
func insert(items: [ItemIdentifierType], into insertionArray: inout [TreeItemType],
163224
existingItem: TreeItemType, after: Bool, existingItemParent: TreeItemType?) {

Tests/Shared/Helpers.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import Foundation
44
public func addItems(_ items: [OutlineItem], to snapshot: ListTreeDataSource<OutlineItem>) {
55
addItems(items, itemChildren: { $0.subitems }, to: snapshot)
66
}
7-
7+
public func addItems(_ items: [NodeTestItem], to snapshot: ListTreeDataSource<NodeTestItem>) {
8+
addItems(items, itemChildren: { $0.subitems }, to: snapshot)
9+
}
810
public func depthFirstFlattened(items: [OutlineItem]) -> [OutlineItem] {
911
return depthFirstFlattened(items: items, itemChildren: { $0.subitems })
1012
}

Tests/SwiftListTreeDataSourcePerformanceTests/MockData.swift renamed to Tests/Shared/MockData.swift

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
// Created by Dzmitry Antonenka on 18.04.21.
66
//
77

8-
import TestsShared
98
import Foundation
109

1110
struct DataSet {
@@ -25,7 +24,21 @@ struct DataSet {
2524
// Total elements in tree: (Int) $R0 = 7_174_452, creation time ~17.5 sec on MBP 2019, core i7
2625
return menuItems(targetNestLevel: 14, currentLevel: 0, itemsInSection: 3)
2726
}()
28-
27+
28+
static var mockDataTiny: [OutlineItem] = {
29+
var items: [OutlineItem] = [
30+
OutlineItem(title: "Level 0, item1", subitems: [
31+
OutlineItem(title: "Level 1, item1"),
32+
OutlineItem(title: "Level 1, item2", subitems: [
33+
OutlineItem(title: "Level 2, item1"),
34+
OutlineItem(title: "Level 2, item2")
35+
])
36+
]),
37+
OutlineItem(title: "Level 0, item2")
38+
]
39+
return items
40+
}()
41+
2942
static var mockDataSmall: [OutlineItem] = {
3043
var items: [OutlineItem] = [
3144
OutlineItem(title: "Compositional Layout", subitems: [
@@ -92,16 +105,17 @@ struct DataSet {
92105
}
93106
}
94107

95-
96-
enum MockData {
108+
public enum MockData {
109+
case tiny
97110
case small
98111
case large88K
99112
case large350K
100113
case doubleLarge797K
101114
case extraLarge7_2M
102-
103-
var items: [OutlineItem] {
115+
116+
public var items: [OutlineItem] {
104117
switch self {
118+
case .tiny: return DataSet.mockDataTiny
105119
case .small: return DataSet.mockDataSmall
106120
case .large88K: return DataSet.mockData88K
107121
case .large350K: return DataSet.mockData350K
@@ -111,15 +125,16 @@ enum MockData {
111125
}
112126
}
113127

114-
class DataManager {
115-
static let shared = DataManager()
128+
public class DataManager {
129+
public static let shared = DataManager()
116130

117-
var mockData: MockData { self.mockDataSmall }
131+
public var mockData: MockData { self.mockDataSmall }
118132

119133
// specialized data sets
120-
lazy var mockDataSmall: MockData = .small
121-
lazy var mockDataLarge88K: MockData = .large88K
122-
lazy var mockDataLarge350K: MockData = .large350K
123-
lazy var mockDataDoubleLarge797K: MockData = .doubleLarge797K
124-
lazy var mockDataExtraLarge7_2M: MockData = .extraLarge7_2M
134+
public lazy var mockDataTiny: MockData = .tiny
135+
public lazy var mockDataSmall: MockData = .small
136+
public lazy var mockDataLarge88K: MockData = .large88K
137+
public lazy var mockDataLarge350K: MockData = .large350K
138+
public lazy var mockDataDoubleLarge797K: MockData = .doubleLarge797K
139+
public lazy var mockDataExtraLarge7_2M: MockData = .extraLarge7_2M
125140
}

Tests/Shared/Model.swift

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
import Foundation
22

33
public class OutlineItem: Hashable {
4+
public let identifier: UUID
45
public let title: String
56
public var subitems: [OutlineItem]
67

7-
public init(title: String,
8-
subitems: [OutlineItem] = []) {
8+
public init(
9+
identifier: UUID = UUID(),
10+
title: String,
11+
subitems: [OutlineItem] = []
12+
) {
13+
self.identifier = identifier
914
self.title = title
1015
self.subitems = subitems
1116
}
17+
18+
public init(
19+
other: OutlineItem
20+
) {
21+
self.identifier = other.identifier
22+
self.title = other.title
23+
self.subitems = other.subitems
24+
}
25+
1226
public func hash(into hasher: inout Hasher) {
27+
// don't add subitems recursively for performance reasons.
1328
hasher.combine(identifier)
1429
}
1530
public static func == (lhs: OutlineItem, rhs: OutlineItem) -> Bool {
16-
return lhs.identifier == rhs.identifier
31+
// don't add subitems recursively for performance reasons.
32+
lhs.identifier == rhs.identifier
1733
}
18-
private let identifier = UUID()
1934
}
2035

2136
extension OutlineItem: CustomStringConvertible {
@@ -24,3 +39,30 @@ extension OutlineItem: CustomStringConvertible {
2439
extension OutlineItem: CustomDebugStringConvertible {
2540
public var debugDescription: String { "\(title)" }
2641
}
42+
43+
public struct NodeTestItem: Hashable {
44+
public let identifier: UUID
45+
public let title: String
46+
public var subitems: [NodeTestItem]
47+
public init(identifier: UUID, title: String, subitems: [NodeTestItem] = []) {
48+
self.identifier = identifier
49+
self.title = title
50+
self.subitems = subitems
51+
}
52+
}
53+
extension NodeTestItem {
54+
public init(leaf: NodeTestItem) {
55+
self.init(identifier: leaf.identifier, title: leaf.title, subitems: [])
56+
}
57+
public init(_ item: NodeTestItem) {
58+
self.init(identifier: item.identifier, title: item.title, subitems: item.subitems)
59+
}
60+
public init(_ item: NodeTestItem, children: [NodeTestItem]) {
61+
self.init(identifier: item.identifier, title: item.title, subitems: children)
62+
}
63+
public init(outline: OutlineItem) {
64+
identifier = outline.identifier
65+
title = outline.title
66+
subitems = outline.subitems.map { NodeTestItem(outline: $0) }
67+
}
68+
}

Tests/SwiftListTreeDataSourceTests/DebugDescriptionUtilsTests.swift

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,18 +110,9 @@ class DebugDescriptionUtilsTests: XCTestCase {
110110
// MARK: - Helpers
111111

112112
func tinyHardcodedDataset() -> [OutlineItem] {
113-
return [
114-
OutlineItem(title: "Level 0, item1", subitems: [
115-
OutlineItem(title: "Level 1, item1"),
116-
OutlineItem(title: "Level 1, item2", subitems: [
117-
OutlineItem(title: "Level 2, item1"),
118-
OutlineItem(title: "Level 2, item2")
119-
])
120-
]),
121-
OutlineItem(title: "Level 0, item2")
122-
]
113+
DataManager.shared.mockDataTiny.items
123114
}
124-
115+
125116
func verifyExpandedLevelsDescriptionsMatchesTopLevelForFlattenedItems() {
126117
let backingStoreExpandedLevelsDescription = debugDescriptionExpandedLevels(sut.backingStore)
127118
let flattenedItemsTopLevelsDescription = debugDescriptionTopLevel(sut.items)

0 commit comments

Comments
 (0)