[Delphi] WorkArea / SafeArea / DisplayCutout に対応する

# WorkArea / SafeArea / Display cutout とは

iPhone ではカメラ部分にノッチ（切り欠き）があり（iPhone 14 でパンチホールになったけど）そのエリアを避ける事が推奨されています。
そして iPhone で味を占めたのか MacBook にもノッチを導入。
さらに iPhone に追随したいくつかの Android のメーカーがノッチを導入。
また、Windows でも TaskBar がある部分はアプリを表示できません。

ということで、どの OS でもアプリを表示できないエリアがあります。
その部分をそれぞれ WorkArea / SafeArea / DisplayCutout と呼びます。

|OS|呼び方|説明|備考|
|---|---|---|---|
|Windows|WorkArea|表示可能なエリア|TaskBarを除いた部分|
|macOS|SafeArea|表示可能なエリア|MacBook Pro 2021 よりノッチ対応|
|iOS|SafeArea|表示可能なエリア|全ての元凶|
|Android|DisplayCutout|表示**不**可能なエリア|Android だけ意味が逆|

これらを意識しないと情報が表示出来ない部分が出てしまいます。
どういうことかというと下記の図を見れば一目瞭然です。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12977/e338c395-d396-80b3-80f5-40d52f5318b4.png">

上図は今回大変参考にさせていただいたブログからです。
■How to Handle Safe Area Insets for iPhone X, iPad X, Android P
https://blog.felgo.com/cross-platform-app-development/notch-developer-guide-ios-android

この図から判るように情報が表示できないエリアを避ける必要があるということです。

# WorkArea を取得する

まずは Windows の WorkArea からです。

```pascal
var D := Screen.DisplayFromForm(Screen.ActiveForm);
var WorkArea := D.PhysicalWorkarea;
```

Windows お前は良い奴！簡単！

# SafeArea を取得する

macOS / iOS は除外する部分を返してくれる API safeAreaInsets を使います。

なんだか地獄みたいな端末の図が下にありますが、ここに示した様に上下左右でどれだけ避ければいいかを返してくれます。
<img height="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12977/60e03c29-6847-791f-dbdc-eb1a84ae7422.png">


## macOS
```pascal
type
  // macOS 12 以降に対応している safeAreaInsets を返す API を定義
  NSScreen12 = interface(NSScreen)
    function safeAreaInsets: NSEdgeInsets; cdecl;
  end;
  TNSScreen12 = class(TOCGenericImport<NSScreenClass, NSScreen12>)  end;

// safeAreaInsets は macOS Monterey 以降で対応
if not TOSVersion.Check(12) then
  Exit;

var Insets := TNSScreen12.Wrap(NSObjectToID(MainScreen)).safeAreaInsets;
```

## iOS
```pascal
// safeAreaInsets は iOS 11 以降で対応
if not TOSVersion.Check(11) then
  Exit;

var Insets := WindowHandleToPlatform(Screen.ActiveForm.Handle).Wnd.safeAreaInsets;
```

macOS と iOS もまあまあ良い奴。

# DisplayCutout を取得する

最後に Android です。
Android も macOS / iOS と同じように避けるエリアが取得できます。

ですが… Android は悪い奴！

他に比べて滅茶苦茶面倒くさいです。
中国メーカーが勝手に iPhone みたいなノッチを採用したので、OS が後追いで対応したせいかもしれません。
Delphi の標準 API ではサポートされていないので API も定義しないといけません。

まずは API の定義です。
メソッドは必要な部分だけ定義しています。
またいくつかは AndroidX (Jetpack) パッケージではない通常の Android API として再定義しています。
めっちゃ長いのでコードは折りたたみました。
<details><summary>API 定義</summary><div>

```pascal
type
// New SDK 30: Window Metrics
JWindowMetricsClass = interface(JObjectClass)
  ['{039F927D-93D1-4B95-BEAB-6A8588DAF375}']
end;

[JavaSignature('android/view/WindowMetrics')]
JWindowMetrics = interface(JObject)
  ['{C8921F16-C1AE-4C58-BD81-E8288D705C5C}']
  function getBounds: JRect; cdecl;
  function getWindowInsets: JWindowInsets; cdecl;
end;
TJWindowMetrics =
  class(TJavaGenericImport<JObjectClass, JWindowMetrics>) end;

// New SDK 30: Window Insets Type
JWindowInsets_TypeClass = interface(JObjectClass)
  ['{36B08B04-FF9D-4D19-94D5-8AF85DF6BAF9}']
  {class} function captionBar: Integer; cdecl;
  {class} function displayCutout: Integer; cdecl;
  {class} function ime: Integer; cdecl;
  {class} function mandatorySystemGestures: Integer; cdecl;
  {class} function navigationBars: Integer; cdecl;
  {class} function statusBars: Integer; cdecl;
  {class} function systemBars: Integer; cdecl;
  {class} function systemGestures: Integer; cdecl;
  {class} function tappableElement: Integer; cdecl;
end;

[JavaSignature('android/view/WindowInsets$Type')]
JWindowInsets_Type = interface(JObject)
  ['{2A3D2158-33F1-481B-A1D4-A22BF014C177}']
end;

TJWindowInsets_Type =
  class(TJavaGenericImport<JWindowInsets_TypeClass, JWindowInsets_Type>) end;

// ReDefine: DisplayCutout
[JavaSignature('android/view/DisplayCutout')]
JDisplayCutout = interface(JObject)
  ['{AD82BADF-58B4-40C8-ACDA-B1802CCEC1E2}']
  function getSafeInsetBottom: Integer; cdecl;
  function getSafeInsetLeft: Integer; cdecl;
  function getSafeInsetRight: Integer; cdecl;
  function getSafeInsetTop: Integer; cdecl;
end;

// ReDefine: Window Manager
[JavaSignature('android/view/WindowManager')]
JWindowManager30 = interface(JWindowManager)
  ['{5E63F8B9-E489-4002-A1F1-1DAF4705F030}']
  function getCurrentWindowMetrics: JWindowMetrics; cdecl;
end;
TJWindowManager30 =
  class(TJavaGenericImport<JWindowManagerClass, JWindowManager30>) end;

// ReDefine: Insets
[JavaSignature('android/graphics/Insets')]
JInsets30 = interface(JObject)
  ['{4990E1CE-0EFD-4269-A218-CD719C4B77FB}']
  function _Getbottom: Integer; cdecl;
  function _Getleft: Integer; cdecl;
  function _Getright: Integer; cdecl;
  function _Gettop: Integer; cdecl;
  property bottom: Integer read _Getbottom;
  property left: Integer read _Getleft;
  property right: Integer read _Getright;
  property top: Integer read _Gettop;
end;

// ReDefine: Window Insets
[JavaSignature('android/view/WindowInsets')]
JWindowInsets30 = interface(JWindowInsets)
  ['{A5F7AF8D-3F5B-49A4-9956-800BEFA5F293}']
  function getInsetsIgnoringVisibility(typeMask: Integer): JInsets30; cdecl;
  function getDisplayCutout: JDisplayCutout; cdecl;
end;
TJWindowInsets30 =
  class(TJavaGenericImport<JWindowInsetsClass, JWindowInsets30>) end;
```
</div></details>

API を定義してようやく DisplayCutout を取り出せますがバージョンで取り出し方が異なるのでまた面倒くさいです。

```pascal
// Android 12 未満は対応していない
if not TOSVersion.Check(12) then
  Exit;

// Android 12 と 13 以降では方法が異なる
if TOSVersion.Check(13) then
begin
  // Version 13 以降
  var Metrics :=
    TJWindowManager30.Wrap(
      TAndroidHelper.Activity.getWindowManager
    ).getCurrentWindowMetrics;

  // Insets 取得
  var Insets :=
    TJWindowInsets30.Wrap(
      TAndroidHelper.JObjectToID(Metrics.getWindowInsets)
    ).getInsetsIgnoringVisibility(
      TJWindowInsets_Type.JavaClass.displayCutout
    );
end
else
begin
  // Version 12
  var RootInsets :=
    TJWindowInsets30.Wrap(
      TAndroidHelper.JObjectToID(
        TAndroidHelper.Activity.getWindow.getDecorView.getRootWindowInsets
      );

  if RootInsets <> nil then
  begin
    var Cutout := RootInsets.getDisplayCutout;
    if Cutout <> nil then
    begin
      // Insets 取得
      var Insets := 
        Rect(
          Cutout.getSafeInsetLeft,
          Cutout.getSafeInsetTop,
          Cutout.getSafeInsetRight,
          Cutout.getSafeInsetBottom
        );
    end;
  end;
end;
```

# TSafeArea ライブラリの作成

一々、これらを呼んでいたら面倒なのでライブラリ化しました。
TSafeArea クラスです。
（WorkArea / SafeArea / DisplayCutout と名称がまちまちなのも嫌なので SafeArea に統一しました）

各 OS の特殊処理が多くて複雑になったので GitHub に上げました。

**ソースはこちら**
https://github.com/freeonterminate/SafeArea


## TSafeArea の使い方

TSafeArea.DpRect で FMX の仮想解像度になった SafeArea が返ってくるので、上下左右がこの範囲内に収まっているか検査して納まっていなければ納めるようにします。
（物理ピクセルで SafeArea を返す TSafeArea.PxRect プロパティもあります）

一番簡単なのは Form の一番上に TLayout を置いて、Align を Contents にし、TLayout の Margin を設定してやることです。
以降、コントロールをこの TLayout に置けば自動的に SafeArea に納まります。

<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12977/dc68aa3b-93c1-7e9e-0327-7a3e15c0d62d.png">

この TLayout の Mergin を設定するための特別なメソッド TSafeArea.GetMarginRect があります。

```pascal
class procedure TSafeArea.GetMartingRect(const AForm: TCommonCustomForm);
```

引数は１つ、マルチディスプレイ環境でどのディスプレイの情報を取るかを示す Form です。
Form が存在するディスプレイの環境を取ってきます。
最近はモバイル環境でもマルチディスプレイが使えるので、なるべく指定した方が良いですが nil も指定できます。
nil を指定すると Screen.ActiveForm → Application.MainForm → プライマリディスプレイ の順で検索します。

Form の上に乗った TLayout の名前を Root として Margin を設定すると、こんな風になります。

```pascal
uses
  PK.HardInfo.SafeArea;

procedure TForm1.FormShow(Sender: TObject);
begin
  Root.Margin.Rect := TSafeArea.GetMarginRect(Self);
end;
```

iOS では Form が表示されていないと SafeArea を取得できないので OnCreate ではなく OnShow などで設定します。
また、FullScreen モードでも OS が返してくる SafeArea をそのまま返すので、上部のステータスバー分などが必用なければ適宜修正してください。

```pascal:FullScreenで右端以外を無効にする例
procedure TForm1.FormShow(Sender: TObject);
begin
  var Margins := TSafeArea.GetMarginRect(Self);

  if FullScreen then
    Margines := RectF(0, 0, 0, Margins.Right); // 上下左のマージンを無効に

  Root.Margin.Rect := Margins;
end;
```

# 最後に
こんなに面倒な事になると思わなかった…

あと、これ絶対必要な作業なので TScreen.SafeArea プロパティを作って全 OS の SafeArea を返してくれればいいんじゃないかなあ。