SwiftListTreeDataSource. Пакет для отображения древовидных данных в виде списка и возможностью поиска.

Демонстрация работы в приложениях TreeView (iOS) и FileViewer (macOS)
Демонстрация работы в приложениях TreeView (iOS) и FileViewer (macOS)

Отображение иерархических данных в виде списков позволяет визуально прослеживать отношения между элементами без необходимости постоянно открывать новый экран. Например, отображение папок и содержащихся в них файлов удобно просматривать в данном виде.

Популярные решения для платформ Apple:

  • NSDiffableDataSourceSectionSnapshot (Apple) добавляет такую возможность начиная с iOS 14

    • Так как нам необходимо поддерживать старые версии iOS, вариант от Apple не подходит. Также нет поддержки фильтра.

  • RATreeView

    • RATreeView выглядит как хороший вариант, есть поддержка старых версией iOS, хороший рейтинг и MIT лицензия. Посмотрев детальнее видны проблемы: c 2016 нет поддержки, немало заведено issues. Из деталей реализации: сильная завязка на UITableView и изменения состояния тесно связано с вызовами методов UI фреймворка. Добавление функциональности фильтра потребует существенной переработки и вероятно создаст риски стабильность компонента.

Так как подходящий инструмент не был найден, было решено написать свой компонент, который позволял бы решать поставленные задачи.

Примечание

Примеры исходного когда содержат только ключевые моменты описания работы алгоритма, финальная рабочая версия с примерами находится по ссылке выше.

Отображение иерархии, простой случай

Рассмотрим самый простой случай, с дочерними элементами и максимальной вложенностью 1:

Parent1
  |– Child1
  |– Child2
Parent2
  |– Child1
…

Несмотря на иерархию данных из примера можно заметить, что фактически это тот же плоский список для отображения. Отступ является декорацией элемента списка, который можно вычислить на основе его вложенности.

Решая этот частный случай нужно создать простой список из иерархической структуры: после каждого родительского элемента добавить дочерние. Создадим модели и метод построения списка для отображения.

Исходный код
class UserModel {
    let name: String
    let children: [UserModel]
    
    init(name: String, children: [UserModel]) {
        self.name = name
        self.children = children
    }
}

struct DisplayItem {
    var name: String
    var isChild: Bool = false
}

func flattenList(_ list: [UserModel]) -> [DisplayItem] {
    var output = [DisplayItem]()
    for item in list {
        output.append( DisplayItem(name: item.name) )
        for child in item.children {
            output.append( DisplayItem(name: child.name, isChild: true) )
        }
    }
    return output
}

Теперь можно построить источник данных для таблицы, например UITableView:

Исходный код
var displayItems = [DisplayItem]()

override func viewDidLoad() {
    super.viewDidLoad()
    displayItems = flattenList(hierarchicalObjects)
}

func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
    return displayItems.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(
        withIdentifier: Cell.identifier,
        for: indexPath
    ) as! Cell
    let item = displayItems[indexPath.row]
    cell.titleLabel.text = item.name
    cell.titleLabelLeadingConstraint.constant = item.isChild ? 20 : 0
    return cell
}

Обработка неограниченной вложенности

Рассмотрим более сложный пример, в котором вложенность не имеет ограничений:

Parent1
  |– Child1
  	|– Sub Child1
  	|– Sub Child2
…
  |– Child2
Parent2
  |– Child1
…

Из структуры данных можно заметить её рекурсивную особенность: элемент может содержать несколько элементов такого же типа, которые в свою очередь также могут могут содержать элементы и так далее. Для этой задачи нам нужно обновить модель: isChild имеет смысл для вложенности 1, здесь нам нужно знать уровень глубины элемента. Назовём новую модель для отображения TreeItem:

Исходный код
class TreeItem<Item: Hashable>: Hashable {
    let id = UUID()
    var value: Item
    var subitems: [TreeItem]
    weak var parent: TreeItem?
    fileprivate(set) var level: Int = 0
    
    init(name: String, parent: TreeItem?, items: [TreeItem] = []) {
        self.name = name
        self.parent = parent
        self.subitems = items
        self.level = level(of: self)
    }
    
   func level(of item: TreeItem) -> Int {
        // Traverse up to next parent to find level. root element has `0` level.
        var counter: Int = 0
        var currentItem: TreeItem = item
        while let parent = currentItem.parent {
            counter += 1
            currentItem = parent
        }
        return counter
    }

    public static func == (lhs: TreeItem<Item>, rhs: TreeItem<Item>) -> Bool {
        return lhs.id == rhs.id && lhs.value == rhs.value
    }
    
    public func hash(into hasher: inout Hasher) {
        hasher.combine(id)
        hasher.combine(value)
    }
}

Добавим два свойства parent и level:

  • parent нужен для определения отношения между элементами и также поможет определить глубину вложенности.

  • level является свойством которое хранит вложенность, вычисляется один раз через level(of:) чтобы избежать расход ресурсов при постоянном обращении к нему.

Сама реализация level(of:) в цикле проходит по цепочке доступных parent свойств и каждый раз инкрементирует счётчик:  в конечном итоге количество доступных parent даёт возможность посчитать глубину текущего элемента.

Подготовка данных

Для начала нам нужно преобразовать исходную модель данных в TreeItem, чтобы её можно было использовать для отображения. Логику поместим в отдельный класс и назовём его ListTreeDataSource.

Исходный код
class ListTreeDataSource<Item> where Item: Hashable {
    private(set) var backingStore: [TreeItem<Item>] = []
    private(set) var items: [TreeItemType] = [] // TODO: Display items for UI framework
    private var lookupTable: [Item: TreeItem<Item>] = [:]

    public func append(_ items: [Item], to parent: Item? = nil) {
        func append(items: [Item], into insertionArray: inout [TreeItem], parentBackingItem: TreeItem?) {
            let treeItems = items.map { item in TreeItem(value: item, parent: parentBackingItem) }
            cacheTreeItems(treeItems)
            insertionArray.append(contentsOf: treeItems)
        }

        if let parent = parent, let parentBackingItem = self.lookupTable[parent] {
            append(items: items, into: &parentBackingItem.subitems, parentBackingItem: parentBackingItem)
        } else {
            append(items: items, into: &backingStore, parentBackingItem: nil)
        }
    }
    
    private func cacheTreeItems(_ treeItems: [TreeItem<Item>]) {
        treeItems.forEach { lookupTable[$0.value] = $0 }
    }
}

Метод append(_:to:)  проверяет куда нужно  нужно добавить items: к существующему parent или как корневые. Если parent указан, то далее проверяется таблица поиска и добавляться будет в parentBackingItem.subitems иначе в backingStore. Создаются целевые TreeItems, кешируются и добавляются в целевой массив.

Таблица поиска позволяет быстро найти целевого TreeItem для заданного элемента, иначе каждый раз нужно выполнять обход всего дерева, что будет не очень эффективно.

Для добавления всей иерархии объектов можно написать небольшую утилиту которая рекурсивно выполняет обход и добавляет все элементы:

Исходный код
func createDataSource() -> ListTreeDataSource<UserModel> {
    var dataSource = ListTreeDataSource<UserModel>()
    
    func addItems(_ items: [UserModel], to parent: UserModel?) {
        dataSource.append(items, to: parent)
        for item in items {
            addItems(item.children, to: item)
        }
    }
    
    addItems(items, to: nil)
    return dataSource
}

TreeDataSource.backingStore является хранилищем данных в удобном для нас формате, но только его не хватит для отображения данных. Для этого нужно построить плоский список из этого иерархического хранилища. Для этого напишем обобщённую версию и назовём depthFirstFlattened:

Исходный код
func depthFirstFlattened(_ list: [Item]) -> [Item] {
    var output = [Item]()
    for item in list {
        output.append( item )
        output.append(contentsOf: depthFirstFlattened(item.children) )
    }
    return output
}

Рекурсивная реализация выглядит довольно просто: после каждого родителя добавить все его вложенные элементы, перед тем как перейти в следующему.

Фактически это обход графа в глубину с добавлением в “плоский” список пройденных элементов. На выходе получается одномерная перспектива на дерево (массив узлов), который можно использовать как источник данных для UI фреймворка.

Данные для списка

Теперь можно исправить TODO для items необходимые компоненту пользовательского интерфейса:

Исходный код
extension TreeDataSource {
    func reload() {
self.items = depthFirstFlattened(items: self.backingStore, itemChildren: \.subitems)
    }
}

Один из вариантов вызывать метод reload автоматически при изменении данных (после вызова append например). Недостаток будет в том, что если у нас тысячи узлов, то создаст серьёзные проблемы с производительностью так как будет постоянно вызываться. Поэтому оставим его для вызова пользователю компонента предоставив больше контроля.

Контроль показа вложенных элементов

Для этого нужна небольшая доработка: свойство TreeItem.isExpanded и фильтр какие дочерние элементы будут показаны:

Исходный код
    func reload() {
        self.items = depthFirstFlattened(items: self.backingStore, itemChildren: { $0.isExpanded ? $0.subitems : [] })
    }

Пример использования с UITableView:

Исходный код
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! Cell
    
    let item = treeDataSource.items[indexPath.row]
    
    let left = 11 + 10 * item.level
    cell.lbl?.text = item.value.title
    cell.lblLeadingConstraint.constant = CGFloat(left)
    cell.disclosureImageView.isHidden = item.subitems.isEmpty
    
    let transform = CGAffineTransform.init(rotationAngle: item.isExpanded ? CGFloat.pi/2.0 : 0)
    cell.disclosureImageView.transform = transform
    
    return cell
}

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let item = treeDataSource.items[indexPath.row]
    
    // update the state
    item.isExpanded = !item.isExpanded
    // rebuild shown items
    treeDataSource.reload()
    
    if let cell = tableView.cellForRow(at: indexPath) as? Cell {
        UIView.animate(withDuration: animationDuration) {
            let transform = CGAffineTransform.init(rotationAngle: item.isExpanded ? CGFloat.pi/2.0 : 0)
            cell.disclosureImageView.transform = transform
        }
    }
    
    tableView.reloadData()
}

Структура компонента также предоставляет возможность добавить новые данные к существующим, как например в случае с отложенной подгрузкой / подгрузкой по запросу. Методы вставки / удаления элементов также доступны, но не описаны для большей сжатости материала.

Уменьшение отступов для глубокой вложенности

Формула для определения отступа может быть улучшена. Например на iPhone 5 сложно уместить даже небольшую иерархию. Ниже представлены текущий и желаемый вариант отображения глубокой вложенности.

Текущий и желаемый вариант отображения глубокой вложенности
Текущий и желаемый вариант отображения глубокой вложенности

Коллега из отдела анализа данных помогла с формулой экспоненциальный спада, чтобы попытаться вместить отступы и получить желаемый вариант:

/// https://en.wikipedia.org/wiki/Exponential_decay
let isPad = UIDevice.current.userInterfaceIdiom == .pad
let decayCoefficient: Double = isPad ? 150 : 40
let lvl = Double(item.level)
let left = 11 + 10 * lvl * ( exp( -lvl / max(decayCoefficient, lvl)) )
let leftOffset: CGFloat = min( CGFloat(left) , UIScreen.main.bounds.width - 120)
cell.lblLeadingConstraint.constant = leftOffset

Оптимизация производительности

Рекурсивная реализация выглядит элегантно, но может создавать проблемы. Для достижения оптимальной производительности и быстрой работы на старых устройствах перепишем алгоритмы обхода в ширину и глубину используя итеративный стиль через очередь и стек:

Исходный код
public func addItems<Item>(_ items: [Item], itemChildren: (Item) -> [Item], to dataSource: TreeDataSource<Item>) {
    dataSource.append(items, to: nil)
    
    var queue: Queue<Item> = .init()
    queue.enqueue(items)
    
    while let current = queue.dequeue() {
        dataSource.append(itemChildren(current), to: current)
        queue.enqueue(itemChildren(current))
    }
    
    dataSource.reload()
}

func depthFirstFlattened<Item>(items: [Item], itemChildren: (Item) -> [Item]) -> [Item] {
    var outFlatStore: Array< Item > = []
    
    var stack: Array< Item > = items.reversed()
    while let current = stack.last {
        stack.removeLast()
        
        outFlatStore.append(current)
        stack.append(contentsOf: itemChildren(current).reversed())
    }
    return outFlatStore
}

Тестирование производительности:

Спецификация машины для тестирования

MacBook Pro 2019, 2,6 GHz 6-Core Intel Core i7, 16 GB 2667 MHz DDR4

Открытие всех узлов expandAll:
* ~88_000 nodes: 0.133 sec.
* ~350_000 nodes: 0.479 sec.
* ~797_000 nodes: 1.149 sec.
* ~7_200_000 nodes: 10.474 sec.

Добавление данных addItems(_:to:):
* ~88_000 nodes: 0.483 sec.
* ~350_000 nodes: 1.848 sec.
* ~797_000 nodes: 4.910 sec.
* ~7_200_000 nodes: 46.816 sec.

Также было интересно сравнить производительность с компонентом от Apple, но я предполагаю это не будет очень корректным так как он ещё и комбинирует функционал NSDiffableDataSourceSectionSnapshot и вероятно выполняет больше работы предоставив меньше контроля. Ради интереса цифры для двух тестов производительности:

expandAll:
~88_000 nodes: 0.133 sec. (`NSDiffableDataSourceSectionSnapshot` - 2 sec)
addItems(_:to:):
~88_000 nodes: 0.483 sec. (`NSDiffableDataSourceSectionSnapshot` - 70 sec)

Фильтр данных

Задача также предоставить возможность поиска по всем элементам, включая родительские и дочерние, сохранив возможность скрывать/открывать вложенные элементы.

Пример применения фильтров
Пример применения фильтров

Задача также предоставить возможность поиска по всем элементам, с следующими критериями:

  • Возможность находить целевые элементы по критерию поиска, как родительские так и дочерние.

  • Показывать весь путь к целевым элементам (сохранить такую же структуру отображения).

  • Если найден родительский элемент с детьми не подходящими под критерий поиска, не применять к ним фильтр (поиск папки когда не знаем содержимое).

  • Сохранить возможность скрывать/открывать родительские элементы.

Визуально представим нашу задачу:

Root
    RootChild_1
        ItemMatching[SearchText]
        Item2Matching[SearchText]
    RootChild_2
        ItemMatching4[SearchText]
        ItemMatching5[SearchText]
            ItemNoMatchingButMightBeSearchTarget1
            ItemNoMatchingButMightBeSearchTarget2
Root2

В данном случае можно выделить несколько основных моментов:

  • Для поиска используются весь массив данных, включая дочерние самого нижнего уровня.

  • Родительские элементы могут быть целевыми, так и не подходить под критерий поиска, но они нам нужны для отображения всего пути.

  • Если найден родительский элемент с детьми не подходящими под критерий поиска, не применять к ним фильтр. Но если есть дети удовлетворяющие критерию, то фильтр будет применён. Примечание: это спорный момент и может зависеть от ваших требований, вдохновления я брал на основе того как работает фильтр в Xcode по файлам проекта, хотя некоторые особенности и отличаются.

Для этого создадим новый класс который расширяет возможности предыдущего. Здесь также можно применить метод обхода в глубину, только в фильтром:

Исходный код
class FilterableTreeDataSource<Item>: ListTreeDataSource<Item> where Item: Hashable {
    public override init() { super.init() }
    private(set) var allFlattenedItemStore: [TreeItem<Item>] = []
    private(set) var filterPredicate: ((ItemIdentifierType) -> Bool)?
    
    private var dispatchQueue = DispatchQueue(label: "FilterableTreeDataSource")
    private var dispatchWorkItem: DispatchWorkItem?

   public private(set) var filteredTargetItems: [TreeItem<Item>] = []
    public private(set) var targetItemsTraversedParentSet: Set<TreeItem<Item>> = .init()

    public func filterItemsKeepingParents(by predicate: @escaping (ItemIdentifierType) -> Bool, completion: @escaping (() -> Void)) {
        self.filterPredicate = predicate
        self.dispatchWorkItem?.cancel()
        
        var workItem: DispatchWorkItem?
        workItem = DispatchWorkItem { [weak self] in
            guard let self = self else { return }
            if workItem?.isCancelled ?? true { workItem = nil; return }

            // collapse all elements
            self.allFlattenedItemStore.forEach { $0.isExpanded = false }
            
            self.filteredTargetItems = self.allFlattenedItemStore.filter { predicate($0.value) }
            self.targetItemsTraversedParentSet = Set( self.filteredTargetItems.flatMap { $0.allParents } )
            
            // expand all traversed parents for the target items
            self.targetItemsTraversedParentSet.forEach { $0.isExpanded = true }
                                    
            DispatchQueue.main.async {
                if workItem?.isCancelled ?? true { workItem = nil; return }

                self.rebuildShownItemsStore()
                workItem = nil
                completion()
            }
        }
        self.dispatchWorkItem = workItem
        self.dispatchQueue.async(execute: workItem!)
    }
    
    override func rebuildShownItemsStore() {
        // Depth first search + filtering. Tries to open expanded nested items matching `isIncludedInExpand` predicate.
        let outFlatStore = depthFirstFlattened(items: self.backingStore, itemChildren: { $0.isExpanded ? $0.subitems : [] }, filter: isIncludedInExpand)
        self.setShownFlatItems(outFlatStore)
    }

    private func isIncludedInExpand(_ item: TreeItemType) -> Bool {
        guard let filterPredicate = filterPredicate else { return true } // allow for all elements when no filtering.
        
        //  Root
        //      RootChild_1
        //          ItemMatching[SearchText]
        //          Item2Matching[SearchText]
        //      RootChild_2
        //          ItemMatching4[SearchText]
        //          ItemMatching5[SearchText]
        //              ItemNoMatchingButMightBeSearchTarget1
        //              ItemNoMatchingButMightBeSearchTarget2
        //  Root2

        switch (item.parent, item) {
        // inсluded root elements when matches predicate or contained in traversed parent set (e.g. Root, Root2).
        case let (.none, rootItem):
            return filterPredicate(rootItem.value) || self.targetItemsTraversedParentSet.contains(rootItem)

        // inсluded all items contained in traversed parent set, to display full path (e.g. RootChild_1, RootChild_2).
        case let (.some(_), item) where targetItemsTraversedParentSet.contains(item):
            return true
            
        case let (.some(parent), item):
            // inсluded items with parent from traversed parent set & matching to `filterPredicate`
            // (e.g. item: ItemMatching4[SearchText], parent: RootChild_2).
            if self.targetItemsTraversedParentSet.contains(parent) {
                return filterPredicate(item.value)
            } else {
                // user might inсlude any additional descendant items (e.g. ItemNoMatchingButMightBeSearchTarget1, ItemNoMatchingButMightBeSearchTarget2)
                return true
            }
        }
    }
    
    private func resetFilteringState() {
        filterPredicate = nil
        dispatchWorkItem?.cancel()
        dispatchWorkItem = nil
    }
    
    private func rebuildAllFlattenedItemStore() {
        self.allFlattenedItemStore = depthFirstFlattened(items: self.backingStore, itemChildren: { $0.subitems })
    }
}

Подготовка состояния для фильтра:

  • allFlattenedItemStore плоский список всех “раскрытых” элементов для поиска, кешируется для оптимизации расхода ресурсов. Источник данных для фильтра.

  • Сбрасываем состояние всех элементов isExpanded=false

  • Получаем filteredTargetItems –  целевые элементы поиска, полученные применением фильтра к allFlattenedItemStore

  • Получаем targetItemsTraversedParentSet – множество родителей целевых элементов

  • Помечаем элементы в targetItemsTraversedParentSet как isExpanded=true чтобы раскрыть путь к целевым элементам

  • Подготовка состояния завершена, вызываем обход в глубину с фильтром, которые определяется текущим состоянием в isIncludedInExpand

Логика фильтра:

  • Если элемент является корневым, то он должен подходить под критерий поиска или входить в множество targetItemsTraversedParentSet

  • Если элемент сам входит в множество targetItemsTraversedParentSet, то он подходит (как например RootChild_2)

  • Далее фильтр применяется ко всем элементам, у которых родители входят в множество targetItemsTraversedParentSet. Дополнительно пользователь может раскрыть целевой элемент поиска, и поэтому нужна `else true` чтобы включить остальные дочерние элементы.

Вывод

Полученный компонент не зависит от UI компонент и может быть использован например с UITableView / UICollectionView / NSTableView, SwiftUI или в консольном приложении. Весь исходный код и демонстрацию работы с примерами можно увидеть по ссылке выше. Компонент также покрыт основными тестами и присутствуют тесты производительности.

Всем спасибо! Надеюсь материал был полезен, буду благодарен за обратную связь.

Комментарии (2)


  1. MFilonen2
    08.09.2021 20:02

    А как красиво удалить элемент например? Придется все рассчитывать самостоятельно, как я понял? Хотелось бы, чтобы удаление / добавление возвращало удаленные индексы, которые потом можно использовать...


    1. dzmitry-antonenka Автор
      09.09.2021 01:56

      Идея как можно доработать метод delete (не тестировал):

      • Получить плоский список удалённых узлов и их раскрытых детей через обход в глубину

      • Пройтись по shownFlatItems и аккумулировать индексы элементов которые входят в данное множество

      https://gist.github.com/dzmitry-antonenka/75ceb704148772c8b228b39776ae515d

      Пожалуйста создавайте issue с описанием функциональности которой не хватает, будем учитывать в следующей версии :)

      Изначально этот функционал не добавлялся, так как был расчёт использовать существующие решения для получения разницы между коллекциями (iOS13 diffable snapshot или reloadData для более старых версий)