Android WindowInsets 分析和最佳实践


什么是 Insets

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

常见的 Insets 有:

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

其中 STATUS_BARNAVIGATION_BAR 又被称为 System bar

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

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

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

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

setSystemUiVisibility 与 WTFs

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

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

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

SYSTEM_UI_FLAG_VISIBLE
SYSTEM_UI_FLAG_LOW_PROFILE
SYSTEM_UI_FLAG_HIDEN_NAVIGATION
SYSTEM_UI_FLAG_FULLSCREEN

SYSTEM_UI_FLAG_IMMERSIVE_STICKY
SYSTEM_UI_FLAG_IMMERSIVE

SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR

SYSTEM_UI_FLAG_LAYOUT_STABLE
SYSTEM_UI_FLAG_LAYOUT_HIDEN_NAVIGATION
SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN

如果想了解这些 flag 的效果可以参考 # Android setSystemUiVisibility详解

System bar 能力变化史

Android 4.4 之前

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

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

Android 4.4

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

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

Android 5.0

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

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

📢注意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 预留出了空间
1WZMbK

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

Android 10

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

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

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

Android 11

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

小结

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

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

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

WindowInsets

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

package androidx.core.view;

public class WindowInsetsCompat {

	public static final class Type {  
	    static final int FIRST = 1;  
	    static final int STATUS_BARS = FIRST;  
	    static final int NAVIGATION_BARS = 1 << 1;  
	    static final int CAPTION_BAR = 1 << 2;  
	  
	    static final int IME = 1 << 3;  
	  
	    static final int SYSTEM_GESTURES = 1 << 4;  
	    static final int MANDATORY_SYSTEM_GESTURES = 1 << 5;  
	    static final int TAPPABLE_ELEMENT = 1 << 6;  
	  
	    static final int DISPLAY_CUTOUT = 1 << 7;  
	  
	    static final int LAST = 1 << 8;  
	    static final int SIZE = 9;  
	    static final int WINDOW_DECOR = LAST;  
	  
	    private Type() {}
	}
}

使用位运算管理状态是很常见并高效的方式,如果对这部分内容不了解,可以移步 KunMinX这篇文章 就算不去火星种土豆,也请务必掌握的 Android 状态管理最佳实践! 入门

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

onApplyWindowInsets 与 setOnApplyWindowInsetsListener

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

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

开发者可以在也可 OnApplyWindowInsetsListener 手动调用 onApplyWindowInsets 使两个方法同时被执行。

WindowInsets 分发

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

笔者在 View 事件分发机制,大型职场 PUA 现场 一文中把 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 分发。
LfaBdL

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

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

小结

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

fitsSystemWindows

b98jig

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

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

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

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

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

📢 注意:这会使开发者在 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 会被覆盖
  • AppBarLayoutCoordinatorLayoutDrawerLayout 等 view 会自定义 fitsSystemWindows 的行为

处理 WindowInsets 的最佳实践

使用 Jetpack 提供的 Compat API

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

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

获取WindowInsets

Baeg38

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

  • 该方法返回分发给视图树的原始 insets
  • insets 只有在 view attached 才是可用的
  • API 20 及以下 永远 返回 false

获取 System bar 和 软键盘的高度

❌ 错误用法

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

res/values
<dimen name="status_bar_size">25dp</dimen>

res/values-v23
<dimen name="status_bar_size">24dp</dimen>

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

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

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

val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return resources.getDimensionPixelSize(resourceId)

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

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

✅ 正确用法

  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

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 前景亮色/暗色

ViewCompat.getWindowInsetsController(view).isAppearanceLightStatusBars = isLight
ViewCompat.getWindowInsetsController(view).isAppearanceLightNavigationBars = isLight

适配 edge-to-edge

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

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

// 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<MarginLayoutParams> {
      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 以给用户更好的使用体验

推荐阅读和参考资源

References

Android Detail:Window 篇—— WindowInsets 与 fitsSystemWindow


文章作者: Weikeet
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Weikeet !
评论
  目录