XE2V Project Harvest series:

Tensions: To make the UITableView and UICollectionView more usable

YLStateMachine: a simple state machine

YLRefresh: With it, you can delete your refresh code

Decomposer: a protocol-oriented architecture pattern

Protocol oriented programming and operation automation ── taking refresh automation as an example

preface

One of the highlights of Swift is its support for protocol Oriented programming (POP), which has many advantages over traditional object-oriented programming (OOP). For example, Meow-God mentioned in his article “Protocol Oriented Programming meets Cocoa (PART 1)” that POP addresses OOP’s dynamic distribution of security and crosscutting concerns and, to some extent, avoids the diamond defect in OOP. POP doesn’t just have that advantage. It also has an innate gene for automation. Think about it. What does an agreement do? Protocols regulate the behavior and characteristics of components. What about automation? Automation is the combination of components with specific behaviors and characteristics in the right way. So, we can combine the protocolized components in the right way to automate. Next, I’ll use the refresh as an example to show you how to eventually automate the refresh operation using protocols.

Simplify the creation and configuration of UITableView

When a page has multiple cells, registering and configuring cells requires a lot of repetitive code. Is there a way to simplify this process? Let’s look at the registration method first:

tableView.register(ACell.self, forCellReuseIdentifier: "ACell")
tableView.register(BCell.self, forCellReuseIdentifier: "BCell")
tableView.register(CCell.self, forCellReuseIdentifier: "CCell")
tableView.register(DCell.self, forCellReuseIdentifier: "DCell")
Copy the code

The register(_:forCellReuseIdentifier:) method needs to provide two parameters — the type of the cell and the corresponding reuse identifier of the cell. Since reuse identifiers correspond to cell types one by one, add a type attribute to the cell as its reuse identifier. Based on this, we define the ReusableView protocol.

  • ReusableView

public protocol ReusableView {}extension ReusableView {
    public static var reuseIdentifier: String {
        return String(describing: self)}}extension UITableViewCell: ReusableView {}Copy the code
  • NibView

The registration is different when cells are created in NIB mode, so we define the NibView protocol:

public protocol NibView {}extension NibView where Self: UIView {
    public static var nib: UINib {
        return UINib(nibName: String(describing: self), bundle: nil)}}extension UITableViewCell: NibView {}Copy the code

To register a cell, write:

tableView.register(ACell.self, forCellReuseIdentifier: ACell.reuseIdentifier)
Copy the code

When registering a cell, you only need to provide its type.

  • Extend the UITableView

Next, in order to solve the problem that the registration method needs to be written repeatedly, we add an extension method to the UITableView:

extension UITableView {
    public func registerCells(with cells: [UITableViewCell.Type]) {
        for cell in cells {
            register(cell, forCellReuseIdentifier: cell.reuseIdentifier)
        }
    }
    
    public func registerNibs(with cells: [UITableViewCell.Type]) {
        for cell in cells {
            register(cell.nib, forCellReuseIdentifier: cell.reuseIdentifier)
        }
    }
}
Copy the code

Thus, registering multiple types of cells becomes fairly neat:

tableView.registerCells(with: [ACell.self.BCell.self])
tableView.registerNibs(with: [CCell.self.DCell.self])
Copy the code

Another place where duplicate code occurs is in the tableView(_:cellForRowAt:) method. When a page has multiple cell classes, we might write:

func tableView(_ tableView: UITableView.cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    switch indexPath.section {
    case 0:
        let cell = tableView.dequeueReusableCell(withIdentifier: "ACell", for: indexPath) as! ACell
        cell.configure(model[indexPath.section][indexPath.row])
        return cell
    case 1:
        let cell = tableView.dequeueReusableCell(withIdentifier: "BCell", for: indexPath) as! BCell
        cell.configure(model[indexPath.section][indexPath.row])
        return cell
    case 2:
        let cell = tableView.dequeueReusableCell(withIdentifier: "CCell", for: indexPath) as! CCell
        cell.configure(model[indexPath.section][indexPath.row])
        return cell
    case 3:
        let cell = tableView.dequeueReusableCell(withIdentifier: "DCell", for: indexPath) as! DCell
        cell.configure(model[indexPath.section][indexPath.row])
        return cell
    }
}
Copy the code

Can you improve this code? The key here is that dequeueReusableCell(…) Methods should be able to return different cells under different identifiers. What do you have in mind? By the way, the opaque type introduced in Swift 5.1 is the key to solving this problem. We just need to add an extension to the UITableView:

extension UITableView {
    public func dequeueReusableCell(
        for indexPath: IndexPath.with cells: [UITableViewCell.Type]) -> some UITableViewCell {
        for (index, cell) in cells.enumerated() where index = = indexPath.section {
            let cell = dequeueReusableCell(withIdentifier: cell.reuseIdentifier, for: indexPath)
            return cell
        }
        
        fatalError()}}Copy the code

And then in the tableView(_:cellForRowAt:) method you can say:

func tableView(_ tableView: UITableView.cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(for: indexPath, with: [ACell.self.BCell.self.CCell.self.DCell.self])
    cell.configure(model[indexPath.section][indexPath.row])
    return cell
}
Copy the code
  • Configurable

To implement a configure(_) method for a cell, you need to define a different protocol:

@objc public protocol Configurable {
    func configure(_ model: Any?)
}

extension UITableViewCell: Configurable {
    open func configure(_ model: Any?){}}Copy the code
  • ModelType

Handwritten cell types are not so elegant during registration and configuration, because Model corresponds to the page. We can store the cell type of the page as the type attribute of Model, and call Model when necessary. To do this we define ModelType:

public protocol ModelType {
    static var tCells: [UITableViewCell.Type]? { get }
    static var tNibs: [UITableViewCell.Type]? { get }
    // All cell types to be registered, sort by display order
    static var tAll: [UITableViewCell.Type]? { get }
    
    var pageablePropertyPath: WritableKeyPath<SomeModel[Something] >? {get }
    
    // Return the models of all cells
    var data: [[Any]] { get}}extension ModelType {
    public static var tCells: [UITableViewCell.Type]? { nil }
    public static var tNibs: [UITableViewCell.Type]? { nil }
    public static var tAll: [UITableViewCell.Type]? { nil }
    
    var pageablePropertyPath: WritableKeyPath<SomeModel[Something] >? {nil}}Copy the code

Thus, when a Model follows and implements a ModelType, we can register and configure the cell as follows:

/ / register cell
tableView.registerCells(with: SomeModel.tCells!)
tableView.registerNibs(with: SomeModel.tNibs!)

// Create and configure the cell
func tableView(_ tableView: UITableView.cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(for: indexPath, with: SomeModel.tAll!)
    cell.configure(someModel.data[indexPath.section][indexPath.row])
    return cell
}
Copy the code

Two, the various page refresh operations together

Often we write very similar refresh code for each page, and this process involves a lot of repetition, so a natural question is, is it possible to lump all the refresh operations together?

What is refresh? Refresh means that when the user initiates an action, pulls down refresh or loads more, the page state changes, along with network requests and other operations, finally completes the refresh and the page state stabilizes. Actions, states, operations, ah, this is the perfect time to use a state machine.

What is a state machine? According to Wikipedia, it is “a mathematical model of computation that represents a finite number of states and behaviors such as transitions and actions between those states.” For aggregate refresh purposes, I created a StateMachine, YLStateMachine, that has four parts: StateType, ActionType, StateMachine, and OperatorType. Let’s just focus on the OperatorType here.

  • OperatorType

When the state changes, the state calls methods defined by the OperatorType protocol to perform some operations. It is defined as follows:

public protocol OperatorType {
    associatedtype Action: ActionType
    
    /// called before the transition begins
    func startTransition(_ state: Action.State)
    /// called when transitioning
    func transition(with action: Action.completion: @escaping (Action.State) - >Void)
    // call after the transition ends
    func endTransition(_ state: Action.State)
}

extension OperatorType {
    public func startTransition(_ state: Action.State){}public func endTransition(_ state: Action.State){}}Copy the code
  • RefreshOperator and DataSourceType

For a refresh, there are five normal states: initial state, in the process of refreshing, stable state with more data not loaded, stable state with more and all data loaded, and of course, an error state; There are only two actions: pull down refresh and load more. The key to creating a refresh state machine is to implement RefreshOperator that complies with the OperatorType protocol.

The function of the RefreshOperator is to perform some operations during the refresh process. What do YOU do during the refresh process? Well, it needs to assign the requested data model to a model owner. To do this, we define a DataSourceType:

public protocol DataSourceType {
    associatedtype Model: ModelType
    var model: Model? { get set }
    // Store information that may be needed to update the target
    var targetInfo: Any? { get set}}Copy the code

Next, RefreshOperator can be defined as follows:

open class RefreshOperator<DataSource: DataSourceType> :OperatorType {
    typealias Action = RefreshAction
    
    public var dataSource: DataSource
    
    public init(dataSource: DataSource) {
        self.dataSource = dataSource
    }
    
    open func startTransition(_ state: RefreshState){}open func endTransition(_ state: RefreshState){}// subclasses must override this method
    open func transition(with action: RefreshAction.completion: @escaping (RefreshState) - >Void) {
        fatalError()}}Copy the code

To use the refresh state machine, we need to create a subclass of RefreshOperator, which makes network requests and processes the returned data. The aggregate refresh operation is implemented here, and it looks something like this:

class SomeRefreshOperator<DS: DataSourceType> :RefreshOperator<DS> {
    
    let networkManager = NetworkManager(a)var target: Target = .first(page: 1)
    
    override func transition(with action: RefreshAction.completion: @escaping (RefreshState) - >Void) {
        updateTarget(when: action)
        
        networkManager.request(target: target) {
            [unowned self] (result: Result<DS.Model.Error>) in
            
            switch result {
            case .success(let model):
                switch action {
                case .pullToRefresh:
                    self.dataSource.model = model
                case .loadingMore:
                    self.dataSource.model![keyPath: model.pageablePropertyPath!] + = model[keyPath: model.pageablePropertyPath!]}// Pass the refresh status
                let state: RefreshState = (model.nextPage = = nil) ? .populated : .paginated
                completion(state)
            case .failure(let error):
                completion(self.errorHandling(error))
            }
        }
    }
    
    func updateTarget(when: RefreshAction) {
        .
    }
    
    func errorHandling(_ error: Error) -> RefreshState {
        // Error handling
        .
        return .error(error)
    }
    
}
Copy the code

Three, to achieve the automation of the refresh operation

  • Revisit the RefreshOperator

Observe the SomeRefreshOperator above. In the transition(with: Completion 🙂 method, the processing process after the data is modeled, requiring independent implementation of update targets and error handling. Error handling is optional, so the only thing that needs to be implemented autonomously is the update Target. If we let Target provide its own update interface, RefreshOperator becomes completely stereotyped and we don’t have to customize it. To do this, we need to define Target and NetworkManager.

  • TargetType

Some pages do not need to be refreshed, so the target should have a read-only property isRefreshable to determine whether the page isRefreshable. In addition, the RefreshOperator requires target to have a method that updates itself. In summary, the definition of TargetType is as follows:

public protocol TargetType: Hashable {
    /// Whether a drop-down refresh can be performed.
    var isRefreshable: Bool { get }
    / / / update the target
    mutating func update(with action: RefreshAction.targetInfo: Any?)
}

extension TargetType {
    public var isRefreshable: Bool { true }
    public mutating func update(with action: RefreshAction.targetInfo: Any?){}}Copy the code
  • Update ModelType

Speaking of targets, the data may be paginated, so we need to update ModelType to add a nextPage attribute to it:

public protocol Pageable {
    var nextPage: Int? { get}}extension Pageable {
    public var nextPage: Int? { nil}}extension ModelType: Pageable {}Copy the code
  • NetworkManagerType

RefreshOperator provides methods that networkManager needs to implement, which we protocol here:

public protocol NetworkManagerType {
    associatedtype Target: TargetType
    associatedtype Model: ModelType
    associatedtype E: Error
    associatedtype R
    
    @discardableResult
    func request(target: Target.completion: @escaping (Result<Model.E- > >)Void) -> R
}
Copy the code
  • Rewrite RefreshOperator

With that in mind, we can rewrite the RefreshOperator function as follows:

open class RefreshOperator<
    DS: DataSourceType.NM: NetworkManagerType> :OperatorType where DS.Model= =NM.Model
{
    
    public private(set) var dataSource: DS
    
    public private(set) var networkManager: NM
    
    public private(set) var target: NM.Target
    
    public init(dataSource: DS.networkManager: NM.target: NM.Target) {
        self.dataSource = dataSource
        self.networkManager = networkManager
        self.target = target
    }
    
    open func startTransition(_ state: RefreshState){}open func endTransition(_ state: RefreshState){}open func transition(with action: RefreshAction.completion: @escaping (RefreshState) - >Void) {
        target.update(with: action, targetInfo: dataSource.targetInfo)
        
        networkManager.request(target: target) {
            [unowned self] (result: Result<DS.Model.NM.E>) in
            
            switch result {
            case .success(let model):
                switch action {
                case .pullToRefresh:
                    self.dataSource.model = model
                case .loadingMore:
                    self.dataSource.model![keyPath: model.pageablePropertyPath!] + = model[keyPath: model.pageablePropertyPath!]}// Pass the refresh status
                let state: RefreshState = (model.nextPage = = nil) ? .populated : .paginated
                completion(state)
            case .failure(let error):
                completion(self.errorHandling(error))
            }
        }
    }
    
    open func errorHandling(_ error: Error) -> RefreshState {
        // Error handling
        // ...
        return .error(error)
    }
    
}
Copy the code
  • AutoRefreshable

To implement automatic refresh, we add an automatic refresh method to UIScrollView. With the refresh state machine, it is easy to implement:

public protocol AutoRefreshable {
    func setAutoRefresh<DS: DataSourceType.NM: NetworkManagerType> (refreshStateMachine: StateMachine<RefreshOperator<DS.NM>>
    ) where DS.Model = = NM.Model
}

extension UIScrollView: AutoRefreshable {
    public func setAutoRefresh<DS: DataSourceType.NM: NetworkManagerType> (refreshStateMachine: StateMachine<RefreshOperator<DS.NM>>
    ) where DS.Model = = NM.Model {
        if refreshStateMachine.operator.target.isRefreshable {
            configRefreshHeader { [unowned self] in
                refreshStateMachine.trigger(.pullToRefresh) {
                    // Select display or remove footer based on refresh status
                    switch refreshStateMachine.currentState {
                    case .error:
                        / / make a mistake
                        .
                    case .paginated:
                        // There is a next page
                        .
                    default:
                        // There is no next page
                        .
                    }
                }
            }
            
            configRefreshFooter { [unowned self] in
                refreshStateMachine.trigger(.loadingMore) {
                    // Select display or remove footer based on refresh status
                    switch refreshStateMachine.currentState {
                    case .populated:
                        // There is no next page
                        .
                    default:
                        .}}}}else {
            // There is no need to configure header and footer without refreshing
            refreshStateMachine.trigger(.pullToRefresh)
        }
    }
}
Copy the code
  • Refreshable

What does a refreshed page need? First, it needs a refreshable view; Second, since we are doing the refresh with the refresh state machine, it should also need a refresh state machine as a property; Finally, it also needs to bind the refresh state machine for reloading of the refreshed data. It looks like this:

public protocol Refreshable {
    associatedtype DS: DataSourceType
    associatedtype NM: NetworkManagerType where DS.Model = = NM.Model
    
    var refreshStateMachine: StateMachine<RefreshOperator<DS.NM> >! {get set }
    var refreshableView: UIScrollView? { get set }
    
    func bindRefreshStateMachine(a)
    func bindRefreshStateMachine(_ completion: @escaping() - >Void)
}

extension Refreshable where Self: UIViewController {
    public func bindRefreshStateMachine(a) {
        refreshStateMachine.completionHandler ={[weak self] in
            guard
                let self = self.!self.refreshStateMachine.currentState.isError
            else { return }

            if let tableView = self.refreshableView as? UITableView {
                tableView.separatorStyle = .singleLine
                tableView.reloadData()
            } else if let collectionView = self.refreshableView as? UICollectionView {
                collectionView.reloadData()
            }
        }
    }
    
    public func bindRefreshStateMachine(_ completion: @escaping() - >Void) {
        refreshStateMachine.completionHandler ={[weak self] in
            guard
                let self = self.!self.refreshStateMachine.currentState.isError
            else { return }

            if let tableView = self.refreshableView as? UITableView {
                tableView.separatorStyle = .singleLine
                tableView.reloadData()
            } else if let collectionView = self.refreshableView as? UICollectionView {
                collectionView.reloadData()
            }
            
            completion()
        }
    }
}
Copy the code
  • ViewModel

The refresh operation involves the UITableViewDataSource, and thanks to the effort of the first step, we have been able to model this process. We define the ViewModel class as the base class of the ViewModel for all pages:

open class ViewModel<Model: ModelType> :NSObject.DataSourceType.UITableViewDataSource
{
    // DataSourceType request
    public var model: Model?
    public var targetInfo: Any?
    
    // MARK: - UITableViewDataSource
    
    open func numberOfSections(in tableView: UITableView) -> Int {
        model = = nil ? 0 : model!.data.count
    }
    
    open func tableView(_ tableView: UITableView.numberOfRowsInSection section: Int) -> Int {
        model = = nil ? 0 : model!.data[section].count
    }
    
    open func tableView(_ tableView: UITableView.cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(for: indexPath, with: Model.tAll!)
        cell.configure(model!.data[indexPath.section][indexPath.row])
        
        return cell
    }
    
    open func tableView(_ tableView: UITableView.titleForHeaderInSection section: Int) -> String? {
        nil
    }
    
    open func tableView(_ tableView: UITableView.titleForFooterInSection section: Int) -> String? {
        nil
    }
    
    open func tableView(_ tableView: UITableView.canEditRowAt indexPath: IndexPath) -> Bool {
        false
    }
    
    open func tableView(_ tableView: UITableView.canMoveRowAt indexPath: IndexPath) -> Bool {
        false
    }
    
    open func tableView(_ tableView: UITableView.moveRowAt sourceIndexPath: IndexPath.to destinationIndexPath: IndexPath){}open func tableView(_ tableView: UITableView.sectionForSectionIndexTitle title: String.at index: Int) -> Int {
        0
    }
    
    open func tableView(_ tableView: UITableView.commit editingStyle: UITableViewCell.EditingStyle.forRowAt indexPath: IndexPath){}}Copy the code

Then, when creating the ViewModel for the page, we can do something like this:

class SomeViewModel: ViewModel<SomeModel> {}Copy the code

There is no need to write any refresh code 🥳.

  • TViewController

Finally, since most operations in viewControllers are also stereotypical, we can create a TViewController that follows the Refreshable protocol for our convenience:

open class TViewController<DS: DataSourceType.NM: NetworkManagerType.RO: RefreshOperator<DS.NM> > :UIViewController.Refreshable where DS.Model= =NM.Model {
    
    public var refreshableView: UIScrollView? = UITableView(a)public var refreshStateMachine: StateMachine<RefreshOperator<DS.NM> >!public convenience init(refreshOperator: RO) {
        self.init()
        
        refreshStateMachine = StateMachine(operator: refreshOperator)
        bindRefreshStateMachine()
    }
    
    public convenience init(refreshOperator: RO.afterRefreshed: @escaping() - >Void) {
        self.init()
        
        refreshStateMachine = StateMachine(operator: refreshOperator)
        bindRefreshStateMachine(afterRefreshed)
    }
    
    open override func viewDidLoad(a) {
        super.viewDidLoad()
        
        guard let tableView = refreshableView as? UITableView else { return }
        
        tableView.frame = view.bounds
        tableView.separatorStyle = .none
        // Prevent redundant splitters
        tableView.tableFooterView = UIView()
        tableView.dataSource = refreshStateMachine.operator.dataSource as? UITableViewDataSource
        
        if DS.Model.tCells ! = nil {
            tableView.registerCells(with: DS.Model.tCells!)}if DS.Model.tNibs ! = nil {
            tableView.registerNibs(with: DS.Model.tNibs!)
        }
        
        view.addSubview(tableView)
    }
    
}
Copy the code

You can simply inherit it and call refreshableView setAutoRefresh(refreshStateMachine:) in the viewDidLoad() method:

class SomeViewController: TViewController<SomeViewModel.NetworkManager<SomeModel>, RefreshOperator<SomeViewModel.NetworkManager<SomeModel>>> {
    override func viewDidLoad(a) {
        super.viewDidLoad()
        
        // Some custom operations
        .
        
        // Start refreshing
        refreshableView?.setAutoRefresh(refreshStateMachine: refreshStateMachine)
    }
}
Copy the code

Write in the last

For more details on the refresh automation, see the source code for YLExtensions, YLStateMachine, and YLRefreshKit. Because of the ViewModel’s scene routing capabilities, its implementation is placed in another library, Descomposer.

Finally, welcome to embrace protocol-oriented programming and explore its potential.