LoginSignup
42
20

More than 3 years have passed since last update.

WindowInsetを理解する

Last updated at Posted at 2019-11-24

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



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="⑩"]
}

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

image.png
image.png



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]
}

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

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

image.png

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

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を設定できる。という感じになります。

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

    <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>
<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>
    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を設定してくれるというものです。

<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

    <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" />
    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


コードを読んだメモ
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;
}

42
20
0

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
42
20