Android Detail:Window 篇——WindowInsets 与 fitsSystemBar
前言
很高兴见到你!👋
作为 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 bar
和Navigation bar
后面的原理 fitsSystemWindows
与WindowInsets
的概念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 上的
屏幕上除了开发者 app 绘制的内容还有系统的 Insets(插入物),Insets 区域负责描述屏幕的哪些部分会与系统 UI 相交。如 Starus bar
或 Navigation bar
:
常见的 Insets 有:
STATUS_BAR
,用于展示系统时间,电量,wifi 等信息NAVIGATION_BAR
,虚拟导航栏(区别于实体的三大金刚键),形态有三大金刚键导航,手势导航两种。(有些设备形态如 TV 没有导航栏)IME
,软键盘,用于输入文字
其中 STATUS_BAR
与 NAVIGATION_BAR
又被称为 System bar
。
如果开发者绘制的内容出现在了系统 UI 区域内,就可能出现视觉与手势的冲突。开发者可以借助 Insets 把 view 从屏幕边缘向内移动到一个合适的位置。
在源码中,Insets 对象拥有 4 个 int 值,用于描述矩形四个边的偏移:
insets.drawio📢 注意:不要把 Insets 的
top
,bottom
,left
,right
与 Rect 的搞混,前者描述的是偏移,后者是坐标。
关于 Insets 更详尽的信息,可以 查看这篇文章[3]。
setSystemUiVisibility 与 WTFsView 的源码中有一个 setSystemUiVisibility()
的方法,虽然该方法在 Android 11 已被弃用,但按照本专栏的一贯风格,我们还是要来介绍一下该方法。
有些场景开发者可能希望 app 的内容可以绘制到状态栏或导航栏的区域以提供更好的用户体验,因此系统提供了 setSystemUiVisibility
方法,开发者可以通过向该方法传入不同的 flag 以应对不同的使用场景。
这些 flag 被称为 Window Transform Flags
,简称 WTFs(滑稽脸 😏),同样的,它们在 Android 11 中被弃用。常用的 flag 如下:
如果想了解这些 flag 的效果可以 移步这里[4]。
System bar 能力变化史Android 4.4 之前
用户内容 显示在 System bar
之间,即下图红框所在区域:
开发者可以使用 setSystemUiVisibility 方法将内容绘制到状态栏后面,下图红框区域:
Android 4.4
Android 4.4 引入了 android:windowTranslucentStatus
和 android:windowTranslucentNavigation
,允许开发者将 System bar
设置成透明:
System bar
background 是由 WindowManager 绘制的(利用 Window 的 flag)
Android 5.0
之前版本 System bar
都是由 WindowManager 绘制的,在 Android 5.0,引入了 android:windowDrawsSystemBarBackgrounds
,当 windowDrawsSystemBarBackgrounds
为 true(默认值) 时,System bar
的 background 在 Window 内部。如下图:
开发者可以调用 Window 的方法为 System bar
设置颜色:
📢注意:
windowTranslucentStatus
和windowTranslucentNavigation
要比为System bar
设置自定义颜色的优先级更高。当
windowTranslucentStatus
或windowTranslucentNavigation
设置为 true 后会导致windowDrawsSystemBarBackgrounds
为 false,System bar
background 由 WindowManager 接管。
自 Android 5.0 后,当 windowDrawsSystemBarBackgrounds
为 true 时,System bar
作为 window 的一部分。换言之,DecorView(FrameLayout 子类)有三个子 View:显示 App 内容的 LinearLayout 以及 Status bar
和 Navigation bar
。
默认情况下,App 的内容显示在 System bar
中间。
理论上,显示 App 内容的 LinearLayout 应该充满屏幕,系统使用了 paddingTop 和 marginBottom 为 System bar
预留出了空间。
那么 App 的内容区域是如何绘制到 System bar
后面的?很简单,LinearLayout 没有 padding 和 margin(我们在后文介绍原理),充满屏幕:
Android 10
随着全面屏设备的普及,越来越多的 Android 设备突破了 16:9 的限制,Android 10 推出了新的导航模式:手势导航。
屏幕边缘左滑和右滑可以触发 Back,底部向上轻扫可以触发 Home新的手势导航与原来的三大金刚键的 Navigation bar
一样,只不过高度变小了。
如果 Navigation bar
是透明的,底部的「小白条」是可以跟随背景动态改变颜色的(与 iOS 一样,不知道谁抄的谁 🤣)
Android 11
Android 11 引入了 WindowInsetsAnimation
允许监听 Insets 的变化进度,使用户体验更加丝滑。
小结
为了方便开发者更合理地使用设备屏幕绘制内容,Android 在历代版本不断迭代 System bar
控制的 API,功能越来越完善。
当开发者将 App 内容绘制到 System bar
后面时要考虑视觉冲突和手势冲突。
**为了防止 App 内容区域与 System bar
发生视觉冲突,官方提供了两种 API, WidowInsets
与 fitsSystemWindows
**。
WindowInsets
描述了一组 Window Content 的 Insets,未来可能会继续添加新的 Insets 类型。目前已有的 Insets 类型有:
使用位运算管理状态是很常见并高效的方式,如果对这部分内容不了解,可以移步 KunMinX[5] 的 这篇文章[6] 入门
System bar
包括 Status bar
,Navigation bar
,Caption bar
,但不包括软键盘(ime
)
onApplyWindowInsets 与 setOnApplyWindowInsetsListener
开发者可以通过在自定义 View 中重写 onApplyWindowInsets()
方法或调用 setOnApplyWindowInsetsListener()
来监听 WindowInsets
的变化,通过对 View 添加 margin
或 padding
的方式处理解决冲突。
这两个方法是互斥的,当存在 OnApplyWindowInsetsListener
时不会执行 onApplyWindowInsets
:
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
分发。
旧版本的分发有一个问题,无法做到两个同级的 View 同时消费 WindowInsets
,如下图:
我们可以将 Level2-1 和 Level2-2 看成顶部导航和底部导航,按照旧逻辑,当 Level2-1 消费了
WindowInsets
,另一个 View 便没机会了。
小结
- 由于开发者可以将 App 内容绘制到与系统 UI 相交的位置,因此官方为开发者提供了解决视觉冲突的方式,
WindowInsets
- 开发者可以重写
View#onApplyWindowInsets
或View#setOnApplyWindowInsetsListener
来根据 WindowInsets 对系统 UI 进行位置避让(对 view 设置 padding 或 margin)。 - 下一节介绍的
fitsSystemWindows
的默认行为也是通过onApplyWindowInsets
实现的。
setFitsSystemWindows
是 View 中 API 14 后加入的方法,对应的 xml 属性是 android:fitsSystemWindows
fitsSystemWindows
的默认行为是:通过 padding 为 System bar
预留出空间。如前文提到的 DecorView 的 LinearLayout,它的 paddingTop
就是 fitsSystemWindows = true
影响的。
默认情况下 DecorView 的子 view 是 inflate
screen_simple.xml
得到的。
那么这个 padding 是如何设置的?
View#onApplyWindowInsets()
中会判断 fitsSystemWindows
最终调用到 internalSetPadding()
方法:
📢 注意:这会使开发者在 xml 中定义的 padding 失效。
fitsSystemWindows
这个 API 另很多开发者迷惑,一个重要原因是很多时候 fitsSystemWindows
并不是使用的默认行为,如 DrawerLayout
和 CoordinatorLayout
。
DrawerLayout
DrawerLayout fitsSystemWindow = true
时:
API > 21 时设置
setSystemUiVisibility(SYSTEM_UI_FLAG_LAYOUT_STABLE | SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN)
**
onMeasure()
时调用子 viewdispatchApplyWindowInsets()
(**正常父 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
的行为
使用 Jetpack 提供的 Compat API
Android Jetpack 组件库中的 androidx.core[11] 提供了大量兼容旧版本的 Compat API,如 ViewCompat
,WindowInsetsCompat
,WindowInsetsControllerCompat
等等。
下图是 ViewCompat#getWindowInsetsController
方法,用于获取 WindowInsetsController
,同时兼容低版本:
获取 WindowInsets
image-20211203104701794使用 ViewCompat.getRootWindowInsets(view)
获取 WindowInsets
。请注意:
该方法返回分发给视图树的原始 insets
insets 只有在 view attached 才是可用的
API 20 及以下 永远 返回 false
获取 System bar 和 软键盘的高度
❌ 错误用法
🙅🏻♀️ 不要固定 status bar 的高度
image-20211203094942407不同 Android 版本 status bar
的高度是不同的!不同设备也可能定制自己的高度。
🙅🏻♀️ 读取系统内部资源
framework 的 dimens.xml
存储了一系列系统内部资源。
如果系统内部资源名称变化怎么办?
「野路子」代码可能有效,但不健壮。
✅ 正确用法
- 获取
WindowInsets
- 通过
WindowInsets#getInsets(type)
获取 Insets - 通过 Insets.top 或 Insets.bottom 获取
System bar
高度
为了兼容旧版本,我们使用 Compat API:
val windowInsetsCompat = ViewCompat.getRootWindowInsets(view)
获取 WindowInsetsval insets = windowInsetsCompat?.getInsets(WindowInsetsCompat.Type.statusBars())
获取 Insetsinsets?.top
或insets?.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
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())
显示/隐藏 System bar 及软键盘设置 System bar 前景亮色/暗色
ViewCompat.getWindowInsetsController(view).isAppearanceLightStatusBars = isLight
ViewCompat.getWindowInsetsController(view).isAppearanceLightNavigationBars = isLight
设置 System bar 前景亮色/暗色适配 edge-to-edge
何为 edge-to-edge?如下图,即应用内容的绘制范围从顶部状态栏下方开始,延伸至底部导航栏上方:
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 与屏幕相交的区域,开发者可以使用
fitsSystemWindows
和WindowInsets
来处理视觉和手势冲突; WindowInsets
的分发根据targetSDKVersion
的不同而略有差别;fitsSystemWindows
的默认行为是:通过 padding 为System bar
预留出空间,本质也是利用 WindowInsets 处理视觉冲突;- 一些自定义 view 如 DrawerLayout 会更改
fitsSystemWindows
的默认行为; - 处理
WindowInsets
可以使用 Jetpackandroidx.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