[SC]()

iOS. Apple. Indies. Plus Things.

Empty View With Diffable Datasource

// Written by Jordan Morgan // Jan 22nd, 2020 // Read it in about 3 minutes // RE: Tech Notes

This post is brought to you by Emerge Tools, the best way to build on mobile.

Among my favorite APIs introduced at W.W.D.C. is without a doubt the new diffable data source for both table and collection views. Replacing a decade old protocol, it brings about a robust way of expressing what should show when, where the truth is and, finally, a way to forgo the death trap that is batch updates.

While rewriting the list view of Spend Stack, I immediately become aware of its benefits:

As far as user experience, batch updates are far superior to the sledgehammer approach of reloadData, so this is a needed step forward. But, thatā€™s only part of it. Iā€™m personally a believer in showing ā€œempty viewā€ states, and if you peek into Appleā€™s stock apps - they are too. Hereā€™s an example of a bag with no items in the Apple Store app:

Previously, I used associated objects and the Objective-C Funtime to do the same thing in Spend Stack:

But hey - Iā€™m being all swifty now, right? So Iā€™ve been thinking about how to do the same with my new bestie, diffable data source while forgoing swizzling. Hereā€™s what Iā€™ve got so far:

func createSubscriptions() {
    let nc = NotificationCenter.default

    // Other subs..

    // The one when a list is mutated
    let tvAnim = Int(SSTableViewBatchUpdateAnimation)
    let listCRUD = nc.publisher(for: .listCRUD)
    .compactMap { $0.object as? SSList }
    .debounce(for: .milliseconds(tvAnim), scheduler: RunLoop.main)
    .sink { [unowned self] list in
        self.showEmptyViewIfNeeded()
    }
    
    subscriptions.append(listCRUD)
}

This function runs when I initialize my subclassed diffable data source (to provide for things like tableView(_ tableView: UITableView, moveRowAt sourceIndexPath:, to:)). When Combine fires off the closure, I simply check if the data is empty:

func showEmptyViewIfNeeded() {
    guard let view = emptyView, let tv = tableView else { return }
    let shouldShow = snapshot().itemIdentifiers.isEmpty && view.superview == nil

    if shouldShow {
        guard let vc = tv.closestViewController() else { return }
        vc.view.addSubview(view)

        if let constraints = self.emptyViewConstraints {
            view.snp.remakeConstraints { make in
                constraints(make)
            }
        }
        } else {
            view.removeFromSuperview()
        }
    }

This all works, but there are some things that need to improve. Notably, there are two core properties on my data source that need to be abstracted out in some fashion:

// Properties on data source object
var emptyView:UIView?
var emptyViewConstraints:((_ make: ConstraintMaker) -> Void)?

// Then later on, when initializing in a view controller or something similar
dataSource.emptyView = SSEmptyStateView(stateText: ss_Localized("list.vc.empty"))
dataSource.emptyViewConstraints = { [unowned self] make in
    let lg = self.view.safeAreaLayoutGuide
    make.top.equalTo(lg.snp.top)
    make.bottom.equalTo(self.toolBar.snp.top)
    make.centerX.equalTo(lg.snp.centerX)
    make.width.equalTo(lg.snp.width)
}

Currently, the empty data view only works with this particular instance. I already know Iā€™ll need it for the rest of the app, too. As I mentioned above, in Objective-C I have an EmptyDataSetDelegate protocol which is tacked onto any collection or table view via associated objects.

I could do something similar, I suppose. Here are some thoughts so far.

I could use a protocol so other types could do something similar:

protocol EmptyDataViewProviding {
    var emptyView:UIView? { get set }
    var emptyViewConstraints:((_ make: ConstraintMaker) -> Void)? { get set }
    
    func showEmptyDataView() -> Void
}

Plus, with protocol extensions I could vend a default implementation.

Next, I could simply extend the type. But without associated objects, Iā€™d lose the two properties I need that describe the view and how it should be constrained.

extension UITableViewDiffableDataSource {
    func showEmptyView() {
        if snapshot().itemIdentifiers.isEmpty {
            // Do stuff
        }
    }
}

At first, I attempted this route by trying to include the properties using a property wrapper implementation, but thatā€™s not allowed in extensions as far as I can tell:

extension UITableViewDiffableDataSource {
    @AssociatedObject var:emptyView?
    @AssociatedObject var:emptyViewConstraints:((_ make: ConstraintMaker) -> Void)?

    func showEmptyView() {
        if snapshot().itemIdentifiers.isEmpty {
            // Do stuff
        }
    }
}

Or, perhaps plain old object oriented programming is the answer? Simply a base type that others extend. Krusty would be so disappointed in me though.

class BaseDiffable : UITableViewDiffableDataSource<SSListTag, SSListItem> {
    var emptyView:UIView?
    var emptyViewConstraints:((_ make: ConstraintMaker) -> Void)?
    
    override func apply(_ snapshot: NSDiffableDataSourceSnapshot<SSListTag, SSListItem>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) {
        super.apply(snapshot, animatingDifferences:animatingDifferences, completion: completion)
        if snapshot.itemIdentifiers.isEmpty {
            // Show empty view
        }
    }
}

I canā€™t decide which I like the most. In the end, my existing Objective-C solution bridges over fine, but if I can avoid swizzling batch updates, I will.

Iā€™ll follow up on Twitter with where I end up.

Until next time āœŒļø.

Ā·Ā·Ā·

Spot an issue, anything to add?

Reach Out.