Data View Mediator

This article describes an approach to separate some routine code from view controllers.

Working with UITableView or UICollectionView, we often have to write some routine code which repeats from controller to controller. It could be loading indicators, specific footers or supplementary views. The very common solution is to create a base view controller, put such logic there and subclass it.

d1

But what if we can avoid such inheritance? This article describes a possible solution. Just an approach, not a framework, because it’s very project-specific.

Mediator to the rescue!

The main idea is to create a layer between view controller and table/collection view dataSource & delegate.

d2

Example

Let’s take a view controllers with UITableViews. For example we’ll implement a loading indicator for infinite scroll and add UIRefreshControl for data reloading.

Apart from implementing UITableViewDataSource & UITableViewDelegate methods (see diagram) our controllers should be able to:

  • refresh loaded data if one uses UIRefreshControl
  • load next page if one reaches a table’s end while scrolling

So we put refresh/scroll handling to the mediator and it’s absolutely doesn’t matter what data we load, how we load, how it should be rendered and so on.

Implementation

We’ll do a very easy implementation to show the approach.

The main part is proxying:

  • the mediator exposes its own protocols TableViewMediatorDataSource & TableViewMediatorDelegate
  • the mediator sets itself as a table dataSource & delegate
  • the mediator proxies every unhandled call from table protocols to its own protocols

d3

public class TableViewMediator: NSObject {

    private var tableView: UITableView!

    private(set) weak var dataSource: TableViewMediatorDataSource?
    private(set) weak var delegate: TableViewMediatorDelegate?

    private var refreshControl: UIRefreshControl?

    public init(tableView: UITableView) {
        super.init()
        self.tableView = tableView
    }

    public func setDataSource(dataSource: TableViewMediatorDataSource, delegate: TableViewMediatorDelegate?) {
        self.dataSource = dataSource
        self.delegate = delegate

        tableView.dataSource = self
        tableView.delegate = self

        refreshControl = UIRefreshControl()
        refreshControl!.addTarget(self, action: "refreshControlDidChangeValue:", forControlEvents: .ValueChanged)
        tableView.addSubview(refreshControl!)
    }

}

The mediator protocols

public protocol TableViewMediatorDataSource: UITableViewDataSource {
}


@objc public protocol TableViewMediatorDelegate: UITableViewDelegate {

    // more: true - load next page, false - reload data
    optional func tableView(tableView: UITableView, mediator: TableViewMediator, shouldLoadMore more: Bool) -> Bool

}

Both protocols are subclassed from corresponding UITableView protocols.

Time to proxy

public class TableViewMediator: NSObject {

    ...

    // MARK: Proxy

    public override func respondsToSelector(aSelector: Selector) -> Bool {
        return (super.respondsToSelector(aSelector)
            || (dataSource?.respondsToSelector(aSelector) ?? false)
            || (delegate?.respondsToSelector(aSelector) ?? false))
    }

    public override func forwardingTargetForSelector(aSelector: Selector) -> AnyObject? {
        if dataSource?.respondsToSelector(aSelector) == true {
            return dataSource
        }
        if delegate?.respondsToSelector(aSelector) == true {
            return delegate
        }
        return super.forwardingTargetForSelector(aSelector)
    }

}

Conforming the UITableView protocols

extension TableViewMediator: UITableViewDataSource {

    public func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        var count = 1 // by default (see apple doc)
        if let num = dataSource?.numberOfSectionsInTableView?(tableView) {
            count = num
        }
        loadingIndicatorSection = count++ // add additional section for the loading indicator
        return count
    }

}

Notice we add an additional property:

private(set) var loadingIndicatorSection: Int?

The loading indicator cell:

private static let LoadingCellIdentifier = "MediatorLoadingCell"

...

tableView.registerClass(UITableViewCell.self, forCellReuseIdentifier: TableViewMediator.LoadingCellIdentifier)

Completing the data source:

extension TableViewMediator: UITableViewDataSource {

    ...

    public func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if section == loadingIndicatorSection {
            return 1 // just one cell
        }
        // external numbers
        return dataSource!.tableView(tableView, numberOfRowsInSection: section)
    }

    public func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        if indexPath.section == loadingIndicatorSection {
            let cell = tableView.dequeueReusableCellWithIdentifier(TableViewMediator.LoadingCellIdentifier, forIndexPath: indexPath)
            if let textLabel = cell.textLabel {
                textLabel.textColor = UIColor.redColor()
                textLabel.text = "Loading..."
                textLabel.textAlignment = .Center
            }
            cell.selectionStyle = .None
            return cell
        }
        // external cells
        return dataSource!.tableView(tableView, cellForRowAtIndexPath: indexPath)
    }

}

The loading indicator height:

extension TableViewMediator: UITableViewDelegate {

    public func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
        if indexPath.section == loadingIndicatorSection {
            if shouldShowLoadingIndicator { // visible
                return 30
            }
            return 0 // hidden
        }
        // external heights
        if let rowHeight = delegate?.tableView?(tableView, heightForRowAtIndexPath: indexPath) {
            return rowHeight
        }
        // default height
        return tableView.rowHeight
    }

}

shouldShowLoadingIndicator:

private(set) var loadingState = false
private var shouldShowLoadingIndicator: Bool {
    // don't show the loading indicator if the refresh control is in use
    return (loadingState || refreshControl?.refreshing == true)
}

Methods to set loadingState and UIRefreshControl handling:

extension TableViewMediator {

    private func tryLoadMore(more: Bool) -> Bool {
        if let result = self.delegate?.tableView?(tableView, mediator: self, shouldLoadMore: more) {
            return result
        }
        return false
    }

    public func startLoadingMore(more: Bool, updatesTable: Bool = true) {
        if !loadingState && refreshControl?.refreshing == false {
            loadingState = tryLoadMore(more)
            if updatesTable {
                tableView.reloadData()
            }
        }
    }

    public func stopLoading(updatesTable updatesTable: Bool = true) {
        refreshControl?.endRefreshing()
        if loadingState {
            loadingState = false
            if updatesTable {
                tableView.reloadData()
            }
        }
    }

    @objc private func refreshControlDidChangeValue(refreshControl: UIRefreshControl) {
        if loadingState || !refreshControl.refreshing {
            return
        }
        loadingState = tryLoadMore(false) // more = false
        if (!loadingState) {
            refreshControl.endRefreshing()
        }
    }

}

Start & stop methods are public, so a controller can use them like:

  1. start loading - show loading indicator via the mediator
  2. handle loading completion - stop loading - hide loading indicator via the mediator

Infinite scroll:

extension TableViewMediator: UITableViewDelegate {

    ...

    public func scrollViewDidScroll(scrollView: UIScrollView) {
        // don't forget an external delegate
        delegate?.scrollViewDidScroll?(scrollView)
        if shouldShowLoadingIndicator {
            return
        }
        if scrollView.contentOffset.y > 0 {
            let value = (scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.bounds.height - max(scrollView.contentInset.top, 0) - max(scrollView.contentInset.bottom, 0))
            // the end is here
            if value <= 0 {
                startLoadingMore(true) // more = true
            }
        }
    }

}

Controller

We got a simple mediator now! Let’s write a controller:

class ViewController: UIViewController {

    private var tableView: UITableView!
    private var mediator: TableViewMediator!

    override func loadView() {
        super.loadView()

        tableView = ...

        view.addSubview(tableView)

        mediator = TableViewMediator(tableView: tableView)
        mediator.setDataSource(self, delegate: self)
    }

    override func viewWillAppear(animated: Bool) {
        super.viewWillAppear(animated)

        mediator.startLoadingMore(false) // shows indicator & calls delegate's tableView(_:mediator:shouldLoadMore:)
    }

}

extension ViewController: TableViewMediatorDataSource {

    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        // as usual
        ...
    }

    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        // as usual
        ...
    }

}

extension ViewController: TableViewMediatorDelegate {

    func tableView(tableView: UITableView, mediator: TableViewMediator, shouldLoadMore more: Bool) -> Bool {
        // load data and return true, return false if loading is impossible
        ...
    }

}

See full example here