Reactive Data Display Manager. Introduction

This is the first part of a series of articles on the ReactiveDataDisplayManager (RDDM) library. In this article, I will describe the common problems that I have to deal with when working with โ€œregularโ€ tables, and also give a description of RDDM.









Problem 1. UITableViewDataSource



For starters, forget about the allocation of responsibilities, reuse and other cool words. Let's look at the usual work with tables:



class ViewController: UIViewController { ... } extension ViewController: UITableViewDelegate { ... } extension ViewController: UITableViewDataSource { ... }
      
      





We will analyze the most common option. What do we need to implement? That's right, 3 UITableViewDataSource



methods are usually implemented:



 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int func numberOfSections(in tableView: UITableView) -> Int func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)
      
      





For now, we will not pay attention to auxiliary methods ( numberOfSection



, etc.) and consider the most interesting one - func tableView(tableView: UITableView, indexPath: IndexPath)







Suppose we want to fill in a table with cells with a description of the products, then our method will look like this:



 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) { let anyCell = tableView.dequeueReusableCell(withIdentifier: ProductCell.self, for: indexPath) guard let cell = anyCell as? ProductCell else { return UITableViewCell() } cell.configure(for: self.products[indexPath.row]) return cell }
      
      





Excellent, itโ€™s not difficult. Now, suppose we have several types of cells, for example, three:





For simplicity of the example, we get the cell to getCell



method:



 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) { switch indexPath.row { case 0: guard let cell: PromoCell = self.getCell() else { return UITableViewCell() } cell.configure(self.promo) return cell case 1: guard let cell: AdCell = self.getCell() else { return UITableViewCell() } cell.configure(self.ad) return cell default: guard let cell: AdCell = self.getCell() else { return UITableViewCell() } cell.configure(self.products[indexPath.row - 2]) return cell } }
      
      





Somehow a lot of code. Imagine that we want to make up the settings screen. What is going to be there?





Moreover, the order is set. A great method will turn out ...



And now another situation - there is an input form. On the input form, a bunch of identical cells, each of which is responsible for a specific field in the data model. For example, the cell to enter the phone is responsible for the phone and so on.

Everything is simple, but there is one โ€œBUT". In this case, you still have to paint different cases, because you need to update the necessary fields.



You can continue to fantasize and imagine Backend Driven Design, in which we receive 6 different types of input fields, and depending on the state of the fields (visibility, input type, validation, default value, and so on) the cells change so much that their cannot lead to a single interface. In this case, this method will look very unpleasant. Even if you decompose the configuration into different methods.



By the way, after that, imagine what your code will look like if you want to add / remove cells as you work. It will not look very nice due to the fact that we will have to independently monitor the consistency of the data stored in the ViewController



and the number of cells.



Problems:





Problem 2. MindSet



The time for cool words has not come yet.

Let's look at how the application works, or rather, how the data appears on the screen. We always present this process in sequence. Well, more or less:



  1. Get data from the network;
  2. Handle;
  3. Display this data on the screen.


But is it really so? No! In fact, we do this:



  1. Get data from the network;
  2. Handle;
  3. Save inside ViewController model;
  4. Something causes a screen refresh;
  5. The saved model is converted to cells;
  6. Data is displayed on the screen.


In addition to quantity, there are still differences. First, we no longer output data; it is output. Secondly, there is a logical gap in the data processing process, the model is saved and the process ends there. Then something happens and another process starts. Thus, we obviously do not add elements to the screen, but only save them (which, by the way, is also fraught) on demand.



And remember about UITableViewDelegate



, it also contains methods for determining the height of cells. Usually automaticDimension



enough, but sometimes this is not enough and you need to set the height yourself (for example, in the case of animations or for headers)

Then we generally share the cell settings, the part with the height configuration is in another method.



Problems:





Idea



The listed problems on complex screens cause a headache and a sharp desire to go for tea.



Firstly, I do not want to constantly implement delegate methods. The obvious solution is to create an object that will implement it. Next we will do something like:



 let displayManager = DisplayManager(self.tableView)
      
      





Fine. Now you need the object to be able to work with any cells, while the configuration of these cells needs to be moved somewhere else.



If we put the configuration in a separate object, then we encapsulate (it's time for smart words) the configuration in one place. In this same place, we can take out the logic for formatting data (for example, changing the date format, string concatenation, etc.). Through the same object, we can subscribe to events in the cell.



In this case, we will have an object that has two different interfaces:



  1. The UITableView



    instance spawn interface is for our DisplayManager.
  2. Initialization, subscription and configuration interface - for Presenter or ViewController.


We call this object a generator. Then our generator for the table is a cell, and for everything else - a way to present data on a UI and process events.



And since the configuration is now encapsulated by the generator, and the generator itself is a cell, we can solve a lot of problems. Including those listed above.



Implementation



 public protocol TableCellGenerator: class { var identifier: UITableViewCell.Type { get } var cellHeight: CGFloat { get } var estimatedCellHeight: CGFloat? { get } func generate(tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell func registerCell(in tableView: UITableView) } public protocol ViewBuilder { associatedtype ViewType: UIView func build(view: ViewType) }
      
      





With such implementations, we can make the default implementation:



 public extension TableCellGenerator where Self: ViewBuilder { func generate(tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell(withIdentifier: self.identifier.nameOfClass, for: indexPath) as? Self.ViewType else { return UITableViewCell() } self.build(view: cell) return cell as? UITableViewCell ?? UITableViewCell() } func registerCell(in tableView: UITableView) { tableView.registerNib(self.identifier) } }<source lang="swift">
      
      





I will give an example of a small generator:



 final class FamilyCellGenerator { private var cell: FamilyCell? private var family: Family? var didTapPerson: ((Person) -> Void)? func show(family: Family) { self.family = family cell?.fill(with: family) } func showLoading() { self.family = nil cell?.showLoading() } } extension FamilyCellGenerator: TableCellGenerator { var identifier: UITableViewCell.Type { return FamilyCell.self } } extension FamilyCellGenerator: ViewBuilder { func build(view: FamilyCell) { self.cell = view view.selectionStyle = .none view.didTapPerson = { [weak self] person in self?.didTapPerson?(person) } if let family = self.family { view.fill(with: family) } else { view.showLoading() } } }
      
      





Here we hid both the configuration and the subscriptions. Note that now we have a place where we can encapsulate the state (because it is impossible to encapsulate the state in the cell because it is reused by the table). And they also got the opportunity to change data in the cell "on the fly."



Pay attention to self.cell = view



. We remembered the cell and now we can update the data without reloading this cell. This is a useful feature.



But I was distracted. Since any cell can be represented by a generator, we can make the interface of our DisplayManager a little more beautiful.



 public protocol DataDisplayManager: class { associatedtype CollectionType associatedtype CellGeneratorType associatedtype HeaderGeneratorType init(collection: CollectionType) func forceRefill() func addSectionHeaderGenerator(_ generator: HeaderGeneratorType) func addCellGenerator(_ generator: CellGeneratorType) func addCellGenerators(_ generators: [CellGeneratorType], after: CellGeneratorType) func addCellGenerator(_ generator: CellGeneratorType, after: CellGeneratorType) func addCellGenerators(_ generators: [CellGeneratorType]) func update(generators: [CellGeneratorType]) func clearHeaderGenerators() func clearCellGenerators() }
      
      





This is actually not all. We can insert generators in the right places or delete them.



By the way, inserting a cell after a specific cell can be damn useful. Especially if we gradually load the data (for example, the user entered the TIN, we loaded the TIN information and displayed it by adding several new cells after the TIN field).



Total



How cell work will now look:



 class ViewController: UIViewController { func update(data: [Products]) { let gens = data.map { ProductCellGenerator($0) } self.ddm.addGenerators(gens) } }
      
      





Or here:



 class ViewController: UIViewController { func update(fields: [Field]) { let gens = fields.map { field switch field.type { case .phone: let gen = PhoneCellGenerator(item) gen.didUpdate = { self.updatePhone($0) } return gen case .date: let gen = DateInputCellGenerator(item) gen.didTap = { self.showPicker() } return gen case .dropdown: let gen = DropdownCellGenerator(item) gen.didTap = { self.showDropdown(item) } return gen } } let splitter = SplitterGenerator() self.ddm.addGenerator(splitter) self.ddm.addGenerators(gens) self.ddm.addGenerator(splitter) } }
      
      





We can control the order of adding elements and, at the same time, the connection between data processing and adding them to the UI is not lost. Thus, in simple cases, we have simple code. In difficult cases, the code does not turn into pasta and at the same time looks passable. A declarative interface for working with tables has appeared and now we encapsulate the configuration of cells, which in itself allows us to reuse cells along with configurations between different screens.



Pros of using RDDM:





Sources here .



Thanks for your attention!



All Articles