WindowInsetを理解する

`android:fitSystemWindows` とか、 `setOnApplyWindowInsetsListener` とか `StatusBar` とか `NavigationBar` とか `DrawerLayout`とか、 `SYSTEM_UI_FLAG_..` とか難しいですよね？？

それを紐解いていきたいです。
それを理解していくにはAndroidでonApplyWindowInsets()がどのように呼ばれているのかを理解していく必要があります。

# 最初にまとめ

* ViewツリーでonApplyWindowInsets()が深さ優先で呼ばれる。
* 他のレイアウトでWindowInsetがconsumeされると意図した場所でonApplyWindowInsets()が呼ばれないので注意
* setSystemUiVisibilityでSYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATIONなどを設定するとアプリのレイアウトがstatusbarなどに食い込むようになる
* setOnApplyWindowInsetsListenerでinsets.systemWindowInsetTopなどで、その食い込んでいる高さがわかるので、レイアウトにpaddingを設定してあげれば良い。
* レイアウトに元から設定されているpaddingを考慮してあげるように、chrisbanes/insetterなどを使おう
* RecyclerViewでpaddingも描画するには`clipToPadding="false"`を使おう

# onApplyWindowInsets()の呼ばれ方
まずレイアウトの初期段階でViewRootImplの中でdispatchApplyInsets()が呼ばれ、そこからdispatchApplyWindowInsets()が呼ばれます。
木の上のレイアウトから深さ優先探索で呼ばれていきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27388/6eb02211-7b64-28d1-c1dd-48e78ea7fcc9.png)

<details>
<summary>図</summary>
```dot
digraph G {
    size=7
    subgraph cluster_0 {
        cluster_0_dispatch[label="dispatchApplyWindowInsets()"]
        cluster_0_apply[label="onApplyWindowInsets()"]
        label = "ViewGroup";
    }
    subgraph cluster_1 {
        cluster_1_dispatch[label="dispatchApplyWindowInsets()"]
        cluster_1_apply[label="onApplyWindowInsets()"]
        label = "ChildViewGroup1";
    }
    subgraph cluster_2 {
        cluster_2_dispatch[label="dispatchApplyWindowInsets()"]
        cluster_2_apply[label="onApplyWindowInsets()"]
        label = "ChildViewGroup2";
    }
    cluster_0_dispatch -> cluster_0_apply [label="①"]
    cluster_0_apply -> cluster_0_dispatch [label="②"]
    cluster_0_dispatch -> cluster_1_dispatch [label="③"]
    cluster_1_dispatch -> cluster_1_apply [label="④"]
    cluster_1_apply -> cluster_1_dispatch [label="⑤"]
    cluster_1_dispatch -> cluster_0_dispatch [label="⑥"]
    cluster_0_dispatch -> cluster_2_dispatch [label="⑦"]
    cluster_2_dispatch -> cluster_2_apply [label="⑧"]
    cluster_2_apply -> cluster_2_dispatch [label="⑨"]
    cluster_2_dispatch -> cluster_0_dispatch [label="⑩"]
}
```
</details>

**将来的には常に上記の動きになりそうですが、現在は以下のような動き**が入っています。
(https://github.com/aosp-mirror/platform_frameworks_base/blob/android10-release/core/java/android/view/View.java の sBrokenInsetsDispatchを参照 )
**WindowInsetsをconsumeしたインスタンスを返した場合は他のメソッドは呼ばなくなっています**。
WindowInsetsをconsumeとはどういうことなのかはもう少し後で出てきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27388/274b54fd-05bf-0fed-57a8-6c7f5b73ae9f.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27388/b8c525a8-ff2b-48dc-ba46-634186774c82.png)

<details>
<summary>図</summary>
```dot
digraph G {
    size=7
    subgraph cluster_0 {
        cluster_0_dispatch[label="dispatchApplyWindowInsets()"]
        cluster_0_apply[label="onApplyWindowInsets()"]
        label = "ViewGroup";
    }
    subgraph cluster_1 {
        cluster_1_dispatch[label="dispatchApplyWindowInsets()"]
        cluster_1_apply[label="onApplyWindowInsets()"]
        label = "ChildViewGroup1";
    }
    subgraph cluster_2 {
        cluster_2_dispatch[label="dispatchApplyWindowInsets()"]
        cluster_2_apply[label="onApplyWindowInsets()"]
        label = "ChildViewGroup2";
    }
    cluster_0_dispatch -> cluster_0_apply [label="①"]
    cluster_0_apply -> cluster_0_dispatch [label="②"]
    cluster_0_dispatch -> cluster_1_dispatch [label="③"]
    cluster_1_dispatch -> cluster_1_apply [label="④"]
    cluster_1_apply -> cluster_1_dispatch [label="⑤onsumeしたWindowInsetをreturnする"]
    cluster_1_dispatch -> cluster_0_dispatch [label="⑥onsumeしたWindowInsetをreturnする"]
    cluster_0_dispatch -> cluster_2_dispatch [label="⑦☓呼ばれない"]
    cluster_2_dispatch -> cluster_2_apply [label="⑧",style = invis]
    cluster_2_apply -> cluster_2_dispatch [label="⑨",style = invis]
    cluster_2_dispatch -> cluster_0_dispatch [label="⑩",style = invis]
}
```
</details>

# DrawerLayoutから見るonApplyWindowInsets()の実装方法

DrawerLayoutでは見て分かる通り、StatusBarに食い込んでレイアウトを表示しています

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27388/4d629b42-4fc1-4f2e-d92e-15c1e896aa2e.png)


まずはView.javaの中のdispatchApplyWindowInsets()の実際のコードを確認してみましょう。
`mOnApplyWindowInsetsListener`があればそれを使うし、なければ、`View.onApplyWindowInsets()`を使うようになっています。

```java:View.dispatchApplyWindowInsets()
    public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
        try {
            mPrivateFlags3 |= PFLAG3_APPLYING_INSETS;
            if (mListenerInfo != null && mListenerInfo.mOnApplyWindowInsetsListener != null) {
                return mListenerInfo.mOnApplyWindowInsetsListener.onApplyWindowInsets(this, insets);
            } else {
                return onApplyWindowInsets(insets);
            }
        } finally {
            mPrivateFlags3 &= ~PFLAG3_APPLYING_INSETS;
        }
    }
```

つまり、`setOnApplyWindowInsetsListener()`でリスナーを追加することで、`onApplyWindowInsets()`を実装したのと同じことができます。


DrawerLayoutのコンストラクタ内でこのリスナーを使って、処理を行っているので、見ていきましょう。以下の2つのことを行っています
① setOnApplyWindowInsetsListenerを使っており、setChildInsets内で自分でpaddingを設定して、consumeしたinsetを返す
② setSystemUiVisibilityでの設定

`SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN`によって、アプリコンテンツのレイアウトが上部に食い込みます。そして**statusBarの大きさなどが入ったinsetがonApplyWindowInsets()で渡ってきて、自分でpaddingを設定できる。という感じになります。**

```java:DrawerLayoutのコンストラクタ内
        if (ViewCompat.getFitsSystemWindows(this)) {
            if (Build.VERSION.SDK_INT >= 21) {
                // ①
                setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() {
                    @Override
                    public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) {
                        final DrawerLayout drawerLayout = (DrawerLayout) view;
                        drawerLayout.setChildInsets(insets, insets.getSystemWindowInsetTop() > 0);
                        return insets.consumeSystemWindowInsets();
                    }
                });
                // ②
                setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
                final TypedArray a = context.obtainStyledAttributes(THEME_ATTRS);
                try {
                    mStatusBarBackground = a.getDrawable(0);
...
        }
```

ちなみに**SYSTEM_UI_FLAG_LAYOUT_FULLSCREENではなくSYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATIONを使うことでレイアウトの領域をStatusBarとNavigationBarの領域も含むようにする事ができます。**

つまりアプリだと以下のようにするといい感じにできます。
将来的にconsumeが使われないようになりそうなのと、chris banes氏も使っていないようなのでconsumeは使っていません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27388/9380f713-763a-ee5c-cb30-61df83645a7a.png)


```xml
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="android:statusBarColor">#80FFFFFF</item> // **半分白く**
        <item name="android:navigationBarColor">#80FFFFFF</item>  // **半分白く**
    </style>
```

```xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#22ff0000" // **デバッグ用に赤くする**
    tools:context=".MainActivity">
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="hello world" />
    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:text="bye world" />
</FrameLayout>
```

```kotlin
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val root = findViewById<View>(R.id.root)
        root.setOnApplyWindowInsetsListener { v, insets ->
            root.updatePadding(
                top = insets.systemWindowInsetTop,
                bottom = insets.systemWindowInsetBottom
            )
            return@setOnApplyWindowInsetsListener insets
        }
        root.systemUiVisibility =
            View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
 // **レイアウトの領域をStatusBarとNavigationBarの領域も含むようにする**
                    View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION 
    }
```

# chrisbanes/insetterはなにをするか？
今回の記事のきっかけになった記事が以下です。
以下のブログで、chrisbanes/insetterについての紹介があります。
https://chris.banes.dev/2019/04/12/insets-listeners-to-layouts/

**現在の方法だと、paddingがViewに設定されていた場合にupdatePaddingの呼び出しによって上書きされておかしくなってしまうので、最初にViewに設定されているpaddingを保存しておいて、それとsystemWindowInsetBottomなどを合わせてpaddingを設定してくれるというものです。**

```xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/root"
    android:paddingTop="16dp"  // **このpaddingが上書きされてしまうのを解決する**
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#22ff0000"
    tools:context=".MainActivity">
```

# RecyclerViewをいい感じに表示するには？

clipToPadding="false"によってpadding内も描画させることによって、スクロールもでき、またコンテンツを表示しておくことができます。

![scroll.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27388/4af9ca42-81ae-a45a-204a-07d08c43e801.gif)

```xml
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler"
        android:background="#22ff0000"
        android:clipToPadding="false"  // ← padding内も描画される
        android:orientation="vertical"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
```

```kotlin
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val recyclerView = findViewById<RecyclerView>(R.id.recycler)
        recyclerView.setOnApplyWindowInsetsListener { v, insets ->
            recyclerView.updatePadding(
                top = insets.systemWindowInsetTop,
                bottom = insets.systemWindowInsetBottom
            )
            return@setOnApplyWindowInsetsListener insets
        }
        recyclerView.systemUiVisibility =
            View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
                    View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
```

<details>
<summary>コードを読んだメモ</summary>
Android 10のコードをちょっと読んでみました。
Viewのメソッドとして、onApplyWindowInsetというメソッドがあります。
まず、AndroidアプリのViewツリーは以下のようなViewの構造になっています
```
ViewRootImpl (ViewParentだが、Viewではない)
  DecorView
    View (statusBarBackground)
    アプリのレイアウト
    View (navigationBarBackground)
```
まずレイアウトの初期段階でViewRootImplの中でdispatchApplyInsets()が呼ばれるようです。
`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS`とかめちゃくちゃ気になりますが、一旦無視します。
`host.dispatchApplyWindowInsets(insets);`というところで、DecorViewのdispatchApplyWindowInsetsが呼び出されます。
```java:ViewRootImpl
    void dispatchApplyInsets(View host) {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "dispatchApplyInsets");
        WindowInsets insets = getWindowInsets(true /* forceConstruct */);
        final boolean dispatchCutout = (mWindowAttributes.layoutInDisplayCutoutMode
                == LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS);
        if (!dispatchCutout) {
            // Window is either not laid out in cutout or the status bar inset takes care of
            // clearing the cutout, so we don't need to dispatch the cutout to the hierarchy.
            insets = insets.consumeDisplayCutout();
        }
        // *** ここでDecorViewのdispatchApplyWindowInsetsを呼ぶ ***
        host.dispatchApplyWindowInsets(insets); 
        Trace.traceEnd(Trace.TRACE_TAG_VIEW);
    }
```
DecorViewはViewGroupを継承しているため、ViewGroupのdispatchApplyWindowInsetsが呼ばれます。
`View.sBrokenInsetsDispatch`など気になる処理がありますが、まずは親クラスのViewの`dispatchApplyWindowInsets(insets)`が呼ばれます。
```java:ViewGroup
    @Override
    public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
        insets = super.dispatchApplyWindowInsets(insets); // まずViewのdispatchApplyWindowInsets(insets)を呼び出す。
        if (View.sBrokenInsetsDispatch) {
            return brokenDispatchApplyWindowInsets(insets);
        } else {
            return newDispatchApplyWindowInsets(insets);
        }
    }
```
NEW_INSETS_MODE_FULLになっていないか、targetSdkVersionがAndroid 10以上でなければ、sBrokenInsetsDispatchがtureになります。
新しい仕組みにはcosumeで呼ぶのをやめたりする仕組みがなく、古い仕組みにはconsumeという仕組みで呼ばなくなるというのがあるようです。
```java
sBrokenInsetsDispatch = ViewRootImpl.sNewInsetsMode != NEW_INSETS_MODE_FULL
                    || targetSdkVersion < Build.VERSION_CODES.Q;
...
    @Override
    public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
        insets = super.dispatchApplyWindowInsets(insets); 
        if (View.sBrokenInsetsDispatch) {
            return brokenDispatchApplyWindowInsets(insets);
        } else {
            return newDispatchApplyWindowInsets(insets);
        }
    }
    private WindowInsets brokenDispatchApplyWindowInsets(WindowInsets insets) {
        if (!insets.isConsumed()) {
            final int count = getChildCount();
            for (int i = 0; i < count; i++) {
                insets = getChildAt(i).dispatchApplyWindowInsets(insets);
                if (insets.isConsumed()) {
                    break;
                }
            }
        }
        return insets;
    }
    private WindowInsets newDispatchApplyWindowInsets(WindowInsets insets) {
        final int count = getChildCount();
        for (int i = 0; i < count; i++) {
            getChildAt(i).dispatchApplyWindowInsets(insets);
        }
        return insets;
    }
```
</details>