Storyboardとの付き合い方 2018
少し前に、自分のStoryboardの使い方をツイートしたら割と反応があったので、改めてまとめてみようと思います。これまで何年かiOSアプリの開発をしてきて、Storyboardとの付き合い方は何度も変わりました。なので、今回紹介するものはあくまで2018年現在のもので、来年には変わっているかもしれません。
説明のイメージを掴みやすくするため、画面の例を用意しました。左が編集時のStoryboardで、右が実行時のiOSシミュレーターです。具体的なトピックが出た時に、この例を説明に使うことがあります。
記事の最後にこれが動作するサンプルコードも用意しましたので、興味があればどうぞ。
Storyboardを使う目的
以下の2つを重視して、Storyboardを選択しています。
- 動作確認に掛かる時間を短縮する
- 成果物の構造を把握しやすくする
ただし、Storyboardを使えば必ずこの2つを達成できる訳ではありません。下手をすれば、他の方法よりも目的から遠ざかってしまう可能性もあります。それでも、付き合い方を工夫すれば、この2つの目的を上手く達成できると考えて、自分はStoryboardを使うことにしています。
Storyboardは何をする場所か
Storyboardができることは幅広いです。ほとんどコードを書かずにUIを実装できるケースもあります。しかし、何もかもをStoryboardでやろうとすると、想像以上の複雑さを招いてしまうケースもあります。最初に挙げた2つの目的を達成するには、Storyboardが得意なこととコードが得意なことを考慮して、Storyboardは何をする場所なのか、明確に決めておく必要があります。
ここでは、Storyboardは静的なレイアウトを設定する場所とします。
静的なレイアウトを設定する
Storyboardは静的なレイアウトの実装が得意です。何がどこに配置されるのか一目瞭然ですし、変更をするときもどこを編集すれば良いのかすぐにわかります。しかし、条件に応じた動的なコンテンツの切り替えやアニメーションはできないため、その部分はコードで補ってあげる必要があります。
役割分担を明確にするため、静的なレイアウトは基本的にStoryboardで行います。また、動的なレイアウトもStoryboardの操作で再現可能にしておくと、動作確認を簡単にできます。例えば、画面の例の「東京都中央区」というラベルの表示/非表示を条件に応じて切り替える場合、ラベルをUIStackViewの中に入れて、isHiddenの操作1つで表示/非表示の切り替えとマージンの切り詰めが行われるようにします。こうしておくと、Storyboard上でレイアウトの確認もできますし、コードからの操作も1行で済みます。
コンポーネントの定義はクラスに委ねる
アプリの開発では、デザインガイドラインを定義するのが一般的かと思います。ガイドラインに登場するコンポーネントはクラスとして定義し、Storyboardではクラスを指定することで配置するコンポーネントを選択します。
例えば、冒頭の画面では以下の4つのコンポーネントが登場していました。
- TitleLabel: 「日本橋人形町」と表示されているタイトル用のラベル
- InfoLabel: 「東京都中央区」と表示されている付加情報用のラベル
- BodyLabel: 説明本文用のラベル
- PrimaryActionButton: 画面で1番重要なアクション用のボタン
TitleLabelのコンポーネントの選択は、Storyboardでは以下のように行っています。
ここで注意したいのは、Storyboard上ではフォントのサイズや色などの、スタイルの指定を行っていないということです。スタイルの指定は、コンポーネントを定義しているクラスが担当し、Storyboardはそうして定義されたクラスを選択して配置するという役割に徹しています。
このような役割分担を徹底すると、以下の2つのメリットが得られます。
- Storyboardからコンポーネントを使うのが簡単になる。
- コンポーネントの一貫性を保てる。
もちろん、Storyboardでもスタイルの設定は可能です。しかし、スタイルの設定をStoryboardが担当してしまうと、同じコンポーネントのためのスタイルの指定を何度も繰り返すことになるため、この2つのメリットは得られなくなってしまいます。
例として、PrimaryActionButtonのスタイルの設定と、Storyboardでのプレビューを見てください。これだけの量のスタイルの設定を毎回Storyboardで繰り返し、尚且つ一貫性を保つのは現実的でないのは明らかです。
また、コンポーネントのクラスではintrinsicContentSizeも実装しています。念のため簡単に説明しますが、intrinsicContentSizeとはView自身が持つ自然なサイズのことです。Auto LayoutはconstraintとintrinsicContentSizeを使ってレイアウトを決めるので、intrinsicContentSizeはconstraintと同じくらい重要な概念です。
PrimaryActionButtonは、以下のようにintrinsicContentSizeを実装しています。
public override var intrinsicContentSize: CGSize {
var size = super.intrinsicContentSize
size.height = 48
if let titleLabel = titleLabel {
size.width = titleLabel.intrinsicContentSize.width + 48
}
return size
}
intrinsicContentSizeを実装したことで、widthとheightのconstraintは不要となり、center Xとbottomのconstraintだけでレイアウトが可能となりました。単に必要なconstraintを2つ減らせただけでなく、コンポーネントのサイズにも一貫性を持たせることができました。
コンポーネントの組み合わせによってコンポーネントを作成する場合には、xibを使うこともあります。以下の例では、タブのタイトル、アイコン、バッジを組み合わせたコンポーネントのコンテンツをxibで作成しています。
xibの扱いはコンポーネントのクラスの内部で完結するように実装し、コンポーネントの利用者にはxibの存在を意識させないようにします。
@IBDesignable
public class MUITabBarButton: UIControl {
@IBOutlet private weak var imageView: UIImageView!
@IBOutlet private weak var titleLabel: UILabel!
@IBOutlet private weak var badgeView: UIView!
@IBOutlet private weak var badgeLabel: UILabel!
public override init(frame: CGRect) {
super.init(frame: frame)
initializeContentView()
}
public required init?(coder: NSCoder) {
super.init(coder: coder)
initializeContentView()
}
private func initializeContentView() {
let nib = UINib(nibName: "MUITabBarButton", bundle: Bundle(for: MUITabBarButton.self))
let contentView = nib.instantiate(withOwner: self, options: nil).first as! UIView
addSubview(contentView)
}
...
}
こうすることで、Storyboardからは通常のコンポーネントと同じように、クラスの指定で使用できるようになります。
画面遷移の設定をしない
Storyboardには、segueという画面遷移を定義する仕組みがあります。segueを使うと画面遷移がStoryboard上に可視化されるため、画面遷移の構造が把握しやすくなります(理想的には)。しかし、多くのケースではStoryboardをコードと協調させる必要があり、責務の分配を複雑化させてしまうという問題もあります。例えば、以下のものにはコードが必要です。
- 遷移先のUIViewControllerへのデータの受け渡し
- Storyboardに対応していないUIViewControllerへの遷移
- UIイベント以外のイベントをトリガーとした遷移(通信完了など)
Storyboardとクラスの2つを使った画面遷移を行う場合、segue IDや遷移先のクラスの二重管理がどうしても生じてしまいます。画面遷移を正しく動作させるには、Storyboardとクラスがそれぞれ持っている値を一致させる必要がありますが、これらが正しく設定されているかどうかは実行時になるまでわかりません。したがって、コンパイラによるエラーの発見の恩恵が受けられず、壊れた状態に気づくのが遅れやすくなります。
自分のチームでは、Storyboardで画面遷移を定義しない代わりに、すべてのUIViewControllerに以下のようなファクトリメソッドを持たせて、常にそのメソッド経由で画面遷移を行うことにしています。
final class DemoViewController {
static func makeInstance(title: String, body: String) -> DemoViewController {
let storyboard = UIStoryboard(name: "Demo", bundle: nil)
let viewController = storyboard.instantiateInitialViewController() as! DemoViewController
viewController.dependency = dependency
return viewController
}
private var title: String!
private var body: String!
}
このファクトリメソッドは次の責務を果たしています。
- UIViewControllerの初期化に必要なパラメーターの宣言
- UIViewControllerのインスタンス化(Storyboardでなくても良い)
- パラメーターを使ったUIViewControllerの初期化
こうした責務が各クラスのファクトリメソッドに押し込まれることで、遷移元は遷移先のインスタンス化や初期化の責務からは解放され、遷移先が宣言しているパラメーターさえ渡せば良いということになります。仮に遷移先のインスタンス化の方法が変わったとしても(例えばStoryboardの名前が変わったとか)、遷移元のコードには影響がありません。また、初期化に必要なパラメーターを変えた場合、追随が必要な箇所がコンパイルエラーによって炙り出されるため、画面遷移を意図せず壊す可能性も低くなります。
ここまでのまとめ
- Storyboardは静的なレイアウトを設定する場所。
- 動的なレイアウトが最小限になるように組む。
- コンポーネントはクラスで定義して、Storyboardで配置する。
- Storyboardで画面遷移の設定をしない。
編集時と実行時の表示を一致させる
普通にStoryboardを使っていると、編集時と実行時の表示に差が出てしまうケースがよく出てきます。仕方がないことのような気もするのですが、これを受け入れてしまうと最初に挙げた2つの目的を達成するのが難しくなってしまいます。
- 動作確認に掛かる時間を短縮する
- 成果物の構造を把握しやすくする
動作確認に掛かる時間を短縮する
編集時と実行時が一致していない場合、余白やフォントがどう見えるのかはアプリを実行するまでわかりません。したがって、レイアウトを調整する度に数秒のビルド時間を待ち、目的の画面まで遷移の操作をして結果を確認する必要があります。これは効率的なワークフローではありません。
編集時と実行時の見た目を揃えておくと、大部分のレイアウトがStoryboard上で確認できるため、アプリの実行も、目的の画面までの操作も不要となります。もちろん、すべてのものがStoryboard上で確認ができるわけではありませんが、7, 8割の確認はStoryboard上で済ませられるため、確認に掛かる時間は確実に短縮できます。
成果物の構造を把握しやすくする
編集時の表示を実行時と一致させることは、別の開発者が後からレイアウトを編集する時に、どこを操作すれば良いのか、どのような操作できそうか、理解するのに役立ちます。そして、この2点をわかりやすく保つことは、UIの開発をスムーズに進める上で非常に重要です。
先に登場したタブのコンポーネント例に、違いを見てみましょう。
このタブのタイトルやアイコンは、コンポーネントのIBInspectableなフィールドで設定できると想像するのが自然でしょう。
続いて、編集時と実行時の表示が一致しないケースを見てみましょう。このコンポーネントをIBDesignableにしなかった場合、Storyboard上の表示は白い四角となります。
これでは、そもそもタブのレイアウトがStoryboard上で行われているのかどうかもすぐには分からないため、コードで設定されている可能性も頭に入れて編集すべき箇所を探し出さなければなりません。また、タブのコンポーネントをStoryboard上に発見した後にも、アイコンやタイトルの設定をどこでしているのか探す必要があります。
このように、編集時と実行時の表示が一致していないと、レイアウトの編集のコストが高くなります。逆に、コンポーネントを作成する際に、表示を一致させるコストを払っておけば、レイアウトを編集する際のコストは小さく抑えられます。基本的に、画面のレイアウトは再利用できませんが、コンポーネントは再利用されるものなので、コストはコンポーネントの作成側に寄せた方が、全体としての効率は良くなるでしょう。
IBDesignableのテクニック
編集時と実行時の表示を一致させるには、IBDesignableが欠かせません。IBDesignableとの付き合い方にもいくつか工夫があるので、ここではそれらを紹介します。
ビルドターゲットの分割によるプレビューの高速化
XcodeはStoryboardでのプレビューを作成するために、Storyboardで使用されているIBDesignableなクラスを集めて、それらが含まれるモジュールをすべてビルドします。ここで気をつけなければならないのは、ビルドされるモジュールが大きくなると、プレビューが作成されるまでに掛かる時間が伸びてしまうということです。
プレビューの作成が短時間で終わるようにするため、コンポーネント専用のフレームワークを作成し、IBDesignableなクラスをすべてそのフレームワークにまとめます。そして、アプリのStoryboardではフレームワークで定義されたコンポーネントを参照します。このような構成にしておくと、ビルド対象はコンポーネント群のみとなり、現実的な時間でビルドが終わります。
よくある失敗は、アプリのモジュールにIBDesignableなクラスを入れてしまうことです。この構成では、プレビューの作成にアプリ全体のビルドが必要となってしまいます。アプリ全体のビルドをせずにプレビューの確認ができるのがStoryboardのメリットなのに、プレビューの作成にアプリ全体のビルドが必要となってしまっては本末転倒です。
IBDesignableが効かなくなった場合の対処
Xcodeを再起動します。
中華フォント現象の修正
中華フォント現象については、@usagimarumaさんの「iOS で日本語文章に発生する中華フォント現象とは」を参照してください。
要するに、デフォルトの言語の優先順位では、システムフォントでは日本語に中国語のフォントファミリーが使用されてしまうという問題です。これは実際のiOSでも起きる問題ですが、Storyboardでも同様に発生します。
この現象が起きていると、編集時と(日本語設定の)実行時のレイアウトが一致しなくなってしまいます。先の記事でも紹介されている通り、NSAttributedStringでkCTLanguageAttributeName=jaを設定すると、この現象を回避できます。
通常、日本語フォントが使用されて欲しいユーザーは日本語設定を使用しているはずなので、実行時までこの属性を設定する必要はないと思います。したがって、Storyboard上でのみこの属性を指定するように、prepareForInterfaceBuilder()でkCTLanguageAttributeName=jaを付けたattibutedTextを設定します。
public override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
if let text = text {
let attributes: [NSAttributedStringKey: Any] = [
NSAttributedStringKey(kCTLanguageAttributeName as String): "ja",
]
attributedText = NSAttributedString(string: text, attributes: attributes)
}
}
今後やるかもしれないこと
- Storyboardのlint
- IBInspectableとコード生成によるData Binding
まとめ
長くなりましたが、要するに言いたいことは以下の2つです。
- Storyboard、コンポーネントのクラス、UIViewControllerの役割分担を意識すると良い。
- 編集時と実行時の表示を一致させると、プレビューによるメリットが大きくなる。
サンプルコードは以下のリポジトリに置いてあります。
iOSDCに居る予定なので、何か聞きたいこととがあれば捕まえてください。