TL;DR,
- 優先度の異なる複数の制約を同時に定義することで、静的な定義だけで動的な振る舞いを実現できる
- 動的な要素の少ない構造のビューはより堅牢である
はじめに
読みやすくメンテナンスしやすいソフトウェアを作るために重要なことの一つは構造をシンプルに保つことです。
iOSアプリのビューは壊れやすいソフトウェアの代表ですが、できるだけシンプルに作ることで変化に強い、堅牢で壊れにくいソフトウェアにできます。
動的な要素が少ないということは、ビューがシンプルであるということの指標の1つと言えます。
この記事では下記に示すような、スクロールに合わせて伸び縮みするヘッダーを、動的な要素を無くし、Auto Layoutの静的な制約のみで実現する方法を解説します。
動的な要素とは、実行時におけるビューおよび制約の追加・削除、Frameや制約を更新することと、機種やスクリーンサイズ、標準UIコンポーネントのサイズなどを仮定して、条件によって処理を分岐することです。
スクリーンサイズや機種を判定して処理を変えたり、ナビゲーションバーの高さなどを固定値だと仮定したコードは変更に弱く、壊れやすいビューとなってしまいます。 また、ビューの追加削除をしたときに、制約がどのように変化するかは自明ではありません(実際にはそのビューに付いていた制約はすべて外れることと決まっていますが、それを頭の中でシミュレートしなければならないので大変です)。
動的な要素がなければ、そもそもそういったことは起こらないので、常に一定の結果を期待できます。
今回のサンプルコードは下記のリポジトリで公開しています。まずStoryboardを眺めて、動かしてみるとわかりやすいです。
サンプルコードではわかりやすさのためにすべての制約をStoryboardで記述しています。スクロールビューのcontentOffset
などを監視するようなコードは一切ありません。ヘッダーの伸び縮みや、ナビゲーションバーの高さで固定する挙動はすべて制約によって実現されます。
ViewControllerにも処理は書かれていますが、レイアウトには関係のないスタイルの変更などであることがわかります。
Storyboardを使わなければいけないわけではなく、例えばviewDidLoad()
やloadView()
だけに記述されていればそれは静的な制約と言えます。
それでは具体的な作り方をみていきます。
伸び縮みせず普通にスクロールするレイアウトを作る
まずは伸び縮みする効果は忘れて、Image Viewをスクロールビューの上部に普通に配置します。
スクロールビューをRoot Viewと同じ大きさになるように上下左右のエッジに合うように制約を付けます。 Image Viewをスクロールビューのサブビューに追加し、左右両端と上端に合うように制約を付けます。これだけではタテ方向の制約が足りないのでAspect Ratioの制約を追加します。
スクロールビューとサブビューの制約は通常のビューに対する制約と異なりスクロールビューのスクロール領域に対する制約になります。
スクロールビュー自体のサイズに対する制約とはならないので、スクロールビューのサイズを決定するために制約は端から端まで繋げて、サブビューのサイズでスクロールビューのスクロール領域のサイズが決定するように制約を付けます。
Image Viewはスクロールビューの左右のエッジに加えて、Viewの左右両端にも合うように制約を付けます。 下方向の制約はスクロールビューの下端まで切れ目なく繋がるように制約を付けます。
ここまでで次のような挙動のレイアウトになります。
Image Viewをスクロールによって伸び縮みさせる
これを基本形として、Image Viewがスクロールによって伸び縮みするように制約を変更していきます。 まず準備としてImage Viewをただのビュー(Image Contaner Viewとします)に置き換え、同じように制約を付けます。
これで実際にスクロールに追随して動くのはImage Viewではなく、Image Container Viewになります。 (Image Viewに付いていた制約はいったん全部外します。)
次にImage Viewをスクロールに追随せず、上部に固定したままになるように制約を追加します。
Image ViewとImage Container Viewの左右両端と下端を合わせる制約を追加します。 さらにImage Viewの上端と View の上端を合わせる制約を追加します。
この時点で下方向にスクロールしたとき、Image ViewはImage Container Viewとの制約によりImage Container Viewに合わせてスクロールしようとしますが、上端はViewとの制約により固定されているので、結果として上端は固定されたまま、下端が下がることで高さが大きくなり、スクロールに合わせて伸び縮みするようになります。
しかし、見てわかるようにこの時点では問題があり、上方向のスクロールができなくなってしまいます。(また上にスクロールした際にAutoLayoutの制約が衝突するエラーが出ていることがわかります。) これはImage ViewをViewの上端に固定したことによります。
これを直すために、AutoLayoutの優先度を調整して、制約がコンフリクトした際に適切に制約がブレイクして、別の制約に切り替わるようにします。
Image ViewとViewに付けた制約の優先度をRequired (1000)
からHigh (750)
に変更します。
これでこの制約を満たすことは必須ではなくなったので、上にスクロールしてImage View.Top == View.Top
の制約が満たせなくなっても単にその制約が無視されるだけで問題ありません。これで以前のようにスクロールできるようになります。
Image Viewに対する上端の制約の優先度を下げたので、上にスクロールした際に高さが維持されるように、Image ViewとImage Container Viewに対して高さが同じかそれ以上(>=
)になる制約を追加します。
これにより、上方向にスクロールした際は、Image View.Top == View.Top
の制約をブレイクしつつ、Image ViewとImage Container Viewの高さが同じという制約は満たせるので、Image Viewは高さを維持したままスクロールに追随します。
下方向にスクロールした場合はImage View.Top == View.Top
の制約とHeight >=
の制約の両方が満たせるのでImage Viewは上部に固定されたままスクロールに合わせて伸び縮みするようになります。
Image Viewがナビゲーションバーの高さで固定するようにする
応用編として、先ほど紹介したテクニックを使って、上方向にスクロールした際、ナビゲーションバーと同じ高さにImage Viewが固定されて残るようにしてみましょう。
Navigation BarはViewとの直接の親子関係にないので、そのままNavigation Barと制約を付けることができません。そのため、サブビューとしてNavigation Barと同じ位置・サイズを維持するようなビューを追加します。
Navigation Barは存在する場合、その位置と大きさは、ビューの左右両端と上端、および下端がSafe Areaの上端に一致します。ビューにそのような制約を付けるとナビゲーションバーとまったく同じ位置とサイズをもつビューになります(Navigation Bar Backingとします)。
Image Viewがナビゲーションバーの位置までスクロールされてきたときに、ナビゲーションバーの下端で止まってほしいので、Navigation Bar Backingの下端に対して、ImageViewの下端を合わせる制約を追加します。これは上方向にスクロールしてナビゲーションバーの位置まできたときだけ有効になってほしいので優先度を下げます(Low (250)
)。これだけでは制約が足りないので、Navigation Bar BackingとImage Viewの高さを一致させるという制約を同様に優先度を下げて(Low (250)
)追加します。
最後に、Image ViewとImage Container Viewの高さが同じかそれ以上という制約の優先度を下げます(High (750)
)。
(ここまでの制約は図で示すことが難しいのでサンプルコードをみて実際に試してみてください。)
そうすると、上方向にスクロールして優先度の低い制約を満たせる状況になった際に制約が有効になり、Image Viewがナビゲーションバーの位置に固定されるようになります。 下方向にスクロールした場合は単に制約が満たせなくなり、優先度が低いため単に無視されて別の制約が満たされることになります。
まとめ
このように複数の制約が適宜ブレイクしてスイッチするように定義することで静的な制約だけでも動的な振る舞いを持たせることができます。
ここで紹介した複数の制約を優先度によって切り替えるテクニックは応用範囲が広く、使いこなせるようになると、レイアウトに関する動的な要素をかなり排除できます。
次に示す記事は、同様の内容をさらに詳しくステップbyステップで解説してくれているので、合わせて読むことをオススメします。
参考