Sansan Tech Blog

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

Sansan iOS メジャーアップデートの舞台裏

Eight 事業部 iOS エンジニアの河辺です。2019年2月から2019年4月までの2ヶ月間、 Sansan の iOS チームに異動し、先日メジャーアップデートした Sansan iOS の開発をしていました。 Sansan iOS ではメジャーアップデートに伴って、アーキテクチャ刷新や開発基盤の構築を行いました。この記事では、開発基盤の構築の内の一つである UI Component 化の取り組みを紹介したいと思います。

UI Component

UI Component という言葉が出てきましたが、この記事では アプリケーションの画面を構成する UI 部品 を UI Component と定義します。

UI Component 化の取り組み前の課題

以下の 2 つの画面の氏名を表示する UILabel、 会社名, 部署, 役職を表示する UILabel は Style が全く同じという特徴があります。ですが、各 UILabel で Style の設定がされており、共通化できていませんでした。 UILabel に限らず、 UITextView, UIButton, UIImageView, UISearchBar においても同様に共通化できていませんでした。

名刺一覧画面 同僚一覧画面
f:id:m-kawabe:20190508092030p:plain:w275 f:id:m-kawabe:20190508092105p:plain:w275
画像はデモデータです


共通化できていないことによって以下の課題がありました。

  • 一つ一つ Style を設定する必要があり非効率
  • デザイン定義に変更があった際の修正作業が非効率
  • Style を設定する回数が多い分、設定ミスをする可能性も増える

UI Component 化の目的

UI Component 化の目的は大きく分けて以下の 2 つがあります。

  • 開発の効率化
  • デザインの統一感・整合性の担保

開発の効率化 だと少し抽象度が高いので、もう少し具体的に定義すると以下の 3 つになります。

  • カプセル化されている
  • 再利用可能
  • 置換可能

デザインの統一感・整合性の担保 も同様に抽象度が高いので、具体的に定義すると以下の 2 つになります。

  • スタイルガイドの定義
  • スタイルガイドに沿った UI 実装

スタイルガイドの定義

Sansan iOS では今回のメジャーアップデートを期に、スタイルガイドを定義しました。今回はいくつか定義したスタイルガイドの内の 色を定義した SansanColor, テキストの Style を定義した TextStyle を紹介します。

色を定義した SansanColor

今回のメジャーアップデートを期に、 Sansan iOS で利用する色を再定義しました。本当に必要な色だけを定義するきっかけが生まれるのも、 UI Component 化のメリットだと考えられます。 SansanColor では、エンジニア・デザイナーの双方にとってわかりやすい名前を一つ一つの色に対して付けています。利用する箇所が明白な色は 利用箇所/色/ユニーク名(例 : backgroundGrayMainDuck) 、利用する箇所が複数ある色は 色/ユニーク名(例 : grayMainRat) というルールで命名しています。 (ちなみに、私のお気に入りの色は stateAttentionFire です)

f:id:m-kawabe:20190513175832p:plain:w200
SansanColor の定義の一部

実装では、色を SansanColor という class の static let で定義しています。また、コード上で直感的に色がわかるカラーパレットで色の指定ができる という理由から #colorLiteral で色を定義しています。

final class SansanColor {
    static let brandIdentitiesSansanBlue = #colorLiteral(red: 0, green: 0.3058823529, blue: 0.5960784314, alpha: 1)
}

テキストの Style を定義した TextStyle

Sansan iOS では、 font size, font color, font weight の 3 つの組み合わせに対して名前を付けて定義しています。命名ルールとして以下の 3 つが考えられます。

  • font size, font color, font weight を直接的に命名(例 : black14bold)
    • Pros : 名前から Style がイメージしやすい
    • Cons : 変更に弱い
  • font size, font color, font weight を抽象的に命名(例 : body)
    • Pros : 変更に強い
    • Cons : 名前から Style がイメージしにくい
  • font size, font color, font weight を利用箇所に応じて命名(例 : feedBody)
    • Pros : 実装時に、どの TextStyle を設定すると良いかイメージしやすい
    • Cons : 利用箇所毎に TextStyle を定義する必要がある

この 3 つの選択肢の中から Pros, Cons を加味して、 2 つ目の font size, font color, font weight を抽象的に命名(例 : body) を採用することにしました。 名前から Style がイメージしにくい という Cons に関しては、デザイナーがデザイン仕様に TextStyle 名を記載し、エンジニアは TextStyle 名だけを意識するだけで済むようにすることで解決しました。 実装では、 TextStyle を Enum で定義しています。各 case では font size, font color, font weight のタプルを返すようにしています。

enum TextStyle: String {
  case body
  case caption
  case date

  private typealias StyleDefinition = (size: CGFloat, color: UIColor, weight: UIFont.Weight)

  private var definition: StyleDefinition {
    switch self {
      case .body:
        return (size: 14.0, color: SansanColor.blackPanther, weight: .regular)
      case .caption:
        return (size: 11.0, color: SansanColor.grayMainElephant, weight: .regular)
      case .date:
        return (size: 12.0, color: SansanColor.grayMainElephant, weight: .regular)
    }
  }

  var font: UIFont {
    return UIFont.systemFont(ofSize: definition.size, weight: definition.weight)
  }

  var color: UIColor {
    return definition.color
  }
}

スタイルガイドに沿った UI 実装

Sansan iOS では、スタイルガイドに沿った UI 実装をするために、 UILabel, UITextView, UIButton, UIImageView, UISearchBar を UI Component 化しています。今回は UILabel を UI Component 化した SansanLabel, UIButton を UI Component 化した SansanButton の 2 つを紹介します。

SansanLabel

SansanLabel は @IBInspectable で前述した TextStyle を受け取るプロパティを定義しています。また、 SansanLabel を @IBDesignable で定義し、設定した TextStyle を Interface Builder 上に反映させるようにしています。

@IBDesignable
final class SansanLabel: UILabel {
  @IBInspectable
  var style: String = "body01" {
    didSet {
        setTextFont()
    }
  }

  required init?(coder aDecoder: NSCoder) {
    super.init(coder: aDecoder)
    setTextFont()
  }

  override init(frame: CGRect) {
    super.init(frame: frame)
    setTextFont()
  }
}

private extension SansanLabel {
  var textStyle: TextStyle {
    #if DEBUG
    guard TextStyle(rawValue: style) != nil else {
        fatalError("\(style) は定義されていない TextStyle です")
    }
    #endif

    return TextStyle(rawValue: style) ?? TextStyle.body
  }

  func setTextFont() {
    font = textStyle.font
    textColor = textStyle.color
  }
}

SansanButton

SansanButton は @IBInspectable で Button の Type を受け取るプロパティを定義しています。また、 SansanButton を @IBDesignable で定義し、設定した Button の Type を Interface Builder 上に反映させるようにしています。

@IBDesignable
final class SansanButton: UIButton {
    @IBInspectable
    var type: String = "main" {
        didSet {
            setStyle()
            setTextStyle()
            setCornerRadius()
        }
    }
}

private extension SansanButton {
    enum ButtonType: String {
        case main
        case sub

        var backgroundColor: UIColor {
            switch self {
            case .main: return SansanColor.brandIdentitiesProductBlue
            case .sub: return UIColor.clear
            }
        }

        var borderWidth: CGFloat {
            switch self {
            case .main: return 0.0
            case .sub: return 1.0
            }
        }

        var borderColor: UIColor {
            switch self {
            case .main: return UIColor.clear
            case .sub: return SansanColor.brandIdentitiesProductBlue
            }
        }

        var textStyle: TextStyle {
            switch self {
            case .main: return TextStyle.action04
            case .sub: return TextStyle.action01
            }
        }

        var cornerRadius: CGFloat {
            switch self {
            case .main, .sub: return 4.0
            }
        }
    }

    func setStyle() {
        let buttonType = ButtonType(rawValue: type) ?? ButtonType.main
        backgroundColor = buttonType.backgroundColor
        layer.borderWidth = buttonType.borderWidth
        layer.borderColor = buttonType.borderColor.cgColor
    }

    func setTextStyle() {
        let buttonType = ButtonType(rawValue: type) ?? ButtonType.main
        titleLabel?.font = buttonType.textStyle.font
        setTitleColor(buttonType.textStyle.color, for: .normal)
    }

    func setCornerRadius() {
        let buttonType = ButtonType(rawValue: type) ?? ButtonType.main
        layer.cornerRadius = buttonType.cornerRadius
        clipsToBounds = true
    }
}

関心の分離

このように UI Component の定義をクラスで行うことで、 Storyboard の関心が明確になります。 Storyboard では View を配置する , 配置した View にクラスを設定する ことだけに徹することができ、各 View の Style の定義はクラスに委ねることができます。つまり、 今回定義した SansanLabel や SansanButton は Style の定義に徹することができます。

f:id:m-kawabe:20190508092844p:plain:w680

Style の定義を Storyboard で行うことも可能ですが、各画面で一つ一つ設定することは開発効率が悪く、ミスをする可能性もあります。その結果、 似ているけど微妙に違う UI を作ってしまうことになりかねません。例として、 SansanButton を見てみましょう。以下の量の Style を各画面で毎回設定することは現実的ではありません。

f:id:m-kawabe:20190508093836p:plain:w120

backgroundColor = UIColor.clear
titleLabel?.font = UIFont.systemFont(ofSize: 14.0, weight: .regular)
setTitleColor(SansanColor.brandIdentitiesProductBlue, for: .normal)

layer.borderWidth = 1.0
layer.borderColor = SansanColor.brandIdentitiesProductBlue
layer.cornerRadius = 4.0
clipsToBounds = true

Interface Builder 上の見た目と実行結果を一致させる

SansanLabel や SansanButton の UI Component を @IBDesignable で定義し、 Style を Interface Builder 上に反映させることのメリットに 動作確認に掛かる時間を短縮できる ということが挙げられます。コードで Style を定義している場合は、 Storyboard を見ただけでは正しく UI 実装ができているかを判断することはできません。そのため、アプリをビルドして目的の画面まで遷移して、正しく UI 実装されていることを確認する必要があります。しかし、 Style を変更するたびにこの作業を繰り返すことは効率的ではありません。 すべてを Storyboard で確認できるようにすることは難しいケースもありますが、極力、 Interface Builder 上の見た目と実行結果を揃えることで、動作確認に掛ける時間を短縮しています。

モジュールの分割

@IBDesignable で定義した UI Component を Interface Builder 上に反映させるために、 @IBDesignable で定義した UI Component が含まれるモジュールをビルドする必要があります。そのため、モジュールのサイズが大きいとビルド時間も長くなります。 モジュールが分割されていなければビルド時間も長くなり、 Interface Builder 上の見た目と実行結果が揃うことで、開発を効率化できる というメリットが失われてしまいます。 そこで Sansan iOS では、 UI Component を SansanUIComponent モジュールに分割しています。こうすることで、プレビューに掛かる時間を数秒に抑えています。

f:id:m-kawabe:20190508094048p:plain:w280

まとめ

Sansan iOS のメジャーアップデートの舞台裏を少しだけ紹介しました。この記事が何かの参考になれば幸いです。また、今後も改善していきたいと考えていますので もっと良い方法があるよ! というお話がある場合は、ぜひ教えて頂きたいです。

ここで書いた内容は Sansan iOS チームの成果であり、私一人の成果ではありません。 Sansan iOS チームの成果を私が代表して書いています。

© Sansan, Inc.