DZNEmptyDataSetを使わずにEmptyStateを実装する


DZNEmptyDataSetは2014年6月(執筆時点で6年前)からある、EmptyStateを表示するためのライブラリで、今も現役で利用されている素晴らしいライブラリです。

ですが、「ちょっとEmptyStateの表示したいだけなのにライブラリを入れるほどでもないし...」とか「SPMに寄せたいけど、DZNEmptydataSetはSPM対応のPRまだマージしてなくて、stableだとCocoaPodsかCarthageしか使えないし...」とか「Method Swizzlingが怖い...」などなど、自作を試みたい動機がいくつかあると思います。

そこで、簡単に自作する方法を紹介したいと思います。SwiftUIではifで切り分けて表示するだけで、非常に簡単なため今回は扱いません。

実装: Delegate

まず、このようにEmptyStateで表示したいラベルやViewを用意します。今回はラベルのみです。

YourViewController.swift

    let emptyLabel: UILabel = {
        let label = UILabel(frame: .zero)
        label.font = UIFont.preferredFont(forTextStyle: .title1)
        label.textColor = UIColor.secondaryLabel
        label.textAlignment = .center
        label.text = "Text that you want to display"
        label.isUserInteractionEnabled = false
        return label
    }()

そのEmptyState用のViewを、スクリーンの高さと等しいViewに対して追加します。その理由としては、ツールバーなどが下部に含まれていると画面中心より上部にEmptyStateのViewが表示されてしまい(centerが高さ分ズレるため)、ビジュアルに違和感が生じるためです。今回はnavigationController?.viewとしました。(ご自分の環境に合わせて適切にViewの選択をお願いします)

この実装ではViewDebuggerを見ると、emptyLabelが最前面に存在している形になります。

YourViewController.swift
navigationController?.view.addSubview(emptyLabel)
navigationController?.view.bringSubviewToFront(emptyLabel)

emptyLabel.equalToParent()

レイアウトを簡単にするために、いくつかextensionを追加しています。

Constraint.swift

extension UIView {

    @_functionBuilder
    public struct ConstrainsBuilder {
        static func buildBlock(_ constraints: NSLayoutConstraint...) -> [NSLayoutConstraint] {
            constraints
        }
    }

    public func makeConstraints(@ConstrainsBuilder builder: (UIView) -> [NSLayoutConstraint]) {
        translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate(builder(self))
    }

    /// - Precondition: The superView should not be nil.
    public func equalToParent() {
        precondition(superview != nil)

        self.makeConstraints {
            $0.leadingAnchor.constraint(equalTo: superview!.leadingAnchor)
            $0.trailingAnchor.constraint(equalTo: superview!.trailingAnchor)
            $0.topAnchor.constraint(equalTo: superview!.topAnchor)
            $0.bottomAnchor.constraint(equalTo: superview!.bottomAnchor)
        }

    }

}

あとはテーブルやコレクションのデータソースで出し分けをするのみです。今回はテーブルの実装を解説します。

データソースの変更の際に呼ばれるtableView(_:numberOfRowsInSection:) -> Intの内部で、その時返すデータ数に応じて、isHiddenの切り替えを行うと期待する挙動をします。

YourViewController+DataSource.swift

extension YourViewController {

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        let numberOfRows = yourItems.count ?? 0

        if numberOfRows == 0 {
            emptyLabel.isHidden = false
        } else {
            emptyLabel.isHidden = true
        }

        return numberOfRows
    }
}

実装: Combine

できる限りnumberOfRowsInSectionはデータ数だけを返したいので、Combineフレームワークが利用できる場合は、できる限りそちらの方が良いです。Publisherを使えばviewDidLoadなどで記述できます。例えば以下のような形です。

Publisher.swift

struct DataSource: NSObject {
    @Published
    var items: [String]
}

class ViewController : UIViewController {
    var dataSource: DataSource = .init()

    override func viewDidLoad() {
        dataSource.$items
            .map({ !$0.isEmpty })
            .receive(on: RunLoop.main)
            .assign(to: \.isHidden, on: emptyLabel)
            .store(to: &cancellables)
    }

}

まとめ

多少ソースコードに余分を含むことにはなりますが、ライブラリを使わなくてもデータ件数による切り替えを行うだけでEmptyStateの実現ができました。