Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Sansan iOS アプリにおけるエラー表示周りのソースコード解説

こんにちは、技術本部 Mobile Application グループの山名です。
普段は Sansan iOS チームで iPhone / iPad アプリを開発しています。

今回は Sansan iOS アプリ(以下、弊アプリ)におけるエラー表示周りのソースコードを解説します。

弊アプリのエラー画面 UI とその構成

ソースコードの前に、まずは UI を紹介します。
弊アプリのエラー画面は、以下の画像のような構成になっています。
これが基本形で、403 のような再試行の必要性がない画面では再試行ボタンを削除する、画面の一部のような狭い箇所に表示する際はアイコンを削除するなど、利用状況に応じてカスタマイズします。

エラー画面の UI

エラー表示周りのソースコード

それではソースコードの解説に移ります。
最初に全体像をお伝えしますと、以下の要素で構成されています。

  • FillBodyErrorEmptyPresentationModel:エラー画面の構成要素を決めるためのモデル
  • FillBodyErrorEmptyView:モデルを元に UI を構築する View
  • FillBodyErrorEmptyViewDisplayable:エラーを表示する UIView, UIViewController が適合するプロトコルと、そのデフォルト実装

ここからはそれぞれの詳細について解説します。 (※ 文量の都合でコードの一部を省略しています)

FillBodyErrorEmptyPresentationModel

エラー画面の構成要素を決めるためのモデルで、UI の構成に必要なプロパティを持ちます。

struct FillBodyErrorEmptyPresentationModel {
    typealias RetryHandler = () -> Void

    let icon: UIImage?
    let title: String?
    let message: String?
    let retryButtonTitle: String?
    let retryHandler: RetryHandler?
}

とはいえ、エラー表示はパターン化されていることが多いです。 そのため、弊アプリにおけるエラーパターンを羅列した Template という enum を定義し、それを用いてモデルを初期化します。 また Template は Preset という enum を内部に持ち、403 やネットワークエラーなど、アプリ全体で使用するモデルを簡単に作成できるようにしています。

struct FillBodyErrorEmptyPresentationModel {
    typealias RetryHandler = () -> Void

    let icon: UIImage?
    let title: String?
    let message: String?
    let retryButtonTitle: String?
    let retryHandler: RetryHandler?

    // ★ エラーパターンを管理する enum
    enum Template {
        // ★ アプリ全体で使用するモデルの生成を共通化するための enum
       enum Preset {
            case forbiddenError
            ...
        }

        case preset(Preset)
        case message(icon: UIImage, title: String)
        case retry(icon: UIImage, title: String, message: String, retryButtonTitle: String, retryHandler: RetryHandler)
        ...
    }

    init(_ template: Template) {
        switch template {
        case let .message(icon, title):
            self.init(...)
        ...
        case let .preset(preset):
            switch preset {
            case .forbiddenError:
                self.init(.message(...))
            ...
            }
        }
    }
}

たとえば、 FillBodyErrorEmptyPresentationModel(.preset(.forbiddenError)) とすることで API 通信で 403 が返ってきた時の共通のエラー表示用のモデルを初期化することができます。

FillBodyErrorEmptyView

モデルを元に UI を構築する View です。

レイアウトは xib で構築していますが、見たままの UI を StackView で構築しているだけなので、申し訳なくも省略させていただきます。
コードでは apply(presentationModel:) を通して先ほど紹介した PresentationModel を受け取り、各値の有無を見て View への反映と isHidden の切り替えによるトルツメを行っています。

final class FillBodyErrorEmptyView: UIView {
    @IBOutlet private weak var rootStackView: UIStackView!
    @IBOutlet private weak var titleLabel: UILabel!
    ...

   func apply(presentationModel model: FillBodyErrorEmptyPresentationModel) {
        if let title = model.title {
            titleLabel.text = title
            titleLabel.isHidden = false
        } else {
            titleLabel.isHidden = true
        }
        ...
    }
}

FillBodyErrorEmptyViewDisplayable

エラーを表示する UIView, UIViewController が適合するプロトコルと、そのデフォルト実装です。

デフォルト実装に関しては利用頻度が高い UIViewController のみ用意しています。 内容としては FillBody という名前の通り対象の View に addSubview し、上下左右端に制約を付けるというシンプルなものです。 ただ UIScrollView に関しては直接 addSubview するとインタラクションを拾ってしまい、インジケータが表示されるなどの問題が発生するため、addSubview する先を UIViewController.view にしています。

show(fillBodyErrorEmptyView:on:animated:) は実装が2つありますが、これは UIScrollView の場合 show(fillBodyErrorEmptyView:sameFrameAs:animated:) を使うことを促すためです。 UIScrollView は UIView のサブクラスなので両方のメソッドを使用できますが、available の Attribute を付与すると使用箇所に Warning が出るので、ビルド時や CI のチェック時に誤った利用に気づくことができます。

protocol FillBodyErrorEmptyViewDisplayable {
    func show(fillBodyErrorEmptyView: FillBodyErrorEmptyView, sameFrameAs scrollView: UIScrollView, animated: Bool)
    func show(fillBodyErrorEmptyView: FillBodyErrorEmptyView, on targetView: UIView, animated: Bool)
    func hide(fillBodyErrorEmptyView: FillBodyErrorEmptyView, animated: Bool)
}

extension FillBodyErrorEmptyViewDisplayable where Self: UIViewController {
    @available(*, deprecated, message: "use show(fillBodyErrorEmptyView:sameFrameAs:)")
    func show(fillBodyErrorEmptyView: FillBodyErrorEmptyView, on view: UIScrollView, animated: Bool) {
        show(fillBodyErrorEmptyView: fillBodyErrorEmptyView, on: view as UIView, animated: animated)
    }

    /// fillBodyErrorEmptyView を view と同じ位置、同じサイズになるようにし、view に addSubView する
    func show(fillBodyErrorEmptyView: FillBodyErrorEmptyView, on targetView: UIView, animated: Bool) {
        fillBodyErrorEmptyView.show(on: targetView, animated: animated, setConstraints: {
            fillBodyErrorEmptyView.fill(to: targetView)
        })
    }

    /// fillBodyErrorEmptyView を scrollView と同じ位置、同じサイズになるようにし、UIViewController.view に addSubView する
    func show(fillBodyErrorEmptyView: FillBodyErrorEmptyView, sameFrameAs scrollView: UIScrollView, animated: Bool) {
        fillBodyErrorEmptyView.show(on: view, animated: animated, setConstraints: {
            fillBodyErrorEmptyView.fill(to: scrollView)
        })
    }

    func hide(fillBodyErrorEmptyView: FillBodyErrorEmptyView, animated: Bool) {
        fillBodyErrorEmptyView.hide(animated: animated)
    }
}

private extension FillBodyErrorEmptyView {
    private var animationDuration: Double { 0.3 }

    func show(on view: UIView, animated: Bool, setConstraints: () -> Void) {
        view.addSubview(self)
        translatesAutoresizingMaskIntoConstraints = false
        setConstraints()
        alpha = 0
        superview?.bringSubviewToFront(self)

        if animated {
            UIView.animate(
                withDuration: animationDuration,
                animations: { [weak self] in
                    self?.alpha = 1
                }
            )
        } else {
            alpha = 1
        }
    }

    func hide(animated: Bool) {
        let completion: (Bool) -> Void = { [weak self] finished in
            if finished {
                self?.removeFromSuperview()
            }
        }

        if animated {
            UIView.animate(
                withDuration: animationDuration,
                delay: 0.25,
                animations: { [weak self] in
                    self?.alpha = 0
                },
                completion: { completion($0) }
            )
        } else {
            alpha = 0
            completion(true)
        }
    }

    func fill(to view: UIView) {
        NSLayoutConstraint.activate([
            topAnchor.constraint(equalTo: view.topAnchor),
            trailingAnchor.constraint(equalTo: view.trailingAnchor),
            bottomAnchor.constraint(equalTo: view.bottomAnchor),
            leadingAnchor.constraint(equalTo: view.leadingAnchor)
        ])
    }
}

実際の利用イメージ

最後に、これまで紹介したエラー表示周りの仕組みを実際の画面で使用するイメージを紹介します。
といってもデフォルト実装のおかげで非常に簡単で、ViewController をプロトコルに適合させ、FillBodyErrorEmptyView に PresentationModel を適用し、show を呼ぶだけです。

final class HogeViewController: UIViewController {
    private lazy var fillBodyErrorEmptyView = FillBodyErrorEmptyView()

    func showForbiddenError() {
        fillBodyErrorEmptyView.apply(presentationModel: .init(.preset(.forbiddenError)))
        show(fillBodyErrorEmptyView: fillBodyErrorEmptyView, on: view, animated: true)
    }
}

extension HogeViewController: FillBodyErrorEmptyViewDisplayable {}

おわりに

以上、『Sansan iOS アプリにおけるエラー表示周りのソースコード解説』でした。 少しでも皆様の参考になれば幸いです。

最後まで読んでいただきありがとうございました。

© Sansan, Inc.