Static Generic Tables

    image

    All of us often have to deal with static tables, they can be the settings of our application, authorization screens, screens “about us” and many others. But often, novice developers do not apply any design patterns for such tables and write an all-in-one non-scalable, inflexible system.

    How I solve the given problem - under a cat.

    What is it about?


    Before solving the problem of static tables - it is worth understanding what it is. Static tables are tables where you already know the number of rows and the content that is in them. Examples of such tables are below.

    image

    Problem


    First of all, it's worth defining the problem: why can't we just create a ViewController, which will be a UITableViewDelegate and a UITableViewDatasource and just describe all the necessary cells? At a minimum, there are 5 problems with our table:

    1. Hard to scale
    2. Depends on indexes
    3. Not flexible
    4. No reuse
    5. Requires a lot of code to initialize.

    Decision


    The problem solving method is based on the following foundation:

    1. Taking out the responsibility of the table configuration to a separate class ( Constructor )
    2. Its wrapper over the UITableViewDelegate and UITableViewDataSource
    3. Connecting cells to custom protocols for reuse
    4. Creating your own data models for each table

    First I want to show how it is used in practice - then I will show how it is all implemented under the hood.

    Implementation


    The task is to create a table with two text cells and one empty between them.

    First of all, I created an ordinary TextTableViewCell with a UILabel .
    Next, for each UIViewController with a static table, you need your own Constructor, let's create it:

    class ViewControllerConstructor: StaticConstructorContainer {
    	typealias ModelType = <#type#>
    }

    When we inherit it from StaticConstructorContainer , the first thing Generic protocol requires us is a model type ( ModelType ) - this is the type of cell model that we also need to create, let's do it.

    I use enum for this, since it is more suitable for our tasks and here the most interesting begins. We will fill our table with content using protocols such as: Titled, Subtitled, Colored, Fonted, and so on. As you can guess, these protocols are responsible for displaying text. Suppose the Titled protocol requires title: String? , and if our cell supports title mappings , it will fill it. Let's see what it looks like:

    protocol Fonted {
    	var font: UIFont? { get }
    }
    protocol FontedConfigurable {
    	func configure(by model: Fonted)
    }
    protocol Titled {
    	var title: String? { get }
    }
    protocol TitledConfigurable {
    	func configure(by model: Titled)
    }
    protocol Subtitled {
    	var subtitle: String? { get }
    }
    protocol SubtitledConfigurable {
    	func configure(by model: Subtitled)
    }
    protocol Imaged {
    	var image: UIImage? { get }
    }
    protocol ImagedConfigurable {
    	func configure(by model: Imaged)
    }
    

    Correspondingly, only a small part of such protocols is presented here, you can create it yourself, as you see - it is very simple. I remind you that we create them once for one goal and then forget them and calmly use them.

    Our cell ( with text ) basically supports the following things: The text font, the text itself, the text color, the cell background color and in general any things that come to your mind.

    We need only the title for now . Therefore, we inherit our model from Titled. Inside the model in case, we indicate what types of cells we will have.

    enum CellModel: Titled {
    	case firstText
    	case emptyMiddle
    	case secondText
    	var title: String? {
    		switch self {
    		case .firstText: return "Я - первый"
    		case .secondText: return "Я - второй"
    		default: return nil
    		}
    	}
    }
    

    Since there is no label in the middle (empty cell), you can return nil.
    With the cell finished and you can insert it into our constructor.

    class ViewControllerConstructor: StaticConstructorContainer {
    	typealias ModelType = CellModel
    	var models: [CellModel] // Здесь мы должны выставить порядок и количество ячеек, отображаемых в коде
    	func cellType(for model: CellModel) -> Self.StaticTableViewCellClass.Type {
    		// здесь мы должны вернуть тип ячейки, которая принадлежит модели
    	}
    	func configure(cell: UITableViewCell, by model: CellModel) {
    		 // Здесь мы можем конфигурировать ячейку вручную, если это необходимо, но можно оставить это пустым
    	}
    	func itemSelected(item: CellModel) {
    		// аналог didSelect, не завязанный на индексах
    	}
    }

    And as a matter of fact, it is all our code. We can say that our table is ready. Let's fill in the data and see what happens.

    Oh yeah, I almost forgot. We need to inherit our cell from the TitledConfigurable protocol so that it can insert the title into itself. The cells support dynamic height too.

    extension TextTableViewCell: TitledConfigurable {
    	func configure(by model: Titled) {
    		label.text = model.title
    	}
    }
    

    How the filled constructor looks like:

    class ViewControllerConstructor: StaticConstructorContainer {
    	typealias ModelType = CellModel
    	var models: [CellModel] = [.firstText, .emptyMiddle, .secondText]
    	func cellType(for model: CellModel) -> StaticTableViewCellClass.Type {
    		switch model {
    		case .emptyMiddle: return EmptyTableViewCell.self
    		case .firstText, .secondText: return TextTableViewCell.self
    		}
    	}
    	func configure(cell: UITableViewCell, by model: CellModel) {
    		cell.selectionStyle = .none
    	}
    	func itemSelected(item: CellModel) {
    		switch item {
    		case .emptyMiddle: print("Нажата средняя ячейка")
    		default: print("Нажата другая ячейка...")
    		}
    	}
    }
    

    Looks pretty compact, right?

    Actually, the last thing left for us to do is connect it all to the ViewController:

    class ViewController: UIViewController {
    	private let tableView: UITableView = {
    		let tableView = UITableView()
    		return tableView
    	}()
    	private let constructor = ViewControllerConstructor()
    	private lazy var delegateDataSource = constructor.delegateDataSource()
    	override func viewDidLoad() {
    		super.viewDidLoad()
    		constructor.setup(at: tableView, dataSource: delegateDataSource)
    	}
    }

    Everything is ready, we must render delegateDataSource as a separate property to our class so that the weak link does not break inside some function.

    We can run and test:

    image

    As you can see, everything works.

    Now let's summarize and understand what we have achieved:

    1. If we create a new cell and want to replace the current cell with it, then we do this by changing one variable. We have a very flexible table system.
    2. We reuse all cells. The more cells you tie to this table, the easier and easier it is to work with. Great for large projects.
    3. We have reduced the amount of code to create the table. And we have to write it even less when we have a lot of protocols and static cells in the project.
    4. We learned how to build static tables from the UIViewController in the Constructor
    5. We have ceased to depend on indices, we can safely swap cells in the array and the logic does not break.

    The code for the test project at the end of the article.

    How does it work from the inside?


    How the protocols work, we have already discussed. Now we need to understand how the whole constructor and its associated classes work.

    Let's start with the constructor:
    protocol StaticConstructorContainer {
    	associatedtype ModelType
    	var models: [ModelType] { get }
    	func cellType(for model: ModelType) -> StaticTableViewCellClass.Type
    	func configure(cell: UITableViewCell, by model: ModelType)
    	func itemSelected(item: ModelType)
    }

    This is a common protocol that requires functions already familiar to us.

    More interesting is its extension :

    extension StaticConstructorContainer {
    	typealias StaticTableViewCellClass = StaticCell & NibLoadable
    	func delegateDataSource() -> StaticDataSourceDelegate<Self> {
    		return StaticDataSourceDelegate<Self>.init(container: self)
    	}
    	func setup<T: StaticConstructorContainer>(at tableView: UITableView, dataSource: StaticDataSourceDelegate<T>) {
    		models.forEach { (model) in
    			let type = cellType(for: model)
    			tableView.register(type.nib, forCellReuseIdentifier: type.name)
    		}
    		tableView.delegate = dataSource
    		tableView.dataSource = dataSource
    		dataSource.tableView = tableView
    	}
    }

    The setup function that we called in our ViewController registers all cells for us and delegates the dataSource and delegate .

    And delegateDataSource () creates a wrapper for us UITableViewDataSource and UITableViewDelegate . Let's look at it:

    
    class StaticDataSourceDelegate<Container: StaticConstructorContainer>: NSObject, UITableViewDelegate, UITableViewDataSource {
    	private let container: Container
    	weak var tableView: UITableView?
    	init(container: Container) {
    		self.container = container
    	}
    	func reload() {
    		tableView?.reloadData()
    	}
    	func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat {
    		let type = container.cellType(for: container.models[indexPath.row])
    		return type.estimatedHeight ?? type.height
    	}
    	func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
    		let type = container.cellType(for: container.models[indexPath.row])
    		return type.height
    	}
    	func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    		return container.models.count
    	}
    	func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    		let model = container.models[indexPath.row]
    		let type = container.cellType(for: model)
    		let cell = tableView.dequeueReusableCell(withIdentifier: type.name, for: indexPath)
    		if let typedCell = cell as? TitledConfigurable, let titled = model as? Titled {
    			typedCell.configure(by: titled)
    		}
    		if let typedCell = cell as? SubtitledConfigurable, let subtitle = model as? Subtitled {
    			typedCell.configure(by: subtitle)
    		}
    		if let typedCell = cell as? ImagedConfigurable, let imaged = model as? Imaged {
    			typedCell.configure(by: imaged)
    		}
    		container.configure(cell: cell, by: model)
    		return cell
    	}
    	func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    		let model = container.models[indexPath.row]
    		container.itemSelected(item: model)
    	}
    }

    I think there are no questions to the functions heightForRowAt , numberOfRowsInSection , didSelectRowAt , they just implement an understandable functionality. The most interesting method here is cellForRowAt .

    In it we implement not the most beautiful logic. We have to prescribe each new protocol to the cells here, but we do it once - so this is not so scary. If the model complies with the protocol, like our cell, then configure it. If someone has ideas on how to automate it, I will be glad to hear in the comments.

    This logic ends. I did not touch third-party utilitarian classes in this system, you can familiarize yourself with the code in full with the link .

    Thanks for attention!

    Also popular now: