この記事はClassi developers Advent Calendar 2021 の 14日目の記事です。
はじめまして。小中事業開発部でモバイルアプリエンジニアをしています拜郷です。
今回は新規開発中サービスのiOSアプリでDesign System1を実装するにあたって考えたことを書いていきます。
Design System導入の背景
小中事業開発部では現在新規サービス tetoru(テトル) のリリースに向けてチーム開発を行なっています。
開発を進めていく中でUXDチームからDesign System導入の検討がされました。
導入の背景としては開発メンバーが増員するタイミングだったのと、デザインツールの移行が決まったのが大きな要因としてありました。
Design Systemはプロダクト、チームそれぞれの視点で以下の目的を実現できると考えています。
- プロダクトを通してユーザに一貫性のある体験を提供できる
- チーム内でデザイン原則を共通認識として持つことができる
- メンテナビリティとスケーラビリティの向上
iOSアプリ開発の背景
iOSアプリは開発初期の段階からUIKitおよびInterface Builder(以下IB)を扱うStoryboard, xibを使用して開発を行なっていました。
Design System導入のためにIBでのレイアウトをやめたりSwiftUIに変更するのは諸々の事情により現実的ではなかったため、前提として開発方針は変えずDesign Systemの実装検討を進めることになりました。
ただしiOSアプリ開発においてUIKitおよびIBはDesign Systemの実装(主にUIコンポーネントの再利用)に適しているとは言えず少々困難です。
以下ではDesign SystemをUIKitで実装するにあたって考えたことを具体例を交えて紹介していきます。
(※今回紹介するソースコードはすべて本記事用のサンプルコードです。)
デザイントークンの実装
まずDesign Systemにおける最小単位であるデザイントークンの実装を考えます。 デザイントークンにはカラー、余白、行間、Elevation(高さ)、タイポグラフィ、シャドウ、アニメーションなど複数のコンポーネントにまたいで使用される情報を定義します。
カラー
カラーの定義はAsset Catalogを使用します。これはXcode9およびiOS11から使える標準機能なので現時点では特に悩まず使えます。
定義したカラーをコード内で使用する場合、UIColorクラスの初期値にAsset Catalogで定義した文字列を指定します。
ここでSwiftGenやR.Swiftを使用することでタイプセーフにリソースを扱えるようになり、タイプミスなどで実行時エラーが発生しなくなる等のメリットがあります。
またAsset CatalogのColorはライト/ダークの各モードをそれぞれ定義することができるため、デザイントークンのカラー定義を双方で検討しておくことでダークモード対応が容易に行えると思います。
// 通常コードからカラーを取得する UIColor(named: "primary") // SwiftGenを使用する Asset.Color.primary.color // R.Swiftを使用する R.color.primary()
タイポグラフィ
タイポグラフィの定義はUIFontをextensionして使用することにしました。
SystemFontを使用する場合は単純にトークン名に紐づいたサイズとウェイトを指定して定義します。
カスタムフォントを使用する場合はサイズ、ウェイトに加えフォントファミリーを指定します。ここでもSwiftGenやR.Swiftを使用することでタイプセーフにリソースを扱えます。
public extension UIFont { static let title: UIFont = .systemFont(ofSize: 44.0, weight: .bold) static let body: UIFont = .systemFont(ofSize: 14.0, weight: .bold) static let caption: UIFont = .italicSystemFont(ofSize: 11.0) static let button: UIFont = .systemFont(ofSize: 14.0, weight: .regular) static let swiftGenSample: UIFont = FontFamily.mplus1.regular.font(size: 14.0) // SwiftGenでカスタムフォントを指定する static let rswiftSample: UIFont = R.font.mplus1pRegular(size: 14.0)! // R.Swiftでカスタムフォントを指定する ... }
レイアウト要素
余白、角丸、高さなどレイアウト要素の数値を扱うデザイントークンはCGFloatをextensionして定義するようにしました。
public extension CGFloat { struct spacing { static let xxx_small: CGFloat = 2 static let xx_small: CGFloat = 4 static let x_small: CGFloat = 8 ... } struct cornerRadius { static let small: CGFloat = 2.0 static let medium: CGFloat = 4.0 static let large: CGFloat = 8.0 ... } }
ただしIBからここで定義したレイアウト要素の情報を直接参照できないという課題があります。
この課題に対する1つの解決策として、IB上で設定したNSLayoutConstraint
をIBOutletを使ってコードと紐づけるという方法があります。
たとえばある画面をレイアウトする際にSafeAreaとコンポーネントの余白に定義したデザイントークンの数値を使いたい場面があるとします。
この場合IB上では仮の値を設定したNSLayoutConstraint
をIBOutletに紐づけます。
あとは紐づけたNSLayoutConstraint
にデザイントークンとして定義した値を設定することができます。
@IBOutlet weak var btnTrailingConstraints: NSLayoutConstraint! { didSet { btnTrailingConstraints.constant = <デザイントークンとして定義した値> } }
しかしこの方法はデメリットも存在します。
特に下記のデメリットはIBを使用するメリットを打ち消すので、今回こちらの方法は採用しませんでした。
- すべての制約をIBOutletに紐づけてコード上で管理するとコード量が肥大化する
- 動的に制約を書き換えることになるのでStoryboard, xib上のレイアウトと実際に表示されるレイアウトに差異が出る
UIコンポーネントの実装
UIコンポーネントはボタンやフォームなどデザインに使用されるUIパーツを定義します。基本的にはここで定義したコンポーネントの組み合わせで各画面のレイアウトが完成します。
実装方針としてはIBDesignableおよびIBInspectableを使用し、UIKitの各パーツをラップしたクラスを定義しそれをIB上でカスタムクラスとして設定することにしました。
カスタムクラスは定義した各デザイントークンを組み合わせることで共通化を図ります。
またUIコンポーネントを定義する上で、コンポーネントを共通化する単位についてデザイナーとしっかり認識を合わせることが重要になります。
ここでエンジニア、デザイナー間で認識齟齬が起きると以下のような問題が発生するかと思います。
- 持つべき機能が異なるコンポーネントを共通化してしまう
- 同じ目的のコンポーネントを別コンポーネントとして切り分けてしまう
いずれもUIコンポーネントが二重管理になったり、想定外の画面でハレーションが起きるなどメンテナビリティ、スケーラビリティを低下させる要因になります。
UILabel
基本方針の通り実装します。カスタムクラスにIBDesignableを付与することでIBから適用したUIが確認できます。
ここでのポイントはUILabelを構成する要素はすべてデザイントークンで定義したものを使用している点です。
@IBDesignable class TitleLabel: UILabel { override init(frame: CGRect) { super.init(frame: frame) setupAttributes() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setupAttributes() } override func prepareForInterfaceBuilder() { super.prepareForInterfaceBuilder() setupAttributes() } private func setupAttributes() { // デザイントークンで定義した値を設定する font = .title textColor = R.color.systemGray_800() } } @IBDesignable class BodyLabel: UILabel { ... } @IBDesignable class CaptionLabel: UILabel { ... }
定義したUILabelのカスタムクラスを設定することでIB上で反映されたスタイルを確認する事ができます(以下サンプル)。
UIButton
こちらも基本方針通りです。
UIButtonの状態に応じてlayoutSubviews
でスタイルを切り替えるようにしています。
その他UIViewを継承するいずれのUIクラス(UITextView, UITextField etc)もこの方針でカスタムクラスを定義してUIコンポーネントを作成するようにします。
@IBDesignable class FillButton: UIButton { override init(frame: CGRect) { super.init(frame: frame) setupAttributes() } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) setupAttributes() } override func prepareForInterfaceBuilder() { super.prepareForInterfaceBuilder() setupAttributes() } override func layoutSubviews() { super.layoutSubviews() switch state { case .normal: backgroundColor = R.color.brandAccessible() titleLabel?.textColor = R.color.systemGray_000() case .highlighted: backgroundColor = R.color.brandAccessibleActive() titleLabel?.textColor = R.color.systemGray_000() case .disabled: backgroundColor = R.color.buttonDisabled() titleLabel?.textColor = R.color.disabled() default: break } } private func setupAttributes() { layer.cornerRadius = .cornerRadius.medium backgroundColor = R.color.brandAccessible() titleLabel?.font = .button titleLabel?.textColor = R.color.systemGray_000() } } @IBDesignable class CancelButton: UIButton { ... } @IBDesignable class TextButton: UIButton { ... }
定義したUIButtonのカスタムクラスを設定することでIB上で反映されたスタイルを確認する事ができます(以下サンプル)。
まとめ
最後までお読みいただきありがとうございます。
まだまだ諸々の事情によりSwiftUIではなくUIKitを使用するプロジェクトは多々あるかと思います。
実装に関しては基礎的な内容だったかも知れませんがDesign Systemを実装する際の考え方の参考になると幸いです。
私自身もDesign Systemとその実装についてまだまだ模索中ではありますが今後もUnlearn & Learnでやっていきたいです!
Classi developers Advent Calendar 2021 の 15日目はおかじさんです。
-
Design Systemとは諸説ありますが、大まかに言うとプロダクトのデザインにおける一貫性を効率的に保つための仕組みのことです。
Design Systemを構成する要素としては、概念・原則をまとめたドキュメント、スタイルガイド、UIコンポーネントライブラリ、またそれらを管理・運用するためのルールやツールなどがあげられます。↩