Android Detail:Window 篇——WindowInsets 与 fitsSystemBar

Flywith24

共 12731字,需浏览 26分钟

 · 2021-12-06

前言

很高兴见到你!👋

作为 Android 开发者,不知道你是否遇到这样的问题:

  • 不知道如何获取状态栏,导航栏以及软键盘高度
  • 将内容绘制到状态栏和导航栏区域后发生视觉和交互冲突
  • 不懂适配 edge-to-edge 的原理
  • 搞不清 android:fitsSystemWindows 这个属性的作用

9 月份纯纯写作的开发者 Drakeet 大佬在扔物线的知识星球中分享了关于 WindowInsets 的视频内容,视频介绍了以下内容:

  • WindowInsets 是什么?
  • WindowInsets 如何被分发?
  • 如何适配透明手势导航栏(edge-to-edge)?
  • 如何准确监听软键盘弹出和获得软键盘高度?
  • 如何自定义软键盘弹出后的响应行为?

本文是对该视频的补充,内容大多摘自 Chris Banes 在 2017 年做的演讲:Becoming a master window fitter🔧[1](已搬运至 B 站)。

阅读本文,你将了解以下内容:

  • System bar 各个版本能力的变化
  • App 将内容绘制到 Status barNavigation bar 后面的原理
  • fitsSystemWindowsWindowInsets 的概念
  • WindowInsets 的分发逻辑
  • 处理 WindowInsets 的最佳实践

限于篇幅原因,本文没有太多分析源码。关于底层的实现原理,我们在之后的文章中介绍。仅关心最佳实践的小伙伴可以直接跳转最佳实践一节。

让我们开始吧~

什么是 Window

Android Detail:Window 篇——站在 Window 视角理解 Activity 任务与返回栈[2]一文中我们讨论过 Android Window 的核心概念并得到一个结论:

在 Android 中,暴露给开发者操作 UI 界面的 API 是 mWindowManager.addView(rootView, windowParams);

简单说,Android 屏幕上的每一个 view 都是在 Window 内的

  • 每个 Activity 有着自己的 Window(PhoneWindow),Activity#getWindow()
  • Dialog 也有自己的 Window,Dialog#getWindow()
  • PopupWindow,Toast 也是通过 WindowManager#addView 将 view 置于 Widnow 上的
什么是 Insets

屏幕上除了开发者 app 绘制的内容还有系统的 Insets(插入物),Insets 区域负责描述屏幕的哪些部分会与系统 UI 相交。如 Starus barNavigation bar

45e29d00c36370ae02d00153e2d63091.webpInsets

常见的 Insets 有:

  • STATUS_BAR,用于展示系统时间,电量,wifi 等信息
  • NAVIGATION_BAR,虚拟导航栏(区别于实体的三大金刚键),形态有三大金刚键导航,手势导航两种。(有些设备形态如 TV 没有导航栏)
  • IME,软键盘,用于输入文字

其中 STATUS_BARNAVIGATION_BAR 又被称为 System bar

如果开发者绘制的内容出现在了系统 UI 区域内,就可能出现视觉与手势的冲突。开发者可以借助 Insets 把 view 从屏幕边缘向内移动到一个合适的位置。

在源码中,Insets 对象拥有 4 个 int 值,用于描述矩形四个边的偏移:

0c95c9a3dab4298d82c31ddeb6e07e79.webpinsets.drawio

📢 注意:不要把 Insets 的 topbottomleftright 与 Rect 的搞混,前者描述的是偏移,后者是坐标

关于 Insets 更详尽的信息,可以 查看这篇文章[3]。

setSystemUiVisibility 与 WTFs

View 的源码中有一个 setSystemUiVisibility() 的方法,虽然该方法在 Android 11 已被弃用,但按照本专栏的一贯风格,我们还是要来介绍一下该方法。

有些场景开发者可能希望 app 的内容可以绘制到状态栏或导航栏的区域以提供更好的用户体验,因此系统提供了 setSystemUiVisibility 方法,开发者可以通过向该方法传入不同的 flag 以应对不同的使用场景。

这些 flag 被称为 Window Transform Flags,简称 WTFs(滑稽脸 😏),同样的,它们在 Android 11 中被弃用。常用的 flag 如下:

057b3f9ce0e05d6298eacf0f22fcb729.webp

如果想了解这些 flag 的效果可以 移步这里[4]。

System bar 能力变化史

Android 4.4 之前

用户内容 显示在 System bar 之间,即下图红框所在区域:

19706e1e952cf12ada0608218296f7d5.webp

开发者可以使用 setSystemUiVisibility 方法将内容绘制到状态栏后面,下图红框区域:

242795e1d794c03d8677167bad849a0c.webp

Android 4.4

Android 4.4 引入了 android:windowTranslucentStatusandroid:windowTranslucentNavigation ,允许开发者将 System bar 设置成透明:

d977a08a15470f52d85df4a3e20ebdcb.webp

System bar background 是由 WindowManager 绘制的(利用 Window 的 flag)

Android 5.0

之前版本 System bar 都是由 WindowManager 绘制的,在 Android 5.0,引入了 android:windowDrawsSystemBarBackgrounds,当 windowDrawsSystemBarBackgrounds 为 true(默认值) 时,System bar 的 background 在 Window 内部。如下图:

d702de80801f67d29bdba455a8f66929.webpsystem bar 在 Window 内

开发者可以调用 Window 的方法为 System bar 设置颜色:

e1f4c24a21ccdc80f253b8eab2715bfe.webp设置状态栏颜色5873811e3db8d58a464df20338f5be26.webp设置导航栏颜色

📢注意windowTranslucentStatuswindowTranslucentNavigation 要比为 System bar 设置自定义颜色的优先级更高

windowTranslucentStatuswindowTranslucentNavigation 设置为 true 后会导致 windowDrawsSystemBarBackgroundsfalseSystem bar background 由 WindowManager 接管。

自 Android 5.0 后,当 windowDrawsSystemBarBackgrounds 为 true 时,System bar 作为 window 的一部分。换言之,DecorView(FrameLayout 子类)有三个子 View:显示 App 内容的 LinearLayout 以及 Status barNavigation bar

默认情况下,App 的内容显示在 System bar 中间。

理论上,显示 App 内容的 LinearLayout 应该充满屏幕,系统使用了 paddingTop 和 marginBottom 为 System bar 预留出了空间

4b05868dbdde991d38bc245a6664a3cc.webp

那么 App 的内容区域是如何绘制到 System bar 后面的?很简单,LinearLayout 没有 padding 和 margin(我们在后文介绍原理),充满屏幕:

0b5e9d22e8a23856677b7f11b707c2df.webp

Android 10

随着全面屏设备的普及,越来越多的 Android 设备突破了 16:9 的限制,Android 10 推出了新的导航模式:手势导航。

849b8d66c6df0d2db5def036dc4398ce.webp

屏幕边缘左滑和右滑可以触发 Back,底部向上轻扫可以触发 Home

新的手势导航与原来的三大金刚键的 Navigation bar 一样,只不过高度变小了。

6a327c94b49fd64b2d54efccbcba7b6f.webp新的手势导航

如果 Navigation bar 是透明的,底部的「小白条」是可以跟随背景动态改变颜色的(与 iOS 一样,不知道谁抄的谁 🤣)

2cadfa236476a96921554af4659d8e29.webp

Android 11

Android 11 引入了 WindowInsetsAnimation 允许监听 Insets 的变化进度,使用户体验更加丝滑。

46764788ef28653d86ca7b0e7c0608a2.webp


Android 11 WindowInsets 动画

小结

为了方便开发者更合理地使用设备屏幕绘制内容,Android 在历代版本不断迭代 System bar 控制的 API,功能越来越完善。

当开发者将 App 内容绘制到 System bar 后面时要考虑视觉冲突和手势冲突。

**为了防止 App 内容区域与 System bar 发生视觉冲突,官方提供了两种 API, WidowInsetsfitsSystemWindows **。

WidowInsets

WindowInsets 描述了一组 Window Content 的 Insets,未来可能会继续添加新的 Insets 类型。目前已有的 Insets 类型有:

7c745b7090b941dbac5528977320eecd.webp

使用位运算管理状态是很常见并高效的方式,如果对这部分内容不了解,可以移步 KunMinX[5] 的 这篇文章[6] 入门

System bar 包括 Status barNavigation barCaption bar,但不包括软键盘ime

c06d004b6a286ba92e4fe80c4242a8cc.webp

onApplyWindowInsets 与 setOnApplyWindowInsetsListener

开发者可以通过在自定义 View 中重写 onApplyWindowInsets() 方法或调用 setOnApplyWindowInsetsListener() 来监听 WindowInsets 的变化,通过对 View 添加 marginpadding 的方式处理解决冲突

这两个方法是互斥的,当存在 OnApplyWindowInsetsListener 时不会执行 onApplyWindowInsets

503c3e78c73d8414dd5616fc94731282.webp

WindowInsets 分发

前文我们提到,如果开发者绘制的内容出现在了系统 UI 区域内,就可能出现视觉与手势的冲突。开发者可以借助 Insets 把 view 从屏幕边缘向内移动到一个合适的位置,此时 View#onApplyWindowInsets() 会被调用。那么这些 Insets 是如何分发给 View 的呢?

笔者在 View 事件分发机制,大型职场 PUA 现场[7] 一文中把 Android 的视图树抽象为 N 叉树

与 View 的事件分发一样,WindowInsets 的分发也是 N 叉树的遍历过程:

从 N 叉树的根节点(DecoView)开始,按照 深度优先 的方式分发给 子 view。

Android 10 和 Android 11 两个版本官方连续修改了 ViewGroup#dispatchApplyWindowInsets() 的逻辑(具体我们在源码解析篇介绍)。

如果 app targetSdkVersion < 30 ,如果某个节点消费了 Insets,所有没遍历到的节点都不会收到 WindowInsets 的分发;

当 app 运行在 Android 11 以上版本的设备上且 targetSdkVersion >= 30,如果某个节点消费了 Insets,该节点的所有子节点不会收到 WindowInsets 分发。

7115e8e081cdcf2cb4bbd83d61f76642.webpWindowInsets 分发

旧版本的分发有一个问题,无法做到两个同级的 View 同时消费 WindowInsets,如下图:

f24eeb4a327dc122103551a0ed80b038.webp

我们可以将 Level2-1 和 Level2-2 看成顶部导航和底部导航,按照旧逻辑,当 Level2-1 消费了 WindowInsets,另一个 View 便没机会了。

小结

  • 由于开发者可以将 App 内容绘制到与系统 UI 相交的位置,因此官方为开发者提供了解决视觉冲突的方式,WindowInsets
  • 开发者可以重写 View#onApplyWindowInsetsView#setOnApplyWindowInsetsListener 来根据 WindowInsets 对系统 UI 进行位置避让(对 view 设置 padding 或 margin)。
  • 下一节介绍的 fitsSystemWindows 的默认行为也是通过 onApplyWindowInsets 实现的。
fitsSystemWindows4e75bda246f9e548ed34f9bb9d9d32c7.webp

setFitsSystemWindows 是 View 中 API 14 后加入的方法,对应的 xml 属性是 android:fitsSystemWindows

fitsSystemWindows 的默认行为是:通过 padding 为 System bar 预留出空间。如前文提到的 DecorView 的 LinearLayout,它的 paddingTop 就是 fitsSystemWindows = true 影响的。

56bcba32cd1f369551ac13a4e8db1270.webp

默认情况下 DecorView 的子 view 是 inflate screen_simple.xml 得到的。

那么这个 padding 是如何设置的?

View#onApplyWindowInsets() 中会判断 fitsSystemWindows 最终调用到 internalSetPadding() 方法:

18292bec66ef1c9b896ee7b5fd053a82.webp

📢 注意:这会使开发者在 xml 中定义的 padding 失效

fitsSystemWindows 这个 API 另很多开发者迷惑,一个重要原因是很多时候 fitsSystemWindows 并不是使用的默认行为,如 DrawerLayoutCoordinatorLayout

DrawerLayout

DrawerLayout fitsSystemWindow = true 时:

  • API > 21 时设置 setSystemUiVisibility(SYSTEM_UI_FLAG_LAYOUT_STABLE | SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)

  • **onMeasure() 时调用子 view dispatchApplyWindowInsets()(**正常父 View 消费 WindowInsets 后子 View 接收不到分发)

  • onDraw() 时调用 setStatusBackground(?android:colorPrimaryDark)

CoordinatorLayout

CoordinatorLayout fitsSystemWindow = true 时:

  • API > 21 时设置 setSystemUiVisibility(SYSTEM_UI_FLAG_LAYOUT_STABLE | SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
  • 根据需要 setStatusBackground
  • 允许设置 behavior 的子 View 拦截并响应 WindowInsets 的变化

小结

关于 fitsSystemWindows,你必须知道以下几点:

  • fitsSystemWindows 是深度优先(我们可以将视图树看成一个 N 叉树)的,第一个设置 fitsSystemWindows 的 view 会去消费 insets 并影响视觉;

  • padding 在 view layout 之前就已经设置了,因此不要误认为设置 padding 时了解 view 所在的位置

  • 开发者在 xml 或 view 初始化设置的 padding 会被覆盖

  • AppBarLayout[8],CoordinatorLayout[9],DrawerLayout[10] 等 view 会自定义 fitsSystemWindows 的行为

处理 WindowInsets 的最佳实践

使用 Jetpack 提供的 Compat API

Android Jetpack 组件库中的 androidx.core[11] 提供了大量兼容旧版本的 Compat API,如 ViewCompatWindowInsetsCompatWindowInsetsControllerCompat 等等。

下图是 ViewCompat#getWindowInsetsController 方法,用于获取 WindowInsetsController,同时兼容低版本:

1317498f5fc0a718078f9f13740a0f4f.webp

获取 WindowInsets

f4c11a3418e0a97cefe61e0f1dc3b212.webpimage-20211203104701794

使用 ViewCompat.getRootWindowInsets(view) 获取 WindowInsets。请注意:

  • 该方法返回分发给视图树的原始 insets

  • insets 只有在 view attached 才是可用的

  • API 20 及以下 永远 返回 false

获取 System bar 和 软键盘的高度

❌ 错误用法

🙅🏻‍♀️ 不要固定 status bar 的高度

a71193e36972a944fff94d5132235054.webpimage-20211203094942407

不同 Android 版本 status bar 的高度是不同的!不同设备也可能定制自己的高度。

8cc16532df3b0395743914afaa5b6552.webpimage-20211203095402699

🙅🏻‍♀️ 读取系统内部资源

framework 的 dimens.xml 存储了一系列系统内部资源。

a374c54f0f3ffcfa6a8c7f7bde4888cf.webpimage-2021120309553727396cb03141c7ad0e97422f351e2ef1b9c.webpimage-20211203095718667

如果系统内部资源名称变化怎么办?

「野路子」代码可能有效,但不健壮。

✅ 正确用法

  1. 获取 WindowInsets
  2. 通过 WindowInsets#getInsets(type) 获取 Insets
  3. 通过 Insets.top 或 Insets.bottom 获取 System bar 高度

为了兼容旧版本,我们使用 Compat API:

  1. val windowInsetsCompat = ViewCompat.getRootWindowInsets(view) 获取 WindowInsets
  2. val insets = windowInsetsCompat?.getInsets(WindowInsetsCompat.Type.statusBars()) 获取 Insets
  3. insets?.topinsets?.bottom 获取 System bar 高度
ViewCompat.getRootWindowInsets(window.decorView)?.getInsets(WindowInsetsCompat.Type.statusBars())?.top
ViewCompat.getRootWindowInsets(window.decorView)?.getInsets(WindowInsetsCompat.Type.statusBars())?.bottom

System bar 隐藏时 getInsets() 获取的高度为 0,如果想在隐藏状态时也能获取高度,可以使用 getInsetsIgnoringVisibility() 方法

ViewCompat.getRootWindowInsets(window.decorView)?.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.statusBars())?.top
ViewCompat.getRootWindowInsets(window.decorView)?.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.statusBars())?.bottom

f1d4cb55fd6c965502e62e2fcdb548fd.webp


getInsetsIgnoringVisibility 在system bar隐藏时也能获得高度

WindowInsetsController

Android 30 引入了 WindowInsetsController 来控制 WindowInsets,主要功能包括:

  • 显示/隐藏 System bar

  • 设置 System bar 前景(如状态栏的文字图标)是亮色还是暗色

  • 逐帧控制 insets 动画,例如可以让软键盘弹出得更丝滑

显示隐藏 System bar

// 软键盘是否可见
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.statusBars()) ?: true
// 显示状态栏
ViewCompat.getWindowInsetsController(view).show(WindowInsetsCompat.Type.statusBars())
// 隐藏状态栏
ViewCompat.getWindowInsetsController(view).hide(WindowInsetsCompat.Type.statusBars())

// 导航栏是否可见
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.navigationBars()) ?: true
// 显示导航栏
ViewCompat.getWindowInsetsController(view).show(WindowInsetsCompat.Type.navigationBars())
// 隐藏导航栏
ViewCompat.getWindowInsetsController(view).hide(WindowInsetsCompat.Type.navigationBars())

// 软键盘是否可见
ViewCompat.getRootWindowInsets(view)?.isVisible(WindowInsetsCompat.Type.ime()) ?: false
// 显示软键盘
ViewCompat.getWindowInsetsController(view).show(WindowInsetsCompat.Type.ime())
// 隐藏软键盘
ViewCompat.getWindowInsetsController(view).hide(WindowInsetsCompat.Type.ime())
162e4c502200f3c44ef9260856ba8cee.webp显示/隐藏 System bar 及软键盘

设置 System bar 前景亮色/暗色

ViewCompat.getWindowInsetsController(view).isAppearanceLightStatusBars = isLight
ViewCompat.getWindowInsetsController(view).isAppearanceLightNavigationBars = isLight
6ca6b12b7536cb90132bd494d1580b96.webp设置 System bar 前景亮色/暗色

适配 edge-to-edge

何为 edge-to-edge?如下图,即应用内容的绘制范围从顶部状态栏下方开始,延伸至底部导航栏上方:

3779502080c114dd3afd8e9869bed3df.webp

edge-to-edge

关于 edge-to-edge 的适配,官方文档[12] 写得很完整,主要分三步:

// 1. 使内容区域全屏
WindowCompat.setDecorFitsSystemWindows(window, false)

// 2. 设置 System bar 透明
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT

// 3. 可能出现视觉冲突的 view 处理 insets
ViewCompat.setOnApplyWindowInsetsListener(view) { view, windowInsets ->
  val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
  // 此处更改的 margin,也可设置 padding,视情况而定
  view.updateLayoutParams {
     topMargin = insets.top
      leftMargin = insets.left
      bottomMargin = insets.bottom
      rightMargin = insets.right
  }
  WindowInsetsCompat.CONSUMED
}

注意:处理 insets 时要保证计算操作有幂等性,即多次进行该计算所得到的结果应该相同,否则 margin/padding 会越来越大!

处理 insets 也可以通过 重写 View#onApplyWindowInsets 来操作。

总结
  • 随着 Android 的不断迭代,开发者可以更充分地利用屏幕空间,能够将内容绘制在系统 UI 后面;
  • Android 使用 Insets 来描述系统 UI 与屏幕相交的区域,开发者可以使用 fitsSystemWindowsWindowInsets 来处理视觉和手势冲突;
  • WindowInsets 的分发根据 targetSDKVersion 的不同而略有差别;
  • fitsSystemWindows 的默认行为是:通过 padding 为 System bar 预留出空间,本质也是利用 WindowInsets 处理视觉冲突;
  • 一些自定义 view 如 DrawerLayout 会更改 fitsSystemWindows 的默认行为;
  • 处理 WindowInsets 可以使用 Jetpack androidx.core 提供的一系列 Compat 类;
  • 牢记获取 Status bar 高度的正确姿势,并避免错误用法;
  • 适配 edge-to-edge 以给用户更好的使用体验
推荐阅读和参考资源
  • 开启全面屏体验 | 手势导航 (一)[13]
  • 处理视觉冲突 | 手势导航 (二)[14]
  • 如何处理手势冲突 | 手势导航连载 (三)[15]
  • 沉浸模式 | 手势导航连载 (四)[16]
  • Why would I want to fitsSystemWindows?[17]
  • WindowInsets — listeners to layouts[18]
  • Becoming a master window fitter🔧[19]
关于我

人总是喜欢做能够获得正反馈(成就感)的事情,如果感觉本文内容对你有帮助的话,麻烦点亮一下 👍,这对我很重要哦~

我是 Flywith24[20],人只有通过和别人的讨论,才能知道我们自己的经验是否是真实的,加我微信交流,让我们共同进步。

  • 掘金[21]
  • 小专栏[22]
  • Github[23]
  • 微信(公众号同名):Flywith24

关注公众号,点击底部 联系我 -> 知识星球 加入免费的知识星球

参考资料

[1]

Becoming a master window fitter🔧: https://www.bilibili.com/video/BV11U4y1T7D1

[2]

Android Detail:Window 篇——站在 Window 视角理解 Activity 任务与返回栈: https://xiaozhuanlan.com/topic/3268795140

[3]

查看这篇文章: https://juejin.cn/post/6844904006343458830

[4]

移步这里: https://blog.csdn.net/QQxiaoqiang1573/article/details/79867127

[5]

KunMinX: https://juejin.cn/user/1081575170900958/posts

[6]

这篇文章: https://juejin.cn/post/6844903879155384333

[7]

View 事件分发机制,大型职场 PUA 现场: https://juejin.cn/post/6911176251495579655

[8]

AppBarLayout: https://developer.android.com/reference/com/google/android/material/appbar/AppBarLayout.html

[9]

CoordinatorLayout: https://developer.android.com/reference/androidx/coordinatorlayout/widget/CoordinatorLayout.html

[10]

DrawerLayout: https://developer.android.com/reference/androidx/drawerlayout/widget/DrawerLayout.html

[11]

androidx.core: https://developer.android.com/jetpack/androidx/releases/core

[12]

官方文档: https://developer.android.com/training/gestures/edge-to-edge

[13]

开启全面屏体验 | 手势导航 (一): https://juejin.cn/post/6844904001721335815

[14]

处理视觉冲突 | 手势导航 (二): https://juejin.cn/post/6844904006343458830

[15]

如何处理手势冲突 | 手势导航连载 (三): https://juejin.cn/post/6844904021367472142

[16]

沉浸模式 | 手势导航连载 (四): https://juejin.cn/post/6844904034281717768

[17]

Why would I want to fitsSystemWindows?: https://medium.com/androiddevelopers/why-would-i-want-to-fitssystemwindows-4e26d9ce1eec

[18]

WindowInsets — listeners to layouts: https://medium.com/androiddevelopers/windowinsets-listeners-to-layouts-8f9ccc8fa4d1

[19]

Becoming a master window fitter🔧: https://www.bilibili.com/video/BV11U4y1T7D1

[20]

Flywith24: https://flywith24.gitee.io/

[21]

掘金: https://juejin.im/user/57c7f6870a2b58006b1cfd6c

[22]

小专栏: https://xiaozhuanlan.com/u/3967271263

[23]

Github: https://github.com/Flywith24


浏览 636
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报