To main content
 

Оживляем таблицы с помощью RxDataSources. RxSwift часть 1

Разрабатывая под iOS, меня всегда смущало обилие делегатов для создания таблиц и их громоздкость. Как только я начал использовать RxSwift, мне удалось познакомиться с его компонентом - RxDataSources. И теперь могу точно сказать, что работа с таблицами еще никогда не была такой простой.
RxDataSources
Представьте себе ситуацию, где вам нужно создать таблицу, в которой будет несколько секций, а в каждой из этих секций могут быть разные типы ячеек. Да-да, все знают это чувство рутины, которое появляется при создании таблиц или коллекций. Куча шаблонного кода без возможности его как-то ужать.

RxSwift позволяет если и не избавиться от этой проблемы, то хотя бы облегчить ее посредством оборачивания DataSource в Observable и привязывания его к таблице или коллекции.

Есть же случаи, когда этого недостаточно, а именно, когда нам нужно построить сложную таблицу с различными ячейками и секциями, или же когда нужно анимировать удаление, добавление или перемещение ячеек. Для этого существует RxDataSources.
Анимированное редактирование таблицы
Рассмотрим анимирование на простом примере с таблицей, где можно будет перемещать, удалять или выбирать ячейки, чтобы перейти на экран деталей.
В первой таблице будет один тип ячейки, но так как она будет интерактивной, то будем использовать RxDataSources (к тому же, если вы уже решили интегрировать RxSwift в свой проект, то лучше будет его использовать везде и постоянно).

Для простой таблицы нужно начать с объявления SectionModel<Section, ItemType>, где ItemType это тип элемента вашего DataSource. Для анимированной таблицы нужно будет использовать AnimatableSectionModel<Section, ItemType>. В зависимости от того, какого типа мы объявляем модель, ее Item должен соответствовать определенному протоколу:

  • В первом случае достаточно Equatable
  • Во втором к Equatable нужно добавить IdentifiableType
На IdentifiableType нужно остановиться подробнее, так как если пройтись по вопросам на гитхабе RxDataSource, то можно заметить, что не всегда люди понимают в чем дело и почему у них интерфейс неправильно себя ведет.
public protocol IdentifiableType {
associatedtype Identity: Hashable
var identity : Identity { get }
}
Identity - это в каком-то смысле social security number объекта. Константа, которая поможет определить личность объекта, например UUID.

Equatable помогает понять, когда у объекта поменялось какое-то свойство. Любое указанное изменение будет триггерить обновление ячейки.

За примером далеко идти не нужно, взять человека: он может поменять одежду, сделать стрижку и сходить в солярий, но его личность останется той же, и для идентификации будет использоваться соответствующий документ.
Объявляем typealias AnimatableSectionModel и dataSource:
typealias RecipeListSectionModel = AnimatableSectionModel<String, Recipe>
var dataSource: RxTableViewSectionedAnimatedDataSource<RecipeListSectionModel>!
Инициализируем dataSource с нужными параметрами:

  • AnimationConfiguration - указываем типы анимаций для разных действий
  • ConfigureCell - возвращаем ячейку и привязываем ее ко viewModel, здесь же можно удобно привязать какие-то действия на элементы в ячейке, например, действие кнопок
  • CanEditRowAtIndexPath - разрешено ли редактировать ячейку
  • CanMoveRowAtIndexPath - разрешено ли перемещать ячейку. Указывается всегда после CanEditRowAtIndexPath
dataSource = RxTableViewSectionedAnimatedDataSource<RecipeListSectionModel>(
animationConfiguration: AnimationConfiguration(insertAnimation: .right,
reloadAnimation: .none,
deleteAnimation: .left),
configureCell: configureCell,
canEditRowAtIndexPath: canEditRowAtIndexPath,
canMoveRowAtIndexPath: canMoveRowAtIndexPath
)
private var configureCell: RxTableViewSectionedAnimatedDataSource<RecipeListSectionModel>.ConfigureCell {
return { _, tableView, indexPath, recipe in
var cell: RecipeListTableViewCell = tableView.dequeueReusableCell(forIndexPath: indexPath)
cell.bind(to: RecipeListCellViewModel(withRecipe: recipe))
return cell
}
}
private var canEditRowAtIndexPath: RxTableViewSectionedAnimatedDataSource<RecipeListSectionModel>.CanEditRowAtIndexPath {
return { [unowned self] _, _ in
if self.tableView.isEditing {
return true
} else {
return false
}
}
}
private var canMoveRowAtIndexPath: RxTableViewSectionedAnimatedDataSource<RecipeListSectionModel>.CanMoveRowAtIndexPath {
return { _, _ in
return true
}
}
view raw DataSource hosted with ❤ by GitHub
Далее привязываем DataSource к SectionModel, конечно же, стараемся использовать Driver:
viewModel.dataSource.asDriver()
.map { [RecipeListSectionModel(model: "", items: $0)] }
.drive(tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
Для удаления используем расширение rx.itemDeleted, где при удалении возвращается indexPath удаляемой ячейки.
tableView.rx.itemDeleted.asDriver()
.drive(onNext: { [unowned self] indexPath in
self.viewModel.dataSource.remove(at: indexPath.row)
})
.disposed(by: disposeBag)
view raw BindingDeleted hosted with ❤ by GitHub
Для перемещения берем rx.itemMoved, где перемещая ячейку мы получаем indexPath, по которому находилась ячейка, и indexPath назначения:
tableView.rx.itemMoved.asDriver()
.drive(onNext: { [unowned self] source, destination in
guard source != destination else { return }
let item = self.viewModel.dataSource.value[source.row]
self.viewModel.dataSource.replaceElement(at: source.row, insertTo: destination.row, with: item)
})
.disposed(by: disposeBag)
view raw BindingMoved hosted with ❤ by GitHub
Видно, что recyclerView перехватывает свайп у MotionLayout. Чтобы этого избежать, добавляем recyclerView следующий параметр:
И, конечно, rx.modelSelected, где возвращается указанный объект, в выбранной ячейке:
tableView.rx.modelSelected(Recipe.self)
.asDriver()
.drive(onNext: { [unowned self] recipe in
var vc = RecipeViewController.initFromNib()
vc.bind(to: RecipeViewModel(withRecipe: recipe))
self.navigationController?.pushViewController(vc, animated: true)
})
.disposed(by: disposeBag)
view raw BindingSelected hosted with ❤ by GitHub
Смотрим результат:
Complex tables
Создание таблиц с разными ячейками и множественными секциями не сильно отличается от того, что описывалось выше. Главным отличием будет то, что вместо обозначения SectionModel нужно создать два типа суммы:

  • для секций этот тип суммы должен соответствовать протоколу SectionModelType. Associated value - массив типа суммы для ячеек
  • для ячеек здесь associated value это то, что вы хотите передать в ячейку
В нашем примере будет окно деталей рецепта, где будет три вида ячеек в одной секции:
enum RecipeSectionModel {
case recipe(items: [SectionItem])
}
enum SectionItem {
case image(imageData: Data)
case about(text: String)
case ingredients(ingredients: [Ingredient])
}
extension RecipeSectionModel: SectionModelType {
typealias Item = SectionItem
var items: [SectionItem] {
switch self {
case .recipe(let items):
return items.map { $0 }
}
}
init(original: RecipeSectionModel, items: [Item]) {
switch original {
case .recipe:
self = .recipe(items: items)
}
}
}
Во viewModel создаем Observable типа суммы секций и инициализируем его каждый раз, когда приходит какое-то изменение в DataSource:
extension RecipeViewModel {
func initSectionModels(withRecipe recipe: Recipe) {
sectionModels = .just(setupSections(recipe: recipe))
}
}
private extension RecipeViewModel {
func setupSections(recipe: Recipe) -> [RecipeSectionModel] {
let dataSource: [RecipeSectionModel] = [
.recipe(items: [.image(imageData: recipe.image),
.about(text: "Total calories: \(recipe.totalCalories)"),
.about(text: isVegetarianCell(recipe.isVegetarian)),
.ingredients(ingredients: recipe.ingredients)])
]
return dataSource
}
func isVegetarianCell(_ isVegetarian: Bool) -> String {
if isVegetarian {
return "Vegetarian recipe"
} else {
return "Non-Vegetarian recipe"
}
}
}
view raw vmDataSource hosted with ❤ by GitHub
В ConfigureCell возвращаем необходимую ячейку с помощью switch:
private var configureCell: DataSource.ConfigureCell {
return { dataSource, tableView, indexPath, _ in
switch dataSource[indexPath] {
case .image(let imageData):
var cell: ImageTableViewCell = tableView.dequeueReusableCell(forIndexPath: indexPath)
cell.bind(to: ImageCellViewModel(withData: imageData))
return cell
case .about(let text):
var cell: AboutTableViewCell = tableView.dequeueReusableCell(forIndexPath: indexPath)
cell.bind(to: AboutCellViewModel(withText: text))
return cell
case .ingredients(let ingredients):
var cell: IngredientsTableViewCell = tableView.dequeueReusableCell(forIndexPath: indexPath)
cell.bind(to: IngredientsCellViewModel(withIngredients: ingredients))
return cell
}
}
}
view raw configureCell hosted with ❤ by GitHub
Смотрим результат:
Спасибо за внимание!