iOSアプリの画面を実装する場面において、
- 映画作品の詳細を紹介する画面
- Photoshopのような、複数レイヤで構成される画像の編集画面
このような多数のUIコンポーネントを配置しなければいけない画面があったとき、あなたはどのようにレイアウトを構築しますか?
多くの方はUIコンポーネント群を、何かの単位(1.の例であれば詳細画面のセクションごとなど)で分類し、場合によってはその中も再分割を行うことで、構造的に分解することを考えるのではないかと思います。
ここではその一例として、Storyboard
とContainer View
を使った構造化された画面設計について考えてみたいと思います。
サンプルコード: StructuredViewControllerSample
Container Viewを使ってビュー(コントローラ)を構造化する
Storyboardを使うと、Container View
というコンポーネントを利用することで、親子関係を持つビューコントローラ(以下、VCと略します)を作ることができます。これはつまり、多数のUIコンポーネントを要する画面を、複数のビューコントローラに分解して管理することができることを表しています。
Container ViewのSelf-sizing
Container Viewは、何もしないと自身に付けられた大きさの制約に従ってレイアウトされます。中の子VCが期待するサイズにSelf-sizingしたい場合は、以下を行う必要があります。
- 子VCの
view.translatesAutoresizingMaskIntoConstraints
にfalse
を設定する
class ChildViewController: UIViewController {
override func loadView() {
super.loadView()
self.view.translatesAutoresizingMaskIntoConstraints = false
}
...
}
- Container View自体に付与している大きさの制約を削除する
- Interface Builder上で制約のエラーが発生する場合、
Intrinsic Size
をPlaceholder
に変更する
- Interface Builder上で制約のエラーが発生する場合、
画面の一部を「リスト」化するには
例えば、ある映画作品の詳細画面にユーザレビューを最大3件表示する必要があったとき、Container Viewの集合で作られた画面ではどのように実現したらいいでしょうか?
- 3つのContainer Viewを作り、その参照先をすべてレビューを表示するVCに向ける
- 要素の数だけ伸張する「スクロールしないTable View」を作る
2〜3件程度であれば前者の方法でも良さそうです(サンプルコードの「セクション」はこの方法で実装)が、件数が不定の場合は後者の方法が最適でしょう。
Scrolling Enabled
がオフのとき、全てのセルが表示される大きさに伸長するようなTable Viewを作るには、以下のような継承クラスを定義するといいでしょう。
class IntrinsicTableView: UITableView {
override var contentSize: CGSize {
didSet {
if !self.isScrollEnabled {
self.invalidateIntrinsicContentSize()
}
}
}
override var intrinsicContentSize: CGSize {
if self.isScrollEnabled {
return super.intrinsicContentSize
} else {
self.layoutIfNeeded()
return CGSize(
width: UIView.noIntrinsicMetric,
height: self.contentSize.height
)
}
}
}
参考: https://stackoverflow.com/questions/2595118/resizing-uitableview-to-fit-content/48623673#48623673
Container Viewの参照先のロードを遅延させるには
Container Viewは、hidden
を設定していたとしても、参照先の子VCは必ずロードされてしまいます。コンテンツによって構成が変わるような画面では、効率が悪くパフォーマンスも低下します。
この子VCの読み込みは、 shouldPerformSegue(withIdentifier:sender:)
で制御することができます。そして遅延させた子VCを読み込む場合は performSegue(withIdentifier:sender:)
を明示的に呼び出します。
override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
if identifier == "onDemandView" {
return false
} else {
return true
}
}
...
func loadPendingViews() {
// 読み込み済のSegue Identifierを指定するとクラッシュするので要
注意!
self.performSegue(withIdentifier: "onDemandView", sender: self)
}
より実践的な課題
子VCから、オーナーVCを参照するには
すべての子・孫VCを包含する、オーナーVCを特定するには、子・孫VCから self.parent
を辿っていけば良さそうなのですが、self.parent
が nil
を返す self
がオーナーVCとは限りません。オーナーVCが、 Navigation Controller
や Tab Bar Controller
等に包含されている場合があるためです。
そこで、子・孫VCであることを宣言するプロトコルを定義し、オーナーVCを容易に参照できる拡張を追加します。
protocol StructuredChildProtocol: StructuredProtocol {
associatedtype OwnerViewController: UIViewController
}
extension StructuredChildProtocol where Self: UIViewController {
var ownerViewController: OwnerViewController? {
return self.parent(of: OwnerViewController.self)
}
private func parent<VC: UIViewController>(of type: VC.Type) -> VC? {
var parent = self.parent
while parent != nil {
if let typedParent = parent as? VC {
return typedParent
}
parent = parent?.parent
}
return nil
}
}
子VCから親VCを参照するときの注意点
子VCの loadView()
は、親VCの prepare(for:sender:)
の後に呼び出されるため、親から子へのデータの受け渡しは比較的問題なく行えます。一方、子VCから親やオーナーVCが持つデータの参照はやや難しくなります。
その理由が、 loadView()
, viewDidLoad()
の呼び出される順序と、 self.parent
が参照できるタイミングです。
-
loadView()
: 親→子→孫の順 -
viewDidLoad()
: 孫→子→親の順 -
loadView()
,viewDidLoad()
の時点ではself.parent
はnil
を返す-
didMove(toParentViewController:)
がコールされた後にself.parent
が設定される。但しこのメソッドも孫→子の順に呼ばれるため、self.parent?.parent
はnil
となる。
-
以上のことから、これらのライフサイクル・コールバックにおいてはVC階層は完成しておらず、子VCにおいて、先の ownerViewController
の参照がうまくできません。
解決策として、先の StructuredChildProtocol
プロトコルを以下のように変更した上、オーナーVCも StructuredProtocol
プロトコルへ準拠させます。
protocol StructuredProtocol: class {
func configureSelf()
}
typealias StructuredViewController = UIViewController & StructuredProtocol
extension StructuredProtocol where Self: UIViewController {
func configure() {
self.configureSelf()
self.children
.compactMap { $0 as? StructuredViewController }
.forEach { $0.configure() }
}
func configureSelf() {}
}
protocol StructuredChildProtocol: StructuredProtocol // class から変更 {
associatedtype OwnerViewController: UIViewController
}
// ほかは変更なし
こうすると、オーナーVCの viewDidLoad()
内で configure()
を呼び出すことで、全ての子・孫VCのセットアップ処理を指示することができます。子・孫VCは configureSelf()
の中で ownerViewController
を参照することができます。
親・子VC間の依存関係とやりとりの検討事項
採用するアーキテクチャにもよりますが、さらに以下の観点で設計すると良いでしょう。
- 子VCは極力親に依存せず、自身の責務を果たすようにする
- 同様に親VCは子に機能や振る舞いを委譲する
- とはいえ、多くの場合は関連性のあるものを扱うはずなので、無理に低依存にする必要もない
-
prepare(for segue:, sender:)
でコンテキスト(ViewModelやPresenter)を渡す- あるイチ機能に対応する画面を分解したのであれば、その全体を表すVMやPresenterで考えてもよさそう
- 子の変化は親VCのメソッドを直接呼ぶのではなく、Delegateや共有したVMの監視などで行う
XIBを使う必要があるケース
StoryboardとContainer Viewを用いたレイアウトの構造的表現は、実現したい複雑な画面を要素分解する場合に向いていると言えます。一方XIBは、以下のような場合に使う必要があるでしょう。
- 共有するTable View CellやCollection View Cellがある
- Table Header/Footer View
- Table View CellとVCへの埋め込みのどちらにも利用できるレイアウトを作りたいとき
- Storyboardの同時編集で競合を避けたいとき