1.什么是window
了解WindowInsets之前,我们先复习一下什么是Window?那么由一个问题开始:请问一个android APP 一定要有Activity才能展示界面吗? Five minutes later~~~ 答案是不一定的(可以百度一下”如何创建没有activity的app” 会有很多搜索结果告诉你怎么做……),那么我们就得知了Android 中 Activity 不是显示 UI 的必要条件,那什么才是呢? 那就是Window, 它可以说是Android Framework层提供的一个最基础的UI组件管理类, PhoneWindow是它的唯一实现类。Activity/Dialog/Toast的UI展现都是依赖于Window来完成。对于UI编写,开发者只需要使用View相关,View最终会以ContentView的形式设置给Window,所以Window就是用来承载和管理View的, 所有视图都是通过Window来实现的,View不能单独存在,必须依附window这个抽象概念。
2.什么是Insets
Insets中文是插入物, 屏幕上除了开发者 app 绘制的内容还有系统的插入物,比如:顶部的状态栏,底部的虚拟导航栏,软键盘等. 如果开发者绘制的内容与系统UI区域重合了,就可能出现被系统 UI 遮盖,视觉与手势的冲突等现象。开发者可以借助 Insets 把所要绘制的View 从屏幕边缘向内移动到一个合适的位置。
注意:不要把 Insets 的 top ,bottom,left,right 与 Rect 的搞混,前者描述的是偏移,后者是坐标。在 Android 上,Insets 区域由 WindowInsets 类表示,在 AndroidX 中则使用 WindowInsetsCompat。
3. 什么是WindowInsets
分别了解了Window和Insets之后,就该来说说WindowInsets了。WindowInsets就是描述窗口内容的一组插入(个人理解就是用来获取系统控件的位置然后来适配我们自己View的位置的), WindowInsets如下图:
大家可以看到这里面最主要的有以下几个变量mStableInsets,mSystemWindowInsets,mWindowDecorInsets
StableInsets:全窗⼝下,被系统UI覆盖的区域(稳定显示边衬区域)
SystemWindowInsets:全窗⼝下,被navigationbar、statusbar、ime或其他系统窗⼝覆盖的区域(最常用)
WIndowDecorInsets:系统预留属性
insets. systemWindowInsets
系统窗口区域是最常用到的。例如: 如果最底部有个底部导航栏, 在迭代为全面屏应用后,为了取得更加沉浸式的体验,我们将界面延展进了系统导航的区域。但这时可以想象的到,底部的导航栏肯定会被系统的虚拟导航栏遮住了,同时也会影响它的点击等操作,如下图
图1
这个时候我们就可以使用 systemWindowInsets.bottom值来增加导航栏下方的边距。为了使用 WindowInsets,我们通常需要在一个视图上添加OnApplyWindowInsetsListener,并且在这个函数中处理传进来的边衬区,代码如下,处理结果如图2
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val sysWindow = insets.systemWindowInsets
//val stable = insets.stableInsets
//val systemGestures = insets.systemGestureInsets
//val tappableElement = insets.tappableElementInsets
v.updatePadding(bottom = sysWindow.bottom)
// 返回边衬区,这样它们才能够继续在视图树中继续传递下去
insets
}
图2
但是目前许多 WindowInsets API 已经被弃用了,取而代之的一些新函数来查询不同类型的边衬区:
getInsets(type: Int) 会返回指定类型的可见边衬区。
getInsetsIgnoringVisibility(type: Int) 会返回所有边衬区,无论它们是否可见。
isVisible(type: Int) 如果指定的类型是可见的,会返回 true,反之则会返回false。
ViewCompat.setOnApplyWindowInsetsListener(...) { view, insets ->
- val sysWindow = insets.systemWindowInsets
+ val sysWindow = insets.getInsets(Type.systemBars())
- val stable = insets.stableInsets
+ val stable = insets.getInsetsIgnoringVisibility(Type.systemBars())
- val systemGestures = insets.systemGestureInsets
+ val systemGestures = insets.getInsets(Type.systemGestures())
- val tappableElement = insets.tappableElementInsets
+ val tappableElement = insets.getInsets(Type.tappableElement())
}
那么刚刚提到的这些方法里面的参数Type是什么呢?
它们在 WindowInsetsCompate.Type 类中被定义为函数,每个函数都会返回一个整数标示,他们分别是
WindowInsetsCompat.Type.ime();//键盘
WindowInsetsCompat.Type.statusBars();//状态栏
WindowInsetsCompat.Type.navigationBars();//导航栏
WindowInsetsCompat.Type.captionBar();//标题栏
WindowInsetsCompat.Type.systemBars();//状态栏,导航栏和标题栏
WindowInsetsCompat.Type.systemGestures();//系统手势
WindowInsetsCompat.Type.mandatorySystemGestures();//强制性系统手势
WindowInsetsCompat.Type.tappableElement();//可点击区域
WindowInsetsCompat.Type.displayCutout();//刘海屏
其中不太好理解的:
WindowInsetsCompat.Type.systemGestures()
这个指的是Android 10 带来的新的手势导航模式,允许用户通过手势动作,而不是导航按钮来进行导航,比如从屏幕左/右边缘向中间滑动,相当于后退按钮等
图3
WindowInsetsCompat.Type.mandatorySystemGestures()
强制系统手势边衬区是系统手势边衬区的子集,之所以称之为 "强制区域",是因为应用无法修改这些区域。强制系统手势边衬区只包含那些系统保留的区域,在这些区域内系统手势操作永远优先。在 Android 10 上,当前唯一的强制区域是屏幕底部的主屏手势区域,系统保留这个区域就可以让用户在任何时候都可以退出当前应用(底部 60dp 即为强制系统手势边衬区(如上图底部藕粉色区域)
WindowInsetsCompat.Type.tappableElement()
可点击区域边衬区也是 Android 10 中新增的可点击区域 insets。它与上面的系统窗口区域 insets 非常相似。它用来界定可触发系统点击行为 (tap) 的最小区域。注意,使用可点击区域里的数值进行布局时,依然可能导致自己的控件与系统 UI 在视觉上重叠,这一点与系统窗口区域 insets 不同,使用后者的值对自己的控件进行位移后能确保不会与系统/导航栏发生视觉重叠。比如:刚刚我们自己的底部导航条在导航栏模式下,用此type获取到的bottom正常,可以上移到如图2的效果,但是在手势操作 (导航条) 模式,且开启了导航条色彩适应后,虽然导航条依然有高度即图3底部的效果,但它被认为是 "透明" 的,系统在这高度内依然允许用户点击应用里的控件,所以在可点击区域 insets 中,其 bottom 值为 0dp,这就会导致应用内部的UI仍可能会与系统导航在展示上发生重叠,但不影响点击效果的情况发生
所以从适配的角度上讲,我建议大家用系统窗口区域 insets(systemWindowInsets),它可以更好地满足几乎所有需要使用可点击区域 insets 的用例。
4. 如何处理边衬区的冲突显示
在对不同类型的 insets 区域有了了解之后,下面我们来看看您需要如何在应用中实际使用它们(其实上面已经说过,哈哈),我们访问 WindowInsets主要就是通过 setOnApplyWindowInsetsListener这个方法进行,我们想要给某个控件增加一下下边距以适配手机的底部的虚拟导航,代码如下
注意 这个回调可能会调用多次
WindowInsetsCompat.Type.ime()
软键盘类型
应用使用过程中 我们可以获取到关于软键盘的一些信息,如下:
val insets = ViewCompat.getRootWindowInsets(view)
val imeVisible = insets.isVisible(WindowInsets.Type.ime())
val imeHeight = insets.getInsets((WindowInsets.Type.ime()).bottom
如果我们需要监听软键盘的改变,我们可以照常使用 OnApplyWindowInsetsListener,并且使用同样的函数:
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val imeVisible = insets.isVisible(WindowInsets.Type.ime())
val imeHeight = insets.getInsets(WindowInsets.Type.ime()).bottom
}
我们还可以通过WindowInsetsAnimation让应用随着软键盘一起移动,创建更加无缝的体验, 它是Android 11提供的API,该类封装了包含插图的动画。应用程序可以通过WindowInsetsAnimation.Callback类收听动画事件
object :WindowInsetsAnimation.Callback(DISPATCH_MODE_STOP){
override fun onPrepare(animation: WindowInsetsAnimation) {
super.onPrepare(animation)
//将在发生任何布局更改之前记录view底部坐标
}
override fun onProgress(
insets: WindowInsets,
runningAnimations: MutableList<WindowInsetsAnimation>
): WindowInsets {
//动画进行时
return insets
}
override fun onEnd(animation: WindowInsetsAnimation) {
super.onEnd(animation)
//动画结束后可以重置
}
}
关闭/隐藏 Android 软键盘(注: 需要界面有EditText并且EditText获取到焦点才能起作用)
private fun showSoftKeyboard(view: View) {
isClose = false
ViewCompat.getWindowInsetsController(view)!!.show(WindowInsetsCompat.Type.ime())
}
private fun hideSoftKeyboard(view:View) {
isClose = true
ViewCompat.getWindowInsetsController(view)!!.hide(WindowInsetsCompat.Type.ime())
}
控制状态栏以及文字颜色
val controller: WindowInsetsControllerCompat? = ViewCompat.getWindowInsetsController(view);
//隐藏状态栏controller?.hide(WindowInsetsCompat.Type.statusBars())
//显示状态栏
controller?.show(WindowInsetsCompat.Type.statusBars())
//设置状态栏颜色为黑色/白色
controller?.isAppearanceLightStatusBars = false
控制导航栏
//隐藏导航栏
controller?.hide(WindowInsetsCompat.Type.navigationBars())
//显示导航栏
controller?.show(WindowInsetsCompat.Type.navigationBars())
//导航栏隐藏时,这三种手势可以唤醒导航栏出现
/**
* BEHAVIOR_SHOW_BARS_BY_SWIPE
* BEHAVIOR_SHOW_BARS_BY_TOUCH
* BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
*/
controller?.systemBarsBehavior=WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_SWIPE