93
59

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

iOSAdvent Calendar 2018

Day 20

iOSアプリのレイアウトを回転で変更するとき注意していること

Last updated at Posted at 2018-12-20

回転に対応する際に注意していることを備忘録的にまとめていきます。他に思いついたものや違うやり方があったなってのがあれば後々追記していきたいです。

また、この辺りの仕様はiOS8前後で大きく変わっているため、iOS8以降の開発を想定させてください。(詳細はUIViewControllerのドキュメントのHandling View Rotationsの項目を)

回転方向の設定は、アプリのどの範囲で回転させたいかによって設定場所を決める

回転方向を決定する方法は複数あるため、どこで設定すればいいのか混乱の元になってると思います。自分は以下の理解で判断しています。

  1. iPhone・iPadで共通の場合はプロジェクトファイル内Deployment Infoのチェックボックスで設定する
  2. iPhone・iPadで異なる場合はInfo.plistにSupportedInterfaceOrientationsをデバイスごとに追加する
  3. UIViewController単位で変えたい場合は、UIApplicationDelegateや各UIViewControllerのsupportedInterfaceOrientationsを使う
  4. iPhoneX系ではシステムの設定としてUpsideDownができない

1. iPhone・iPadで共通の場合はプロジェクトファイル内Deployment Infoのチェックボックスで設定したものだけを使う

プロジェクトファイルのDeployment Info > Device Orientationで回転方向を指定できます。
最初はとりあえずあまり深く考えずにこの設定を変更します。すると、アプリ全体で設定が反映されます。
この設定は他の設定に比べて一番優先順位が低く、一旦ここで全体的な設定をしてしまうのがいいです。

スクリーンショット 2018-12-20 22.39.06.png

注意点としては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.

https://developer.apple.com/documentation/uikit/uiviewcontroller/1621435-supportedinterfaceorientations

古いプロジェクトファイル(〜iOS8)を使っている場合は、ここだけでiPhone・iPadの切り替えもできます。

2. iPhone・iPadで異なる場合はInfo.plistにSupportedInterfaceOrientationsをデバイスごとに追加する

Info.plistを使って、iPhone・iPadで回転制御の仕方を変えられます。

iPhoneは

スクリーンショット 2018-12-20 22.42.32.png

iPadは

スクリーンショット 2018-12-20 22.42.42.png

を変更します。
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ですLandscapePortraitのどちらであろうと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
93
59
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
93
59

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?