Компоновщик
Published: 3 November 2017
Tagged:
composite ios swift

Часто в приложениях встречаются табличные представления UITableView, UICollectionView. В зависимости от задачи я использовал в качестве структуры данных массив либо словарь. Это решение несло ряд ограничений и не было гибким. Для меня более удобным стало использование компоновщика. Проект с примером на Git-Hub :octocat:


Компоновщик — структурный паттерн проектирования. Он позволяет сгруппировать объекты в древовидную структуру, а затем работать с ними как с единым объектом.



Теория

Как будет выглядеть наше дерево? Табличное представление состоит из списка секций. В свою очередь, секции содержат модели ячеек (тоже самое для коллекций). Таким образом нам нужны элементы двух типов которые могут содержать дочерние элементы (контейнеры) и конечные элементы (листья) в которые нельзя добавлять детей.


Контейнер (Композит) — составной элемент дерева. Содержит дочерние элементы — Листья или другие Контейнеры — но не знает, какие именно, так как работает с ними только через общий интерфейс Компонента (CompoundItemProtocol). Методы этого класса переадресуют основную работу своим дочерним компонентам, хотя и могут добавлять что-то своё к результату. Таким образом, паттерн Компоновщик позволяет нам работать с деревьями объектов любой сложности, не обращая внимание на конкретные модели, формирующие дерево.



Реализация

  1. Напишем общий интерфейс для элементов дерева CompoundItemProtocol, все элементы нашей структуры должны релиазовать этот протокол. Так же создадим перечисление с доступными уровнями вложенности CompoundItemLevel.
    // уровень вложенности
    enum CompoundItemLevel {
      case root, section, item
    }
    
    protocol CompoundItemProtocol {
      // уникальный идентификатор
      var identifier: String {get}
      // уровень на котором находится элемент
      var level: CompoundItemLevel {get}
      // список дочерних элементов элемента дерева
      var children: [CompoundItemProtocol] {get} 
      // список элементов для работы
      var items: [CompoundItemProtocol] {get}
      // добавить дечерний элемент
      func add(_ model: CompoundItemProtocol...)
      // удалить дочерний элемент
      func remove(_ model: CompoundItemProtocol)
    }
    
    // Возможность сравнивать объекты с протоколом CompoundItemProtocol 
    func == (lhs: CompoundItemProtocol, rhs: CompoundItemProtocol) -> Bool {
      return lhs.identifier == rhs.identifier
    }
    Компонент определяет общий интерфейс для простых и составных компонентов дерева.
  2. Так как все модели должны его реализовывать - напишем базовый каласс BaseCompoundItem на базе которого будем добавлять необходимые модели нашего дерева:
    class BaseCompoundItem: CompoundItemProtocol {
      var children: [CompoundItemProtocol] = []
      let level: CompoundItemLevel
      let identifier: String
      
      var items: [CompoundItemProtocol] {
        return children
      }
      
      // инициализация без параметров создает рутовый элемент структуры
      init() {
        self.identifier = "root"
        self.level = .root
      }
      
      init(identifier: String, level: CompoundItemLevel) {
        self.identifier = identifier
        self.level = level
      }
      
      func add(_ model: CompoundItemProtocol...) {
        self.children.append(contentsOf: model)
      }
      
      func remove(_ model: CompoundItemProtocol) {
        if let index = self.children.index(where: { $0 == model }) {
          children.remove(at: index)
        }
      }
    }
    Контейнер (или Композит) — это составной элемент дерева.
  3. Базовая реализация готова. К ней можно добавить конкретные модели: секций и ячееки. Для создания модели элементов дерева будем использовать паттерн декоратор, чтобы расширить нашу базовую реализацию BaseCompoundItem. Этот структурный паттерн позволит динамически добавить объектам новую функциональность, используя так называемые обвертки. Напишем модель для секции:
    // Сделаем нашей модели read only протокол, только с необходимыми полями
    protocol SectionCompoundItemProtocol {
      var header: String? {get}
      var footer: String? {get}
    }
    
    class SectionCompoundItem: CompoundItemProtocol {
      private let decoratedComposite: CompoundItemProtocol
      
      // основные поля которые мы добавили с помощью декоратора
      var header: String?
      var footer: String?
      
      var identifier: String {
        return self.decoratedComposite.identifier
      }
      
      // из нашего декарируемого объекта будем рекурсивно 
      // запрашивать элементы, вернет только листья
      var children: [CompoundItemProtocol] {
        return self.decoratedComposite.children
      }
      
      var items: [CompoundItemProtocol] {
        return self.decoratedComposite.children.flatMap {$0.items}
      }
      
      var level: CompoundItemLevel {
        return self.decoratedComposite.level
      }
      
      init(identifier: String, header: String?, footer: String?) {
        // Для упрощения мы не будем передавать декарируемый 
        // объект, а просто созданим его с нужным уровнем вложенности
        self.decoratedComposite = BaseCompoundItem(identifier: identifier, level: .section)
        self.header = header
        self.footer = footer
      }
      
      // В секцию можно добавить все кроме секций и корневых элементов
      func add(_ model: CompoundItemProtocol...) {
        for item in model {
          if item.level != .section && item.level != .root {
            self.decoratedComposite.add(item)
          } else {
            print("You can`t add section in other section")
          }
        }
      }
      
      func remove(_ model: CompoundItemProtocol) {
        self.decoratedComposite.remove(model)
      }
    }
    Модель секции тоже является контейнером так как может содержать дочернии элементы.
  4. И модель для ячейки:
    // Сделаем нашей модели read only протокол, только с необходимыми полями
    protocol CellCompoundItemProtocol {
      var title: String? {get}
      var subtitle: String? {get}
      var cellIdentifier: String {get}
    }
    
    class CellCompoundItem: CompoundItemProtocol, CellCompoundItemProtocol {
      private let decoratedComposite: CompoundItemProtocol
      var title: String?
      var subtitle: String?
      let cellIdentifier: String
      
      var identifier: String {
        return self.decoratedComposite.identifier
      }
      
      // это лист дерева так что возвращаем самого себя
      var tems: [CompoundItemProtocol] {
        return [self]
      }
      
      // детей у листов быть не может
      var children: [CompoundItemProtocol] {
        return []
      }
      
      var level: CompoundItemLevel {
        return self.decoratedComposite.level
      }
      
      init(identifier: String, cellIdentifier: String, title: String?, subtitle: String?) {
        self.decoratedComposite = BaseCompoundItem(identifier: identifier, level: .item)
        self.title = title
        self.subtitle = subtitle
        self.cellIdentifier = cellIdentifier
      }
      
      func add(_ model: CompoundItemProtocol...) {
        print("Нельзя добавить дочерние элементов, этот элемент является листом структуры")
      }
      
      func remove(_ model: CompoundItemProtocol) {
        print("У листьев нет детей, нечего удалять")
      }
    }
    Модель ячейки является листом – это простой элемент дерева, не имеющий ответвлений.

Использование

Код написан, можно опробовать нашу новую структуру данных:

// создаем корневой контейнер
self.datasource = BaseCompoundItem()

// создаем модели секции
let section1 = SectionCompoundItem(identifier: "Section1", header: "Вселенная MARVEL", footer: "Marvel Comics — американская компания, издающая комиксы, подразделение корпорации «Marvel Entertainment»")
let section2 = SectionCompoundItem(identifier: "Section2", header: "Вселенная DC", footer: "DC Comics — одно из крупнейших и наиболее популярных издательств комиксов, наравне с Marvel Comics")

// создаем модели ячеек
let cap = CellCompoundItem(identifier: "cell2", cellIdentifier: "CellID", title: "Captain America", subtitle: "Стивен Роджерс")
let batman = CellCompoundItem(identifier: "cell5", cellIdentifier: "CellID", title: "Batman", subtitle: "Брюс Вэйн")
    
// добавляем в секции модели ячеек и в корневой контейнер секции
section1.add(cap)
section2.add(batman)
datasource.add(section1, section2)

Теперь выполняем tableView.reloadData(), и готово. Посмотреть пример кода можно тут :cat:



В заключении

Что полезного можно добавить:

  • Фильтры в модели ячеек. Добавим дополнительный параметр isVisible — от него зависит, будут ли модели возвращаться.
// CellCompoundItem
var isVisible = false
var items: [CompoundItemProtocol] {
    return self.isVisible ? [self] : []
}
  • Проверка валидации всех полей таблицы. Добавляем новый метод func isValid() -> Bool в CompoudItemProtocol. Реализуем его во всех моделях ячеек нашего дерева.
  • Проверка изменений данных таблицы при создании сложных форм ввода данных (создается по аналогии с п. 2).

Есть вопросы? Пишите сюда – @alobanov