アプリのロジックからisIPhoneXフラグを消すためにやったこと

![auto_layout_or_swift_code.png](https://qiita-image-store.s3.amazonaws.com/0/17214/52cb7ca5-9956-86b4-be2e-c30790b7143b.png)

昨日は @kikuchy による[Flutterをプロダクション導入する話](https://qiita.com/kikuchy/09bcc8e4c7fd691e1a9c)でした。
プロダクションでFlutterを導入するという攻めの開発をしているyoubrideチームですが、ぜひこれからも得られた知見を共有していきたいと思います :mag: 

本日はそんなFlutterから一転、[Poiboy](https://poiboy.jp/)のiOSアプリ開発で行ったSafe Area対応についてのお話をお送りしたいと思います。

あらすじ: コードでの端末判定について
---

みなさんのiOSアプリではコードでデバイス判定して分岐していますか :question: 
主観にはなりますが、 **Auto Layoutだけで全端末のレイアウトを実現できているケースはそう多くないのではと思っています。**
例えば記事のタイトルにもあるように、 **`isIPhoneX` のようなフラグを見て、大きなデバイスと小さなデバイスで分岐するみたいな実装をしているケースも多いかと思います。**
自身もこうしたコードでのデバイス判定を何度も実装したことがあります。

コードでの端末判定における問題点
---

しかしこのようなフラグは、**端末バリエーション増加の度に追加が必要になります。**
新しい端末が発表される都度

```swift
if isIPhoneXr { ... }
```

や

```swift
let isWideDevice = isIPhone6Plus || isIPhone6sPlus || ... || isIPhoneXsMax
```

のような実装を増やすと、 **単にロジックの分岐が増えて見通しが悪くなるだけでなく、意図しないUI不整合の温床にもなります。**
当たり前ですが **Appleも公式に「今はまだ存在ない新しいデバイスについても、Auto Layout対応していればUIをうまく適合させることができる [^appleofficial]」と言っています。**
なので本来であれば、このようなフラグを作る時点で仕様や実装方針を疑った方が良いのかもしれません。

実は自分のプロダクトであるPoiboyでも、改めてコードを調べたところ、そこそこの数のデバイス判定箇所を見つけてしまいました :sweat:
もちろんそれらは全て **歴史的経緯等の理由からやむなくそういった実装になっているわけです。**
ですが、やはり直せるところは早めに直しておきたいですよね :sweat_smile: 

そこで先日、Poiboy iOSでも、デバイス判定ロジックの使用箇所の調査と除去にTRYしました。
その中で、**こうしたデバイス判定箇所ができた背景にいくつかのパターンがあったので、この記事ではそれを整理してまとめてみました。**

「直さなくても動く」系の問題ではありますが、今対応しておくことで長期的には無駄なメンテナンスコストがなくなり恩恵を受けられるはずです。
こうしたタスクも、Poiboyチームでは改善活動の中で取り組むことができます。
なお改善活動を手に入れるまでの背景については[弊社Diverseの勉強会にて発表](http://bit.ly/2RzGjPa)しております[^diversetech]ので、そちらの資料もご覧いただければと思います。

デバイス判定ロジックが発生する原因
---

さて本題です。
自分が調べた範囲において、 デバイス判定が発生した理由は以下の通りでした。

1. **そもそもAuto Layout / Safe Areaがよくわからないから**
2. **既存レイアウトの修正が面倒だから**
3. **連続的ではなく離散的にレイアウトを制御したいから**
4. **iPadでの表示をサポートする必要があったから**
5. **全画面で表示する画像が必要だから**

### 1. そもそもAuto Layout / Safe Areaがよくわからないから

#### 概要

Auto LayoutやSafe Areaに苦手意識のある、あるいは理解が追いついていないという方、ちゃんと勉強しないとなぁと思いつつやっていない方は、心当たりがあるかもしれません :sweat: 
自分もSafe Areaが出た当初はあまり良くわかっておらず、しっかり対応できずに放置した箇所がありました。

Safe Area Layoutを使わずにiPhone X対応をしている箇所は、 **だいたいのケースでダミーViewの制約を付け替えたりConstantを変えることによって実現されていることが多いです。**
その場合、 **端末の大きさやSafe Area外のUI変更に大きく影響を受けてしまいますのでやめた方が良いでしょう。**

例えばこんなレイアウト。

<img src="https://qiita-image-store.s3.amazonaws.com/0/17214/4cded9f0-f898-7c17-9c6d-62066608f985.png" alt="背景はSafe Area外まで表示してボタンはSafe Area内に置くレイアウトの例" width="480">

よくあるホームインジケータ領域に背景がかかっていて、ボタンのタップ領域はSafe Area内に置きたいケースです。
Poiboyのアプリを確認したところ、Safe Areaがよく分からずに対応していたためか、このようなデバイス判定フラグでダミーViewを挿入していたのです。

![safe area background wrong modified.png](https://qiita-image-store.s3.amazonaws.com/0/17214/eef00b70-2288-5d5f-94ca-5f69ad36f54b.png)

#### 対応方針

この場合

1. 背景はボタン上端からSuper Viewの下端(=Safe Area外)まで
2. ボタン領域はSafe　Areaの下端(=Safe Area内)まで

の高さ制約をつけることで対応できます。
実際やってみるとこういう感じですね。

![safe area background right modified.png](https://qiita-image-store.s3.amazonaws.com/0/17214/291bef92-64a1-b82d-c98b-c21414f551dd.png)

このように、Safe Area外にはコンテンツとして操作可能なものは置かないが、背景など操作に関係ないものは置いて良いわけです。
ルートのViewだけではなく、子のViewでもSafe Area Layoutを使うことができるので、無理にルートのSafe Areaに制約を付けなくても大丈夫です。
面倒かもしれませんが、**コンテンツと背景を分けたうえで、Safe Areaと画面領域のどちらにくっつけるのかを考えると良いでしょう。**

### 2. 既存レイアウトの修正が面倒だから

#### 概要

上記と似ていますが、既存のレイアウトがぐちゃぐちゃだったり見にくい場合に、**レイアウトを組み直すのが面倒なのでとりあえずダミーViewで対応したというケースもあります。**
先程と似た例ですが、今度はこのように下からアニメーションして出てくる場合を考えます。

![safe area animation.gif](https://qiita-image-store.s3.amazonaws.com/0/17214/af561890-1f5d-2f24-5b49-bd970678299f.gif)

:arrow_up: Safe Area外に　`UILabel` が配置されてしまっているのがおわかりでしょうか :question:

こちらも表示させたいViewの下部がBottom Layout Guideにくっついていました。
このため、 **iPhone XのみViewの縦横比制約を置換して、かつ下部のマージンを設定することで無理やりをSafe Area内に収めようとしていました。**

```swift
if isIPhoneX {
    // 事前にIBOutletで表示させたいViewの縦横比に関する制約を紐づけ
    if let aspectLayoutConstraint = self.otherAccountViewAspectLayoutConstraint {

        // 通常の制約を外して新しい制約に置換
        self.otherAccountView.removeConstraint(self.otherAccountViewAspectLayoutConstraint)
        let newAspectLayoutConstraint = NSLayoutConstraint(
            item: aspectLayoutConstraint.firstItem!,
            attribute: aspectLayoutConstraint.firstAttribute,
            relatedBy: aspectLayoutConstraint.relation,
            toItem: aspectLayoutConstraint.secondItem,
            attribute: aspectLayoutConstraint.secondAttribute,
            multiplier: CGFloat(375) / CGFloat(279),
            constant: 0)
        self.otherAccountView.addConstraint(newAspectLayoutConstraint)
        self.otherAccountViewAspectLayoutConstraint = newAspectLayoutConstraint
    }

    // ここらへんは無理やり画面端からのマージンを作っている
    self.otherAccountBottomViewHeightLayoutConstraint.constant = 53
    self.snsNotificationLabelTopMarginLayoutConstraint.constant = 9
    self.snsNotificationLabelCenterYLayoutConstraint.priority = .defaultLow
    self.snsNotificationLabelTopMarginLayoutConstraint.priority = .defaultHigh
}
```

先程の例と異なるのは、 **制約の付け替えによってアニメーションしている点です。**

![safe area animation incorrect modified.png](https://qiita-image-store.s3.amazonaws.com/0/17214/01c542b6-ac67-cc0f-1804-f852c9af31cc.png)

ダミーViewをやめてボタンのViewをSafe Area内に収めると、Safe Area外の部分はアニメーションされません。
しかし制約が複数IBOutletでつながっているため、親Viewを変えるのは少々面倒で放置されたというわけでした。

![safe area animation incorrect.gif](https://qiita-image-store.s3.amazonaws.com/0/17214/191c3469-a995-7c3b-ace5-0e0d2c5c27df.gif)

#### 対応方針

そこで、 **背景用のViewを親Viewではなく同じ階層に置き、そちらをボタン上部とBottom Layout Guideにくっつけることで、Safe Area外でも求めているアニメーションを実現することができます。**

![safe area animation right modified.png](https://qiita-image-store.s3.amazonaws.com/0/17214/08ffac88-c06c-356f-07a2-596f924e97df.png)

するとこんな感じで動きます。

![safe area animation right.gif](https://qiita-image-store.s3.amazonaws.com/0/17214/b2d03a5f-565c-db36-fc35-3100927dd1bf.gif)

やはりこちらについても、 **背景とコンテンツを分けるという視点を持つことで、アニメーションがあってもシンプルな実装で対応することができそうです。**

### 3. 連続的ではなく離散的にレイアウトを制御したいから

#### 概要

(フォント以外の例がなかったのでここではフォントに絞って話をします)

Auto Layoutでは、連続的な変化をさせることは特異ですが、離散的な変化をさせることは難しいと思っています(やり方はあるとは思いますが)。
例えば**「通常18ptのフォントサイズをiPhone SE(widthが320px以下)では16ptにする」みたいなことです。**
コードだとこのような実装になっていました。

```swift
if isIPhoneSe {
    view.titleLabel.font = UIFont(name: "HiraginoSans-W6", size: 13)
    view.descriptionLabel.font = UIFont(name: "HiraginoSans-W3", size: 11)
}
```

Size Classes[^sizeclasses]を使うことで、Interface Builder上でランドスケープモードやiPad等のカテゴリごとに別の値を指定することは可能です。

<img width="360" alt="size classes.png" src="https://qiita-image-store.s3.amazonaws.com/0/17214/625a70bd-04e6-b1d2-fd0c-2b52e4fd1955.png">

しかしこの方法ではiPhone X以前の端末は同じカテゴリに分類されますし、細かくデバイスごとにUIを指定するのには使えません。

#### 対応方針

**フォントであればMinimum Font ScaleやMinimum Font Sizeの項目を入れることで、一定サイズまでは自動的に縮小をかけてくれます。**

<img width="377" alt="minimum font scale.png" src="https://qiita-image-store.s3.amazonaws.com/0/17214/3225d2b2-a9f3-59ac-56d6-e06ec630d783.png">

なので身も蓋もない結論ですが**「連続的に変化させるようにする」ということになると思います。**

<img width="480" alt="minimum font scale.png" src="https://qiita-image-store.s3.amazonaws.com/0/17214/2aa8397a-35fa-ff43-b73e-c4adedf8c598.png">

:arrow_up: 連続的な変化のイメージ図

Poiboyでは基本的にMinimum Font Scaleを0.5までは許容するというデザインルールを設けています。
これに従えば、明示的にサイズを指定せずとも自動的に縮小させられますが、一部でなぜか明示的に値をしていた箇所があったのでそれを修正した次第です。

あくまで **フォントサイズはこちらが明示的に指定せず、Auto Layoutに自動計算させるというのが良いのではないかと思います。**

### 4. iPadでの表示をサポートする必要があるから

#### 概要

これまでiPhone専用アプリをiPadで動かすと、比率が3:2で表示されていました(iPad Pro除く)。
そのため一部の画面で**Viewが入りきらない or 潰れてしまう部分がありました。**
これを防ぐため、 **`isIPhone4s` のようなフラグ(サイズではなく比率で判定していた)を使い、一部の要素を非表示にするみたいなことをしてる箇所がありました。**
ユニバーサルアプリはちゃんと対応しないとですが、iPhone専用アプリの場合はiPadのためだけにUIScrollViewにするというのも考えものです。

#### 対応方法

ところが実は、 **iOS 12からiPadでiPhone用アプリを表示すると16:9の比率で表示してくれるようになっていた(らしい)です。[^ipadios12]**

Apple公式の情報が見当たらないのと実機を持っていないので断定していませんが、シミュレーター上では確かにそうなっていることが分かります。
iOS 11.4と12.1のiPad Airでの比較です。

|1xモード|2xモード|
|:---:|:---:|
|<img alt="iPad Air2でのiPhone互換モードの違い(1x)" src="https://qiita-image-store.s3.amazonaws.com/0/17214/c10942a9-217c-b7e8-61ca-efb4477b3737.png">|<img alt="iPad Air2でのiPhone互換モードの違い(2x)" src="https://qiita-image-store.s3.amazonaws.com/0/17214/8e00f4bb-e30a-54d2-c833-e56135460f9f.png">|

したがって(iOS 12未満のiPadユーザーを無視できれば) **ようやく4:3比率のデバイス対応から開放されるはずです。**
自分はこの事実知らなかったので、危うくフラグを放置し続けるところでした。

**iPhone専用アプリであれば、今後はアスペクト比16:9をベースに考え、最小サイズの端末をiPhone SEと考えて画面レイアウトを組むのが良いのかなと思いました。**

### 5. 全画面で表示する画像が必要だから

#### 概要

なんらかの事情により、フルスクリーンの引き伸ばし不可能な画像を使用する場合に、画像リソースの切り替えでデバイス判定をすることがあります。
例えばPoiboyだと

1. ログインの画面などで使う背景画像
2. 一部の画面のチュートリアル用画像

がフルスクリーン表示されていました。

1については、「背景画像を共通で用意して配置でどうにかすれば良いのでは :question: 」と思う方もいるかもしれません。
しかし画像の性質上、**縦長に切り取られるとどうしても画面端にいるモデルさんの顔が切れたりしてプロダクト的に良いとは言えない見た目になってしまいます。**
**そのため高アスペクト比の画像を別で用意し、ロジックでスイッチする箇所があるというわけです。**

画像比較をすると、比率によってモデルさんの写り込み方が異なるのが見て取れます。
16:9ではほぼ全身が入っているのに対して、2:1になると肩のあたりまでがカットされてしまいます。

|iPhone 8 Plus (縦横比 16:9)|iPhone X (縦横比 2:1)|
|:---:|:---:|
|<img width="320" alt="Simulator Screen Shot - iPhone 8 Plus - 2018-12-04 at 22.32.37.png" src="https://qiita-image-store.s3.amazonaws.com/0/17214/d7d6131d-c522-e161-0ae1-07a850d9d397.png">|<img width="320" alt="Simulator Screen Shot - iPhone X - 2018-12-04 at 21.55.10.png" src="https://qiita-image-store.s3.amazonaws.com/0/17214/a0f81287-7706-8cc9-b17f-291dcdd03b98.png">|

また2についても、**アプリ内でチュートリアルを実施する際、条件が揃っていないものの説明だけはしておきたいため、全画面画像で状態を再現し説明している箇所があり、当然比率を維持しないといけない**　ので、ここでも画像をスイッチする必要が生じているわけです。

#### 対応方針

最も難しかったのはここでした。
ここはもう **仕様の調整も含めての対応が必要です :exclamation:** (どんな開発でも仕様調整は避けては通れません :sweat_smile:)

もしかしたらデバイス判定は無くせないかもしれませんし、うまくいけば消すことができるかもしれません。
例えば1であれば、**背景と手前の要素(モデルさんとか)を分けて、かつ背景を引き伸ばし可能な要素にすれば理想的な対応が可能です。**
要は通常の画面のように、 **コンテンツとして表示する画像をAuto Layoutで配置しすることで良い感じにスケールさせるということです。**

ですが、当然クリエイティブを作る側との調整が必要にはなります。
Poiboyのログイン画面を見てもらうと分かるように、 **クロマキー撮影をしているわけではないので、そもそも背景を分離するのは無理 or めちゃくちゃ面倒です。**
そこまでしてデバイス判定ロジックをなくすべきかどうかはよく話し合って考えましょう。

2のケースも、チュートリアルの仕様自体を考え直すのが先決です。
アプリの状態管理の設計自体を見直すことも必要ですが、過度に複雑な仕様はエンジニア側から避けるべきとの提案をすべきだと思います :thumbsup: 

おまけ
---

なお「今すぐにデバイス判定は消せないけど、これ以上の使用はやめたい :expressionless:」という場合には、**`@available` アノテーションをつけておくことで使用時にwarningsを表示させることができます :warning:**

<img alt="deprecated available annotation.png" src="https://qiita-image-store.s3.amazonaws.com/0/17214/a2b981a7-7fee-3169-672f-4d52919f2262.png">

自分だけではなくチームメンバーへの周知にもなるので、一次対応としてはおすすめの方法です :thumbsup: 

<img alt="deprecated warnings annotation.png" src="https://qiita-image-store.s3.amazonaws.com/0/17214/6374101e-5b90-8af5-c5b8-cb27b06e8c3c.png">


まとめ
---

ということで、今回はPoiboy iOSにおけるデバイス判定ロジックの使用箇所と背景についてまとめました。
実際にはランドスケープモードやユニバーサルアプリに非対応という前提もあったので、対応すべき箇所は思うほど多くはありませんでした。
もし他にも「こういうところでデバイス判定していて消せなかった :exclamation: 」的なものがあれば、ぜひ共有していただけると嬉しいです。

そして明日は @cfiken さんによる機械学習にまつわるポストです :computer: 
果たしてどんな内容になるのでしょうか... :thinking: 乞うご期待です :exclamation: 

参考資料
---

[^ipadios12]: ["iPhone Only" apps on the iPad](https://bendodson.com/weblog/2018/06/20/iphone-only-apps-on-ipad/)
[^appleofficial]: [Auto Layout Techniques in Interface Builder \- WWDC 2017 \- Videos \- Apple Developer](https://developer.apple.com/videos/play/wwdc2017/412/)
[^diversetech]: [『「改善Dayを作ろう!」って言ってたけど気づいたらなくなったよね…』を繰り返さないために](http://bit.ly/2RzGjPa)
[^sizeclasses]: [Xcode Overview: Building for Multiple Screen Sizes](https://developer.apple.com/library/archive/documentation/ToolsLanguages/Conceptual/Xcode_Overview/BuildingforMultipleScreenSizes.html)