回転に対応する際に注意していることを備忘録的にまとめていきます。他に思いついたものや違うやり方があったなってのがあれば後々追記していきたいです。
また、この辺りの仕様はiOS8前後で大きく変わっているため、iOS8以降の開発を想定させてください。(詳細はUIViewControllerのドキュメントのHandling View Rotationsの項目を)
回転方向の設定は、アプリのどの範囲で回転させたいかによって設定場所を決める
回転方向を決定する方法は複数あるため、どこで設定すればいいのか混乱の元になってると思います。自分は以下の理解で判断しています。
- iPhone・iPadで共通の場合はプロジェクトファイル内Deployment Infoのチェックボックスで設定する
- iPhone・iPadで異なる場合はInfo.plistにSupportedInterfaceOrientationsをデバイスごとに追加する
- UIViewController単位で変えたい場合は、UIApplicationDelegateや各UIViewControllerのsupportedInterfaceOrientationsを使う
- iPhoneX系ではシステムの設定としてUpsideDownができない
1. iPhone・iPadで共通の場合はプロジェクトファイル内Deployment Infoのチェックボックスで設定したものだけを使う
プロジェクトファイルのDeployment Info > Device Orientationで回転方向を指定できます。
最初はとりあえずあまり深く考えずにこの設定を変更します。すると、アプリ全体で設定が反映されます。
この設定は他の設定に比べて一番優先順位が低く、一旦ここで全体的な設定をしてしまうのがいいです。
注意点としてはiPhoneではUpside Downを設定しても反映されません。これは、他の設定方法であるUIViewControllerのsupportedInterfaceOrientations
がデフォルトでUpside Downができないようになっているためです(その設定のほうが優先的に考慮されます)。もしUpsideDownを有向にしたければ、後述するように各ViewController内で全方向へ回転できるよう実装する必要があります。
Override this method to report all of the orientations that the view controller supports. The default values for a view controller's supported interface orientations is set to all for the iPad idiom and allButUpsideDown for the iPhone idiom.
古いプロジェクトファイル(〜iOS8)を使っている場合は、ここだけでiPhone・iPadの切り替えもできます。
2. iPhone・iPadで異なる場合はInfo.plistにSupportedInterfaceOrientationsをデバイスごとに追加する
Info.plistを使って、iPhone・iPadで回転制御の仕方を変えられます。
iPhoneは
iPadは
を変更します。
3.のUIVIewController単位での設定をしている場合には、そちらのほうが優先的に適用されます。そのため、iPhone・iPadで全体的に回転状態をコントロールする場合にはとりあえずここの設定をしておくといいでしょう。
これに関しても、iPhoneでUpside Downを設定してもそれは反映されません。
3. UIViewController単位で変えたい場合は、UIApplicationDelegateや各UIViewControllerのsupportedInterfaceOrientationsを使う
UIViewController単位で細かく回転の設定を変えたい場合には
- UIApplicationDelegateの
application(_:supportedInterfaceOrientationsFor:)
- 各UIViewControllerの
supportedInterfaceOrientations
を実装します。
UIApplicationDelegateのメソッドはすべてのViewControllerへ影響を与えます。もしここで個別に制御をしたい場合には、メソッド内で現在の画面状態をチェックして適切な値を返す必要があります。
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
return .all
}
}
一番ベストな方法は、個々のViewController内で実装するやり方でしょう。UIViewControllerに対して実装した場合、そのViewControllerに対してのみ反映されます。
class ViewController: UIViewController {
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return .all
}
}
```
注意点としては```addChild(_:)```によるコンテナViewControllerでの回転制御です。その場合、子ViewControllerの回転はコンテナの```supportedInterfaceOrientations```で制御されます。これはデフォルトで存在しているコンテナであるUINavigationControllerなどでも同様です。どのViewControllerが前面にいるのかで変更したい場合は、UINavigationControllerを継承したクラスで、```topViewController```の```supportedInterfaceOrientations```を返すようにしましょう。
## 4. iPhoneX系ではシステムの設定としてUpsideDownができない
上記で説明した話とは別にDevice Support Orientationという概念も存在します。
これは何かと言うと、デバイスレベルで始めから決まっている取りうる回転方向のことです。
もっと具体的に言ってしまえば、どのような設定を行っていても、iPhoneX系ではUpsideDownの設定は効きません。
この設定が最優先で適用されます。
> The system intersects the view controller's supported orientations with the app's supported orientations (as determined by the Info.plist file or the app delegate's application(_:supportedInterfaceOrientationsFor:) method) and the device's supported orientations to determine whether to rotate. For example, the UIInterfaceOrientation.portraitUpsideDown orientation is not supported on iPhone X.
>
> https://developer.apple.com/documentation/uikit/uiviewcontroller/1621435-supportedinterfaceorientations
# LandscapeとPortraitでレイアウトを切り替えたい場合、どのタイミングでサイズが変わるのか注意する
画面仕様としてSize Classでレイアウトを変更できるとバグが入りにくい状態にできます。ただし、回転に対応するならLandscapeとPortraitでUIを変えたいという要件がどうしてもおきてくると思います。
その場合に特に頭を悩ませやすいのがiPadです。Landscape・PortraitのどちらであろうとSize Classは基本的に同じで、Size Classのみでは縦持ち・横持ちに合わせたレイアウト変更ができません。そのかわりに、回転のタイミングでUIViewControllerもしくはUIScreenがどんなサイズに変わるのかを見るようにします。
画面が回転しつつあるのを検知するイベントとしては、```viewWillTransition(to:with:)```が使えます。
- [viewWillTransition(to:with:)](https://developer.apple.com/documentation/uikit/uicontentcontainer/1621466-viewwilltransition)
UIViewControllerが、直接所持しているviewのサイズを変更する直前に呼ぶ処理です。引数の```size```には、どんなサイズへ変わろうとしているのかが入ってきます。
もう一つの引数である```coordinator ```へレイアウト変更のコードを入れてやると、回転のアニメーションへ合わせて変更が実行されます。
```swift
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
if size.width > size.height {
// レイアウト変更処理を書く
} else {
// ...
}
}, completion: nil)
}
```
これで問題なさそうに見えますが、必ずしもこのタイミングで適切なサイズが取れるとは限りません。次に書くように、回転させた時点では各ViewControllerのviewはスクリーンのサイズに合わせて変更されていないケースがあります。そのため、viewのサイズを使った計算を入れてしまうと、レイアウト崩れを起こす可能性があります。
止む終えない場合は、直下のviewのレイアウトが決定している```viewWillLayoutSubview```のイベントのタイミングで計算させるのもありかなと思います。
- [viewWillLayoutSubviews()
](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621437-viewwilllayoutsubviews)
また、これも別で書きますが、UICollectionViewのレイアウトを決める場合にはレイアウトクラスを継承して、サイズが変わる時に呼ばれるイベントを使ってハンドリングするのも手だと思っています。
ついでに書くと、iPhoneの5.5インチ端末とXR, XS Maxに関してLandscape時のSize Classは幅がiPadと同じです。
userInterfaceIdiomを使わず、(例えばStoryboard上で)Size Classのみによって判断してしまうと、このケースを見落としてしまうため注意が必要かと思います。
# 対象のViewControllerが前面に出ていないケースでの回転時挙動を考慮する
UIViewControllerやUIViewは、現在の階層構造によってイベントの実行されるタイミングが変わるように設計されています。そのため、あるViewControllerをいじったらその中だけテストして動作保証できるかと言うと必ずしもそんなことはなく、全体の構造を考慮しなければならない場合があります。
そして、回転時のレイアウト変更がまさにそのケースになります。一つ前の話のようにviewのサイズでレイアウトを切り替える場合、そのviewを所有するViewControllerが階層上のどこにいるのかでレイアウト処理の実行タイミングが微妙に変わります。
例えば、UINavigationControllerがUIWindowのrootViewControllerにいるとします。そのUINavigationControllerはHogeViewControllerを持っており、回転させると```subviews```のレイアウト処理が実行され、先程の```viewWillTransition(to:with:)```の```coordinator```内の処理も実行されます。
ここで、FugaViewControllerをナビゲーションスタックに追加します。すると、回転した時にHogeViewControllerの```subviews```へのレイアウト処理は実行されません。しかし、```coordinator```内の処理は実行されるため```subviews```のframeは回転前のまま計算が行われることになります。
そのため、HogeViewControllerがアプリの前面にいるときは回転させるとうまくレイアウトされるが、裏側に回っている時に回転させるとレイアウトが崩れるということが起きている可能性があります。
実装後に動作確認するときは、そのViewControllerが前面に出ている場合に加え、ナビゲーションスタックの途中にいる・モーダルで裏に隠れている場合やアプリをバックグラウンドに持っていた状態で回転操作をし、画面を表示させるということをしたほうがいいでしょう。
# Viewへのデータバインディングとレイアウト計算は分離する
AutoLayoutを始めに張ってそれだけでレイアウトが作れる場合は考えなくていいのですが、コードで地道にレイアウト計算したり、AutoLayoutのパラメータを変更する場合はこの点に注意が必要かなと思います。
回転に対応するようになると、画面生成やデータを表示させる時などの初期のタイミングとは別に、UIViewControllerのオブジェクトが生きたまま任意のタイミングでレイアウトが変わりえます。
UIViewやUIViewControllerがレイアウト計算のイベントを必要な時に呼ぶので、レイアウトに関する処理は```layoutSubview```や```updateConstraints```などそれぞれ専用のメソッド内で実装しましょう。
- [layoutSubviews()](https://developer.apple.com/documentation/uikit/uiview/1622482-layoutsubviews)
- [updateConstraints()](https://developer.apple.com/documentation/uikit/uiview/1622512-updateconstraints)
自分で呼びたい場合に```setNeedsLaout()```や```setNeesdsUpdateConstraints()```などで間接的にレイアウトをフックするのがいいと思っています。
- [setNeedsLayout()](https://developer.apple.com/documentation/uikit/uiview/1622601-setneedslayout)
- [setNeesdsUpdateConstraints()](https://developer.apple.com/documentation/uikit/uiview/1622450-setneedsupdateconstraints)
# UICollectionViewで回転時のレイアウトを制御したい場合、レイアウトクラスを継承してそこで実装するほうが安全
UITableViewを対応させる場合、
- 横幅はView全体に広がると決まっている
- UITableViewCellのcontentviewにAutolayoutを貼っていればよしなに余白を設定してくれる
といった理由で、回転でのレイアウトを深く考える必要がなく比較的楽かと思います。
一方、UICollectionViewを使うと回転でだいたいどこかレイアウト崩れを起こすジンクスが自分にはあります。
```viewWillTransition(to:with:)``` で```invalidatesLayout()``` を呼ぶなど、UIViewControllerのライフサイクルで頑張ってレイアウトを制御するのも一つの方法です。
- [invalidateLayout()](https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617728-invalidatelayout)
```swift
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { [weak self] _ in
self?.collectionView.collectionViewLayout.invalidateLayout()
}, completion: nil)
}
```
ただ、個人的にはレイアウトクラスを継承してそこへ再計算の実装を行うのが好みです。
- [UICollectionViewLayout](https://developer.apple.com/documentation/uikit/uicollectionviewlayout)
レイアウトクラス内に```bounds```が変更されたら必ず呼ばれるメソッドがあり、そこで条件判定をして、```invalidateLayout```をすることでレイアウトの再計算をさせます。これだと他の画面まで含めたアクロバティックな回転操作でCollectionViewのレイアウトが崩れてしまったという事態を防ぐことができます。
- [shouldInvalidateLayout(forBoundsChange:)](https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617781-shouldinvalidatelayout)
```swift
class HogeCollectionViewFlowLayout: UICollectionViewFlowLayout {
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds)
guard let flowLayoutContext = context as? UICollectionViewFlowLayoutInvalidationContext else {
return context
}
// collectionViewのサイズが変わったタイミングで必ずinvalidateする
flowLayoutContext.invalidateFlowLayoutDelegateMetrics = (newBounds.size != collectionView?.bounds.size)
return context
}
}
```
中の実装方法はもっと効率化したほうがいいケースはあると思いますが、頻繁にCollectionViewのサイズ変更がされない作りのものであれば、無理なく確実にサイズにフィットしたレイアウトを計算できます。
# アプリ内の操作で回転させる場合はLadscapeに固定されたカスタムモーダルによって実現可能
例えばユーザーが端末を回転させなくても、ボタンを押すことでLandscapeモードへ移行させるUXにしたいケースはあるでしょう。
デバイスの回転をアプリから行うAPIは残念ながら公開されていません。
もし正攻法に行うのであれば、(フルスクリーン表示の)モーダルを使ってLandscapeに固定されたViewControllerを表示させるのがいいだろうと思っています。UIViewControllerが持つ以下のAPIでモーダル表示のタイミングでのViewControllerの方向を制御できます。
- [preferredInterfaceOrientationForPresentation](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621438-preferredinterfaceorientationfor)
```swift
class ViewController: UIViewController {
override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
return .landscapeRight
}
}
```
もし回転方向をこのまま.landscapeRightに固定したいのであれば、```shouldAutorotate```をfalseにして、```supportedInterfaceOrientation```も.landscapeRightのみにします。
```swift
class ViewController: UIViewController {
override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
return .landscapeRight
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return .landscapeRight
}
override var shouldAutorotate: Bool {
return false
}
}
```
dismiss時にもこれらのイベントは呼ばれます。通常は.portraitで、特定のモーダルが表示されたときのみlandscapeにするのであれば、以下のように```isBeingPresented```で出し分けるといいでしょう。
```swift
class ViewController: UIViewController {
override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation {
if isBeingPresented {
return .landscapeRight
} else {
return .portrait
}
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if isBeingPresented {
return .landscapeRight
} else {
return .portrait
}
}
override var shouldAutorotate: Bool {
return false
}
}
```
このままだと、モーダルの出方がかっこよくないため、カスタムトランジションを組み合わせることで画面が回転しながら表示されるよう遷移させると、より良くなります。
また、以下の通知でユーザーがデバイスの向きを変えたタイミングに上記のモーダルを表示させるというのもできます。
- [orientationDidChangeNotification](https://developer.apple.com/documentation/uikit/uidevice/1620025-orientationdidchangenotification)
ちなみに、デバイスの回転が全くできないのかと言うと、実はそういうわけではありません。
以下のように書けば、ボタンを押した時に強制的に回転させることができます。
```swift
UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation")
```
この書き方から分かる通りキー値コーディングを使ってprivateなプロパティへアクセスしています。推奨はされませんし審査で落とされるリスクがあります。
- [setValue(_:forKey:)](https://developer.apple.com/documentation/objectivec/nsobject/1415969-setvalue)
# まとめ
回転制御のTipsを思いつくままに書いてみました。回転はレイアウト崩れを起こしがちな操作です。一方で、UIViewControllerのライフサイクルの中で画面サイズが動的に変わる前提でレイアウトのコードを書くとiPadの画面分割にも対応しやすいというメリットがあります。また、できる限りUIKitの標準的なUIに寄せることの恩恵も受けやすい部分でもあります。
もしこんなやり方を取っているよとかこの方法だとこういうケースでうまく動かないのでこうしたほうがいいなどありましたら、ぜひ教えて下さいm(_ _)m
# 参考資料
- https://developer.apple.com/documentation/uikit/uiviewcontroller
- https://developer.apple.com/documentation/uikit/uiviewcontroller/1621435-supportedinterfaceorientations
- https://developer.apple.com/documentation/uikit/uicontentcontainer/1621466-viewwilltransition
- https://developer.apple.com/documentation/uikit/uiviewcontroller/1621437-viewwilllayoutsubviews
- https://developer.apple.com/documentation/uikit/uiview/1622482-layoutsubviews
- https://developer.apple.com/documentation/uikit/uiview/1622512-updateconstraints
- https://developer.apple.com/documentation/uikit/uiview/1622601-setneedslayout
- https://developer.apple.com/documentation/uikit/uiview/1622450-setneedsupdateconstraints
- https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617728-invalidatelayout
- https://developer.apple.com/documentation/uikit/uicollectionviewlayout
- https://developer.apple.com/documentation/uikit/uicollectionviewlayout/1617781-shouldinvalidatelayout
- https://developer.apple.com/documentation/uikit/uiviewcontroller/1621438-preferredinterfaceorientationfor
- https://developer.apple.com/documentation/uikit/uidevice/1620025-orientationdidchangenotification
- https://developer.apple.com/documentation/objectivec/nsobject/1415969-setvalue