iOS Today Extention (ウィジェット)のサイズは固定ではない

iOSのウィジェットのサイズについて、情報を見つけられなかったため記録を残します。
iOS9以降を対象にしていて、8以前は調べていません。

対象OS:iOS9/10/11
対象XCode: 9.4

# ウィジェットのライフサイクル
Todayを表示するとき、毎回Todayビューコントローラーの最初から始まります。
iOS10以降でアプリアイコンを3DTouchで押した時に表示されるウィジェットでも同じです。

- iOS9
  -  Todayを表示
      1. awakeFromNib()
      2. beginRequest()
      3. viewDidLoad()
      4. **widgetMarginInsets()**
      5. widgetPerformUpdate
      6. viewWillAppear()
      7. viewDidAppear()
  - Todayを表示終了
     1. viewWillDisappear()
     2. viewDidDisappear()
     3. deinit()


- iOS10以降
  -  Todayを表示
     1. awakeFromNib()
     2. beginRequest()
     3. viewDidLoad()
     4. **widgetActiveDisplayModeDidChange()**
     5. widgetPerformUpdate()
     6. viewWillAppear()
     7. viewDidAppear()
  -  Show more /Show lessを押した時
     1. widgetActiveDisplayModeDidChange()
  -  Todayを表示終了
     1. viewWillDisappear()
     2. viewDidDisappear()
     3. deinit()
  
iOS9までと10以降では4ステップ目に呼ばれる関数が違っています。
deinitは即行われるわけではありませんが、だいたい表示終了後、数秒で行われるようです。
2番目のbeginRequest()はcontextを渡してきますが、iOS9ではとくに情報は入っていません。iOS10以降では、サイズの情報が得られます。この情報はビューコントローラーのextensionContextに保持され、viewDidLoad()以降のいつでも参照できます。
ウィジェットは特に迅速な動作を要求されます。表示するたびに毎回最初から作られるので、起動したらそこにある情報を使って動くよう設計しておく必要があります。

# ウィジェットの画面サイズ

App Extention Programming Guide の Todayのページには、Auto Layoutを使うよう指示があります。
ウィジェットのサイズは、システムの強い制約を受けます。ウィジェットの横幅はデバイスやOS毎に固定されていて変更できません。高さは一定の制約の中で変更可能ですが、iOS9以前とiOS10以降で仕様が異なっています。

## iOS9のウィジェット
![ios9iPhone4sWidgetSizes.png](https://qiita-image-store.s3.amazonaws.com/0/137262/82ffb40d-6828-baee-50ad-0c97aea7fce7.png)

iOS9ではウィジェットの高さは、ウィジェットが制御できます。

### 高さを動的に変えなくて良い場合
ウィジェットの高さを固定できる場合は、`viewDidLoad`の中でビューコントローラーの`preferredContentSize`の`height`に入れて指定します。`width`は0にします。
ビューコントローラの`view`の`translatesAutoresizingMaskIntoConstraints`を`false`にしないようにしてください。
`preferredContentSize`で指定した高さは、最終的に制約に反映されます。`translatesAutoresizingMaskIntoConstraints`を`falseに`してしまうと、その制約がつくられなくなり、高さが不定になります。

```swift:TodayViewController.swift
func viewDidLoad() {
    preferredContentSize = CGSize(width:0, height:希望の高さ)
    view.translatesAutoresizingMaskIntoConstraints = true 
}
```

### iOS9で高さを可変にしたいとき
`preferredContentSize`はビューのサイズを決め打ちしてしまうので、ラベルなどのもつ本来のコンテンツサイズ(intrinsic content size)を自動的に反映させることはできません。例えばDynamic Typeに対応するなど、コンテンツサイズに応じた表示にする場合は、`preferredContentSize`を指定せず、Auto Layoutに全部をまかせます。そうすると、内部サイズを反映した高さでウィジェットを作成してくれます。ただし、幅は決められているので、どのサイズになってもいいようにAuto Layoutを設計する必要があります。また、最上位ビューの高さが決まるようなレイアウトにしないと、無駄に大きいサイズでウィジェットが作られます。
このときも、`translatesAutoresizingMaskIntoConstraints`は`true`のままにするようにしてください。

### insetの高さは0がよい
ビューコントローラーに`widgetMarginInsets(forProposedMarginInsets:)`を実装すると、widgetの表示の余白を制御することができます。デフォルトでは、ウィジェットをインデントして右によせ、下にも余白をとります。機種毎のOS9でのウィジェットのinset値はこうなっていました。

| 機種 | 画面サイズ        | 横幅  |inset上| inset左 |  inset下 | inset右|
|--:|:-:|:-:|:-:|:-:|:-:|:-:|
|iPhone4s| 320x480    |272 | 0.0 | 48.0 | 39.0 |   0.0
|iPhone5等 | 320x568   |272 | 0.0 | 48.0 | 39.0  |  0.0
|iPhone6等 | 375x667   |336 | 0.0 | 48.0 | 39.0 |   0.0
|iPhone6Plus等 |414x736|375 | 0.0 | 52.0 | 39.0  |  0.0

この関数では高さ0を返しておく事をお勧めします。というのは、一番最初にウィジェットを表示するときだけ、ウィジェットの高さが`preferredContentSize.height + inset.top + inset.bottom`になってしまう ~~不具合~~ 現象があるので、サイズが変化してしまうからです。次にサイズを変えるきっかけがあると、ウィジェットの高さは正しく`preferredContentSize.height`になります。

```swift:TodayViewController.swift
func widgetMarginInsets(forProposedMarginInsets defaultMarginInsets: UIEdgeInsets) -> UIEdgeInsets {
    return = UIEdgeInsets(top: 0, left: defaultMarginInsets.left, bottom: 0, right: defaultMarginInsets.right)
}
```

ウィジェットは即時応答性を高めるために、前に表示した画像のキャプチャを一旦表示して、新しいサイズにアニメーションしながら変化させられるよう仕組みが用意されているのですが、その部分に間違ったサイズを指示してくるタイミングがあるようです。一番最初だけは高さ0で始まり、それからウィジェットのサイズに変化するよう指示がきます。このとき、`viewWillTransition()`にはウィジェットのサイズで指示が来ますが、実際のビューは、insetの高さを足した大きさで作られてしまいます。
insetを0にして影響がないようにするのが一番簡単な回避策だと思います。
固定の高さでよければ、大きさが変わっても問題ないようなレイアウトにすることもできます。高さをオートレイアウトで決める場合は難しいでしょう。

### iOS9のウィジェットの高さの決め方
iOS9ではOSが指定してくるウィジェットのサイズを調べる手段がなく、画面サイズを元に自分で適当に決める必要があります。App Extention Programming Guideには、スクロールしなければならないほど大きいサイズを指定しないように記載があります。
iOS10以降で仕様が変わってしまっているので、そちらに合わせて決めるのも現実的な方法だと思います。

### iOS9のウィジェットの文字色
最新のヒューマンインターフェースガイドラインでは暗い文字を推奨していますが、iOS9では背景が暗いので、白い文字のほうが良いです。昔のヒューマンインターフェースガイドラインには、おそらく白くするように指示があったんでしょうね。

## iOS10以降のウィジェット

iOS10以降は大きく仕様が変わって、幅だけでなく高さも固定になりました。
ウィジェットのサイズを2種類（`.compact`と`.expanded`)定義できるようになり、「表示を増やす」/「表示を減らす」ボタンでユーザーが切り替えられます。`.compact`への対応は必須で、`.expanded`は任意です。
`.expanded`は制限内で任意の高さに変更できますが、`.compact`はOSが決めた値に固定されます。
`preferredContentSize`の高さ指定は`.expanded`では有効ですが、`.compact`では無視されます。
`widgetMarginInsets()`はもはや呼ばれません。
その代わり、ビューコントローラーの`extensionContext`に大きさの情報が入るようになりました。
`extentionContext.widgetActiveDisplayMode`で現在のサイズを取得でき、
`extentionContext.widgetLargestAvailableDisplayMode`で、`.expanded`を使うかどうかを指定できます。
また、大きさを切り替える時、ビューコントローラーの`widgetActiveDisplayModeDidChange()`が呼ばれます。この関数で`.expanded`になるときに、`preferredContentSize`に高さを指定します。高さの範囲は`.compact`と`.expanded`のそれぞれの最大サイズの間です。`width`は0でもかまいません。
widgetが自分でどちらのサイズにするかを選ぶことはできないようです。
(iOS11ではNewsが最初から`.expanded`であるように思えますが、やり方がわかりませんでした)

使い方も変わり、Search画面にも出せるようになり、3D Touchでアプリアイコンを押した時にもウィジェットが表示されるように拡張されています。

### 高さ固定という誤解
高さ固定といっても、単純に固定ではありません。Dynamic Typeに合わせて変化してしまいます。
(Dynamic Typeについては、[iOSのDynamic Typeについて](https://qiita.com/eytyet/items/24bed1106a42b19819b5) に書きました。)
web上の多くの情報は、単にwidgetの高さは110(または120)固定であるとしていますが、正しくありません。
Dynamic Typeは、ユーザーがいつでもフォントサイズを変化させることができる機能です。ウィジェットの大きさは、これに合わせて、たとえあなたのアプリがDynamic Typeに対応していなくても、動的に変化します。
変化は.compactの大きさだけでなく、.expandedの方にも影響し、maxSizeが変化します。
以下に調査結果を示します。

**iOS10.3のウィジェットサイズ:**
![iOS10widget.png](https://qiita-image-store.s3.amazonaws.com/0/137262/75fea6a3-66a0-cf19-18b4-8d93aa928c88.png)


| 機種        |画面   |横幅|モード    |1|2|3 |4|5|6|7|8|9|10|11|12|
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|                  
|iPhone4s    |320x468|304|.compact | 95 | 100 | 105 | 110 |120 |130 |140 |170 |200 | 240 | 280 | 325 |
|↑           |↑      |↑  |.expanded|532 | 520 | 504 | 528 |528 |520 |504 |476 |480 | 480 | 448 | 520 |
|iPhone5/SE  |320x568|304|.compact | 95 | 100 | 105 | 110 |120 |130 |140 |170 |200 | 240 | 280 | 325 |
|↑           |↑      |↑  |.expanded|532 | 520 | 504 | 528 |528 |520 |504 |476 |480 | 480 | 448 | 520 |
|iPhone6等     |375x667|359|.compact | 95 | 100 | 105 | 110 |120 |130 |140 |170 |200 | 240 | 280 | 325 | 
|↑           |↑       |↑ |.expanded|608 | 600 | 630 | 616 |624 |624 |616 |612 |560 | 576 | 560 | 520 |
|iPhone6Plus等(縦) |414x736|398|.compact|95  | 100 | 105 | 110 |120 |130 |140 |170 |200 | 240 | 280 | 325 | 
|↑           |↑       |↑ |.expanded|684 | 680 | 672 | 660 |672 |676 |672 |680 |640 | 672 | 672 | 650 | 
|iPhone6Plus等(横) |736x414|420|.compact|95  | 100 | 105 | 110 |120 |130 |140 |170 |200 | 240 | 280 | 325 | 
|↑           |↑       |↑ |.expanded|684 | 342 | 378 | 352 |336 |364 |336 |340 |320 | 288 | 336 | 260 | 

表の1〜12はDynamic Typeで指定したの文字の大きさで、表内の数値がそのときの高さです。
1が最小でデフォルトは4、8以上はアクセシビリティで「さらに大きな文字」スイッチをオンにすることで選べます。
`.compact`行の数値はウィジェットの固定高さで、`.expanded`行は、その時の`maxSize.height`の値を示しています。
`.expanded`の最大高さが微妙に変化する理由はよくわかりませんが、調べた限り毎回同じ結果になりました。
また、iPhone6Plusなどの5.5インチiPhoneの横表示は、高さも幅も縦表示と違う設定になっています。
`.expanded`という名前なのに、`.compact`より小さいサイズになるところがあったりしますが、おそらくこれは不具合です。このケースでは、ボタンを押しても高さが低い方に寄って変化しなくなるという変な動きをします。以下のコードでは、それぞれのモードのmaxSizeにすることができました。

```swift:TodayViewController.swift
@available(iOSApplicationExtension 10.0, *)
func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) {
    switch activeDisplayMode {
        case .compact:
            preferredContentSize = CGSize(width: 0, height: 0)
        case .expanded:
            preferredContentSize = maxSize
        }
    }
}
```

(iPadは調べていませんが、さらにひどい混乱があるかもしれません。なお、widgetは常にiPadにも対応が必須です・・・)


**iOS11.4のウィジェットサイズ:**
![ios11iPhone5WidgetSizes.png](https://qiita-image-store.s3.amazonaws.com/0/137262/4e1ab45a-fe19-655e-8383-80cc3a286f2c.png)


| 機種        |画面   |横幅|モード    |1|2|3 |4|5|6|7|8|9|10|11|12|
|--:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|                  
|iPhone5/SE  |320x568|304|.compact | 95 | 100 | 105 | 110 |120 |130 |145 |170 |200 | 200 | 200 | 200 |
|↑           |↑      |↑  |.expanded|456 | 440 | 462 | 440 |480 |520 |522 |680 |720 | 640 | 640 | 560 |
|iPhone6等     |375x667|359|.compact | 95 | 100 | 105 | 110 |120 |130 |145 |170 |200 | 200 | 200 | 200 | 
|↑           |↑       |↑ |.expanded|532 | 560 | 546 | 528 |576 |624 |696 |816 |880 | 880 | 800 | 800 |
|iPhone6Plus等(縦) |414x736|398|.compact|95  | 100 | 105 | 110 |120 |130 |145 |170 |200 | 200 | 200 | 200 | 
|↑           |↑       |↑ |.expanded|608 | 600 | 630 | 616 |672 |676 |754 |884 |1040| 1040| 1040| 880 | 
|iPhone6Plus等(横) |736x414|398|.compact|95  | 100 | 105 | 110 |120 |130 |145 |170 |200 | 200 | 200 | 200 | 
|↑           |↑       |↑ |.expanded|608 | 600 | 630 | 616 |672 |676 |754 |884 |1040 | 1040| 1040| 880 | 
|iPhoneX     |375x812|359|.compact|95  | 100 | 105 | 110 |120 |130 |145 |170 |200 | 200 | 200 | 200 | 
|↑           |↑       |↑ |.expanded|684 | 680 | 672 | 660 |720 |780 |812 |1020|1120|1120 |1040 | 960 | 

iOS11では5.5インチiPhoneの横表示のウィジェットサイズは、縦表示と同じに統一されています。
iOS10のように`.compact`と`.expanded`が逆転するケースはもうなく、`.expanded`の最大サイズには画面より大きいサイズを指定できるようになりました。
なお、`.expanded`でのウィジェットの大きさは、ウィジェットのタイトル部分の大きさに影響されます。タイトルが2行になると、その分ウィジェット本体の高さが減って、全体の大きさが変わらないように調整されます。例えば上記の表で、iPhoneXでは、サイズ10より11の方が最大高さが減っていますが、これは10まではタイトルが1行に収まっていたのが11で2行になった結果です。必ず上記の値になるわけではないのでご注意ください。


## iOS11のウィジェットのフォントサイズ
iOS11では、TodayExtentionの内部から`UIFont.preferredFont()`を呼ぶと、通常のアプリとは異なるサイズのフォントを返してきます。大きすぎるフォントを使わないように配慮されているようです。

**iPhone5/SE系:**

| スタイル | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|--:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|.body      |14|15|16|17|19|21|23|28|33|**33**|**33**|**33**|
|.callout   |13|14|15|16|18|20|22|26|32|**32**|**32**|**32**|
|.caption1  |11|11|11|12|14|16|18|22|26|**26**|**26**|**26**|
|.caption2  |11|11|11|11|13|15|17|20|24|**24**|**24**|**24**|
|.footnote  |12|12|12|13|15|17|19|23|27|**27**|**27**|**27**|
|.headline  |14|15|16|17|19|21|23|28|33|**33**|**33**|**33**|
|.subheadline|12|13|14|15|17|19|21|25|30|**30**|**30**|**30**|
|.title1    |25|26|26|26|27|28|30|36|41|**41**|**41**|**41**|
|.title2    |19|20|20|20|21|22|24|29|35|**35**|**35**|**35**|
|.title3    |19|20|20|20|21|22|24|29|35|**35**|**35**|**35**|
|.largeTitle|31|32|32|32|33|34|35|42|46|**46**|**46**|**46**|

**iPhone6系/6Plus系/X:**

| スタイル | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
|--:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|.body      |14|15|16|17|19|21|23|28|33|**33**|**33**|**33**|
|.callout   |13|14|15|16|18|20|22|26|32|**32**|**32**|**32**|
|.caption1  |11|11|11|12|14|16|18|22|26|**26**|**26**|**26**|
|.caption2  |11|11|11|11|13|15|17|20|24|**24**|**24**|**24**|
|.footnote  |12|12|12|13|15|17|19|23|27|**27**|**27**|**27**|
|.headline  |14|15|16|17|19|21|23|28|33|**33**|**33**|**33**|
|.subheadline|12|13|14|15|17|19|21|25|30|**30**|**30**|**30**|
|.title1    |25|26|27|28|30|32|34|38|43|**43**|**43**|**43**|
|.title2    |19|20|21|22|24|26|28|34|39|**39**|**39**|**39**|
|.title3    |17|18|19|20|22|24|26|31|37|**37**|**37**|**37**|
|.largeTitle|31|32|33|34|36|38|40|44|48|**48**|**48**|**48**|


# 対応例
私の場合は、ウィジェットに置くのは、短い説明文とボタン(タップでアプリを起動)という単純な内容だったので、サイズが大きくなるのを防ぎたいと思って詳細を調べたのですが、結局、**できない**事がわかりました。
仕方がないので上記の画像のように、`.compact`にのみ対応し、せめてボタンがはみ出さないようにしました。みっともないですが、仕方がありません。

# その他
## iOS9でのDynamic Type対応について
Today Extensionでは、iOS9であっても、`NSNotification.Name.UIContentSizeCategoryDidChange`による文字サイズの変更通知の取得は必要ありません。設定アプリへ行って、Todayビューへ戻ってきた時にまた最初から実行されるので、結果的に、新しい設定を反映した画面を作ることになります。

## 終わりに
ウィジェットの大きさがDynamic Typeと連動する事についての記述は他にみたことがなく、仕方なく記事を書くことにしました。たまたま私が調べた範囲ではそうだったという以上の根拠はありませんが、実際に、このように動いていると思います。
Appleの公式アプリや、Googleのアプリは対応しているので、当たり前の仕様なのかもしれません。
本気で対応すると、かなり厄介な仕様になっていると思いますが、ウィジェットのサイズは強制的に適用されてしまい、逃げられません。サイズが変わっても問題がないようにみなさんのアプリもご確認ください。

アクセシビリティに対応したアプリが増える事を望みます。
誤解などあれば、ご教示頂けると記事を書いた甲斐があります。遠慮なくお願いいたします。


## 参考文献
[App Extention Programming Guide - Today](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Today.html#//apple_ref/doc/uid/TP40014214-CH11-SW1)
[iOS Human Interface Guideline - Widgets](https://developer.apple.com/design/human-interface-guidelines/ios/extensions/widgets/)