Android 全面屏体验
一、概述
Android 应用中经常会有一些要求全屏显隐状态栏导航栏的需求。通过全屏沉浸式的处理可以让应用达到更好的显示效果。在 Android 4.1 之前,只能隐藏状态栏, 在 Android4.1之后,Android 提供了一套控制 SystemUI的方式。Android P 增加了异形屏处理,应用需要对异形屏进行适配。Android Q 增加了全面屏手势导航,应用还需要对全面屏手势导航进行适配。 在 Android R 开始,Android 增加了 WindowInsetsController 来控制 Window 效果。
二、Android 4.4 之前隐藏状态栏
2.1 通过代码设置
针对 Activity 通过 WindowManager 标志,动态设置,代码如下:
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// If the Android version is lower than Jellybean, use this call to hide
// the status bar.
if (Build.VERSION.SDK_INT < 16) {
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN);
}
setContentView(R.layout.activity_main);
}
...
}
可以使用 FLAG_LAYOUT_IN_SCREEN
将 activity 布局设置为使用相同的屏幕区域。当你启用 FLAG_FILLSCREEN
时,可以使用相同的屏幕区域。这将防止在状态栏隐藏和显示时调整内容的大小。
2.2 XML 配置
如果你要设置 activity 的状态栏一直处于隐藏状态,那么首选在 manifest 设置 style 的方式,实现如下:
<application
...
android:theme="@android:style/Theme.Holo.NoActionBar.Fullscreen" >
...
</application>
使用 manifest 设置的方式有以下优点:
- 比编程方式更加容易维护,更不容易出错。
- 会有一个更加平滑的过渡,因为在实例化activity之前系统就已经拥有了呈现UI所需的信息。
Android 4.1 之前的全面屏体验很糟,由于碎片化严重,有些机型可能不会呈现预期的效果。
三、Android 4.4 设置全面屏
Android 4.1之后通过 setSystemUiVisibility
来控制 SystemUI,所用到的 flag 如下:
控制SystemBar相关:
SYSTEM_UI_FLAG_FULLSCREEN
SYSTEM_UI_FLAG_HIDE_NAVIGATION
SYSTEM_UI_FLAG_LOW_PROFILE
布局相关:
SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
SYSTEM_UI_FLAG_LAYOUT_STABLE
沉浸式粘性相关(Android4.4引入):
SYSTEM_UI_IMMERSIVE
SYSTEM_UI_IMMERSIVE_STICKY
3.1 控制 SystemBar
SYSTEM_UI_FLAG_FULLSCREEN
该属性用来隐藏状态栏
View decorView = getWindow().getDecorView();
int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN;
decorView.setSystemUiVisibility(uiOptions);
getSupportActionBar().hide(); // 将ActionBar隐藏掉
通过以上代码可以实现隐藏状态栏。为了显示出全屏效果,同时将 ActionBar 隐藏掉。
仅设置 SYSTEM_UI_FLAG_FULLSCREEN
这一条属性,显示效果如下:
- 当滑动 system bar、点击 home 键 menu 键就会清除掉flag,状态栏会重新显示出来。
- 并且布局也会随着状态栏的显隐进行布局调整进行重绘。
SYSTEM_UI_HIDE_NAVIGATION
该属性是用来隐藏导航栏
View decorView2 = getWindow().getDecorView();
int uiOptions2 = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN;
decorView2.setSystemUiVisibility(uiOptions2);
getSupportActionBar().hide();
这里同时隐藏了状态栏和导航栏,效果如下:
- 与隐藏状态栏不同的是点击任意布局中的任意位置都会导致导航栏导航栏重新显示出来。
- 并且布局也会随着状态栏导航栏的显隐进行布局调整进行重绘。
SYSTEM_UI_LOW_PROFILE
这个属性的能力是让SystemBar在视觉上变得模糊,重要性变得更低一点。具体表现是状态栏图标仅保留电量时间关键图标,并且变暗。导航栏图标变成三个点或者变暗。这个flag使用的很少。
View decorView7 = getWindow().getDecorView();
int uiOptions7 = View.SYSTEM_UI_FLAG_LOW_PROFILE;
decorView7.setSystemUiVisibility(uiOptions7);
3.2 布局相关
在新的Android4.1以及之后新的SystemUI设置里,仅单独设置隐藏状态栏和导航栏的flag会导致布局重绘,为了在显隐状态栏和导航栏的时候保持布局的稳定的显示效果,就需要以下属性了。
SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
可以让布局延伸到状态栏的位置。在状态栏在隐藏和显示之前切换的时候,布局稳定的显示在状态栏后面(如果显示状态栏,则布局在状态栏后面,隐藏状态栏布局也不变)。
View decorView3 = getWindow().getDecorView();
int uiOptions3 = View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
decorView3.setSystemUiVisibility(uiOptions3);
getSupportActionBar().hide();
以上代码显示出来的效果和上一段代码相比,布局延伸到了状态栏的位置:
- 当滑动systembar、点击home键menu键就会清除掉flag。状态栏会重新显示出来。
- 布局不会随着状态栏的显隐进行调整变化
SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
可以让布局延伸到导航栏的位置。可以让导航栏在隐藏和显示之前切换的时候,布局稳定的显示在导航栏后面(如果显示导航栏,则布局在导航栏后面,隐藏导航栏也不变)。
View decorView4 = getWindow().getDecorView();
int uiOptions4 = View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
decorView4.setSystemUiVisibility(uiOptions4);
getSupportActionBar().hide();
以上代码的显示效果是状态栏和导航栏隐藏,布局延伸到了状态栏和导航栏的位置:
- 点击任意布局就会清除掉flag。状态栏导航栏会重新显示出来。
- 布局不会随着状态栏导航栏的显隐进行调整变化。
SYSTEM_UI_FLAG_LAYOUT_STABLE
该flag的作用是保持布局稳定,避免在显隐状态栏导航栏的时候发生布局的变化。可以辅助以下
SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
、SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
两个flag的使用,让应用保持全屏的情况下,布局不随状态栏导航栏显隐发生变化。也可以不配合这两个flag使用,也能达到保持布局稳定的效果,不过不能实现全屏,会留出状态栏和导航栏的位置。
View decorView3 = getWindow().getDecorView();
int uiOptions3 = View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
decorView3.setSystemUiVisibility(uiOptions3);
getSupportActionBar().hide();
以上代码显示出来的效果和上一段代码相比,布局延伸到了状态栏的位置:
- 当滑动systembar、点击home键menu键就会清除掉flag。状态栏会重新显示出来。
- 布局不会随着状态栏的显隐进行调整变化
在设置了SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN、SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION flag的情况,如果把状态栏导航栏颜色设置为透明,则会有透明的状态栏导航栏覆盖在布局上的效果。这也证明了即便布局状态栏导航栏出来了,布局也确实延伸到了状态栏导航栏的位置。
3.3 沉浸式粘性相关
以上flag的组合设置中一直存在一个问题(在点击Home键、menu键等操作会导致flag被清除,导航栏一点击界面就会导致flag被清除,效果消失的问题。)。其实我们大部分情况都希望效果能够稳定的显示,而不是在简单操作之后就会消失掉。下面两个属性就是为这个问题工作的。
SYSTEM_UI_IMMERSIVE
在以上flag设置的基础上设置该属性,可以保证在点击home键、menu键时不会失去状态。但是如果手动调出systembar的时候,设置的相关flag还是会被清除掉。
View decorView5 = getWindow().getDecorView();
int uiOptions5 = View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_IMMERSIVE;
decorView5.setSystemUiVisibility(uiOptions5);
getSupportActionBar().hide();
以上代码的显示效果:
- 隐藏状态栏、导航栏。
- 布局延伸到了状态栏、导航栏的位置。
- 布局稳定显示,不会因为状态栏的显隐来调整布局。
- 当手动调出状态栏导航栏的时候,flag才会被清除。
SYSTEM_UI_IMMERSIVE_STICKY
设置这个属性后。当状态栏隐藏的时候,手动调出状态栏导航栏,显示一会儿随后就会隐藏掉。设置该属性后不会清除flag,该属性是比较常用的一种。但是离开页面肯定是会导致flag被清除掉的,以上所有flag设置都会有这种情况。
View decorView6 = getWindow().getDecorView();
int uiOptions6 = View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
decorView6.setSystemUiVisibility(uiOptions6);
getSupportActionBar().hide();
以上代码的显示效果:
- 隐藏状态栏、导航栏
- 布局延伸到了状态栏、导航栏的位置。
- 布局稳定显示,不会因为状态栏的显隐来调整布局。
- 手动调出的状态栏导航栏会半透明显示覆盖在界面上,随后还会隐藏掉。
- 如果离开页面还是会导致flag被清除,效果消失。
3.4 全面屏说明
3.4.1 flag 被清除问题
为实现全面屏显示效果,设置不同的 flag 控制window, 这些 flag 是可能被清除的。
想要主动清除flag,也可以直接调用setSystemUiVisibility(0);
为了解决 flag 被清除的问题,重设的位置可以放在onWindowFocusChanged中。常见的做法是在 onWindowFocusChanged
回调中重新设置。
class MainActivity : AppCompatActivity() {
...
override fun onWindowFocusChanged(hasFocus: Boolean) {
if (hasFocus) {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
}
}
...
}
3.4.2 全面屏体验
真正需要隐藏状态栏和导航栏的场景其实不多,游戏、视频等应用可能需要,大多数时候我们可能只需要将视图内容延伸到状态栏和导航栏后面(是否真的有必要?),以达到更具视觉冲击力的体验。
这时候,我们需要将状态栏和导航栏颜色设置为透明。Android Api 21 提供了相应接口,示例代码如下:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}
另外是否真的有必要将视图内容延伸到状态栏和导航栏后面,以及可能产生的交互冲突该如何解决?
由于 Android 10 支持全面屏手势导航,由于导航栏自身的大小和突出程度已经非常小了,一般建议把内容延伸到导航栏后面。
Android 9 及以下的设备,导航栏后绘制内容是可选的,根据情况酌情选择。
而在状态栏后面绘制内容,完全取决于开发者,如果内容是一张背景图片或者地图类应用等等,则可以将视图内容延伸到状态栏后面,如果UI页面顶部是一个 Toolbar, 明显就不太合适了。
关于 Android 10 上的全面屏体验,第五节将做详细介绍。
3.4.3 令人困惑的 fitsSystemWindows 属性
根据官方文档,如果某个View 的fitsSystemWindows 设为true,那么该View的padding属性将由系统设置,用户在布局文件中设置的
padding会被忽略。系统会为该View设置一个paddingTop,值为statusbar的高度。fitsSystemWindows默认为false。
重要说明
- 只有将视图内容延伸到状态栏或者导航栏后面(设置View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 或者 View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION flag)时,fitsSystemWindows才会起作用。不然 systemBar 的空间轮不到用户处理,这时会由ContentView的父控件处理,如果用HierarchyView 工具查看,将会看到,ContentView 的父控件的paddingTop 或者 paddingBottom 将会被设置。
- 如果多个view同时设置了fitsSystemWindows,只有第一个会起作用。
四、Android P 异形屏适配
Android的异形屏,包括刘海屏,水滴屏、挖孔屏,从Android 9.0 (API Level 28)开始Android官方也出了刘海屏的适配支持,下面是官方对异形屏的适配方案。
4.1 官方对异形屏设备的要求
为了确保一致性和应用兼容性,官方对搭载 Android 9 的设备有以下要求:
- 一条边缘最多只能包含一个刘海。
- 一台设备不能有两个以上的刘海。
- 设备的两条较长边缘上不能有刘海。
- 在未设置特殊标志的竖屏模式下,状态栏的高度必须至少与刘海的高度持平。
- 默认情况下,在全屏模式或横屏模式下,整个刘海区域必须显示黑边。
4.2 不隐藏系统状态栏的情形
如果应用所有界面均不隐藏状态栏,也就是应用不与系统状态栏重叠,那么就无需处理异形屏适配,系统状态栏会自动调整占据了异形切口的位置。
在竖屏和横屏情况下是如下效果:
4.3 隐藏系统状态栏的情形
4.3.1 配置应用如何处理异形切口区域
隐藏了系统状态栏,意味着应用的内容将扩充到系统状态栏原有的位置,系统提供了控制是否在异形切口区域显示内容的配置,该配置是 Window 级别的属性。属性名为 layoutInDisplayCutoutMode
, 它有三个可选值,分别是:
- WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
这是默认行为。官方说明是在竖屏模式下,内容会呈现到刘海区域中;但在横屏模式下,内容会显示黑边。 - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
在竖屏模式和横屏模式下,内容都会呈现到刘海区域中。 - WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
内容从不呈现到刘海区域中。
该属性配置方式有两种:
1.通过xml配置,通过主题样式文件配置:
在主题样式文件中通过 android:windowLayoutInDisplayCutoutMode 定义,示例代码如下:
<style name="Theme.DisplayCutoutDemo" parent="...">
...
<item name="android.windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
2.通过代码定义
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_FULLSCREEN // 隐藏状态栏
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // 隐藏导航栏
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN // 允许视图内容延伸到状态栏区域
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION // 允许视图延伸到导航栏区域
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val lp = window.attributes
lp.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
window.attributes = lp
}
因为 Android 系统不允许视图内容跟系状态栏和导航栏区域重叠,要让视图内容强制延伸至系统状态栏和导航栏区域,需对 systemUiVisibility 做相应的设置。
4.3.2 异形切口区域的视图适配
如果将视图内容扩展到了异形切口,异形切口会遮挡部分内容。如果遮挡的部分不影响体验,则不需要处理。但是如果遮挡的部分是文字内容,或者有用户可交互的操作,则需要进行适配。具体适配的方式就是获取异形切口的位置和大小,进行处理。系统提供接口 WindowInsets.getDisplayCutout()
来获取 DisplayCutout
对象,该对象秒速了屏幕中所有切口位置和大小。
如何获取 DisplayCutout 对象
获取 DisplayCutout 对象,首先要获取 WindowInsets 对象。 WindowInsets 对象并没有直接获取方法,只能通过监听或者回调方法获取,获取 WindowInsets 对象有两种方法:
- 在自定义View内部重写
View.onApplyWindowInsets(WindowInsets)
方法获取
class MyView(context: Context): View(context) {
override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets {
return super.onApplyWindowInsets(insets)
}
}
注意事项:如果是重写View.onApplyWindowInsets(WindowInsets)方法获取,请确保 WindowInsets 在之前没有被消耗,没有给 View 的父级或者 View 设置 View.OnApplyWindowInsetsListener 监听。
- 通过给 View 设置
View.onApplyWindowInsetsListener
监听获取
window.decorView.apply{
window.decorView.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
setOnApplyWindowInsetsListener { v, insets ->
insets
}
}
}
}
如何获取屏幕异形切口区域
有了 WindowInsets 对象,我们可以通过 WindowInsets.getDisplayCutout()
获取切口相关信息。
在 API Level 28 中,该对象只能支持获取屏幕的安全区域,通过以下方法获取各边安全间距:
WindowInsets.getSafeInsetBottom()
WindowInsets.getSafeInsetLeft()
WindowInsets.getSafeInsetRight()
WindowInsets.getSafeInsetTop()
在 API Level 29 开始,还支持获取具体的切口位置和大小信息,方法如下:
WindowInsets.getBoundingRectBottom()
WindowInsets.getBoundingRectLeft()
WindowInsets.getBoundingRectRight()
WindowInsets.getBoundingRectTop()
获取各边的切口信息,知道切口信息,可以更加精准地控制显示。
从 API 21 开始,可以通过 WindowInsets 这个对象获取状态栏和导航栏的高度,这些接口分别是
WindowInsets.getStableInsetBottom()
WindowInsets.getStableInsetLeft()
WindowInsets.getStableInsetRight()
WindowInsets.getStableInsetTop()
在 API Level 30 开始这几个接口被废弃,使用下面方法替代:
WindowInsets.getInsetsIgnoringVisibility (int typeMask)
用这种方法获取的时候,即使状态栏和导航栏处于隐藏状态,也不影响获取。
示例
window.decorView.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
setOnApplyWindowInsetsListener { v, insets ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
insets.displayCutout?.apply {
binding.tvTest.setPadding(safeInsetLeft, safeInsetTop, safeInsetRight, safeInsetBottom)
}
}
insets
}
}
}
适配前后效果图如下:
4.3.3 异形屏适配的注意事项
- 不要让异形切口区域遮盖任何重要的文本、控件或其他信息。
- 不要将任何需要精细轻触识别的交互式元素放置或延伸到异形切口区域,异形切口区域中的轻触灵敏度可能要比其他区域低一些。
- 避免对状态栏高度进行硬编码,否则可能会导致内容重叠或被切断。
- 如果的应用需要进入全屏模式,使用shortEdges 模式,退出全屏是,使用 never 模式。
- 在全屏模式下,在使用窗口坐标与屏幕坐标时需要注意,因为在显示黑边的情况下,窗口不会占据整个屏幕。因此根据屏幕原点(屏幕左上角)得到的坐标与根据窗口原点(窗口左上角)得到的坐标不再相同。可以根据需要使用
View.getLocationOnScreen()
接口将屏幕坐标转换为视图坐标。在处理 MotionEvent 时,应当使用MotionEvent.getX()
和MotionEvent.getY()
来避免类似的坐标问题。不要使用MotionEvent.getRawX()
或MotionEvent.getRawY()
。
五、Android Q 全面屏及手势导航
Android 10 中添加了新的系统导航模式,用户可以通过手势交互执行后退、返回至主屏以及打开设备助手等操作。通过使用手势交互来执行系统导航,应用可以使用到更多的屏幕空间。这有助于为用户打造更加沉浸的体验。
5.1 Android 10 的全面屏
在 Android 10 的实现全面屏,分为三步:
1. 请求全屏布局
第三节我们说过 Android 10 上建议将视图内容延伸到导航栏,这里我们主要看导航栏。这部分内容和第三节一致,我们只需要使用视图的 setSystemUiVisibility() 方法,主要关注:
view.systemUiVisibility =
// Tells the system that the window wishes the content to
// be laid out at the most extreme scenario. See the docs for more information on the specifics
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
// Tells the system that the window wishes the content to be laid out as if the navigation bar was hidden
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
这里不再赘述。
效果如下:
2. 更改系统栏颜色
Android 10 上,我们只需要将系统栏颜色设置为完全透明即可:
通过 XML 设置如下:
<style name="Theme.MyApp">
<item name="android:navigationBarColor">
@android:color/transparent
</item>
<!-- Optional, if drawing behind the status bar too -->
<item name="android:statusBarColor">
@android:color/transparent
</item>
</style>
或者通过代码设置
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}
此时系统会执行两个操作:
- 动态颜色适配
系统栏里的内容会根据其后面的内容改变颜色。如果拖拽条位于浅色内容前方,它将变为深色,在深色内容前方时则变为浅色。 - 半透明遮盖
当应用声明 targetSdkVersion 为 29 时,系统也可以在系统栏后面放置一层半透明遮盖。 SDK 28 或更低版本,系统不会显示遮盖,而是提供透明的导航栏。
当然不同的设备厂商可能会根据情况禁用动态颜色适配。比如手机系统性能不足以支持动态色彩适配。
在 Android 9 或者更早的版本则需要自己给导航栏设置一个半透明遮罩。示例代码:
<!-- values/themes.xml -->
<style name="Theme.MyApp">
<item name="android:navigationBarColor">
#B3FFFFFF
</item>
</style>
<!-- values-night/themes.xml -->
<style name="Theme.MyApp">
<item name="android:navigationBarColor">
#B3000000
</item>
</style>
5.2 边衬区(Insets)
前面提到,将视图内容延伸到系统栏后面之后,内容可能会有重叠,造成视觉冲突,如何处理这种视觉冲突?
先熟悉一个术语——边衬区(insets),Insets 区域负责描述屏幕的哪些部分会与系统 UI 相交 (intersect),例如导航或状态栏。如果应用的控件出现在了这些区域内,就可能被系统 UI 遮盖。我们可以使用 insets 区域来尝试解决视觉冲突,如把视图从屏幕边缘向内移动到一个合适的位置。
在 Android 上,Insets 区域由 WindowInsets 类表示,在 AndroidX 中则使用 WindowInsetsCompat。在 Android 10 系统中处理应用布局时,开发者需要知晓 5 个获取 insets 区域的方法。
5.2.1 系统窗口边衬区
方法: getSystemWindowInsets()
系统窗口区域是最常用到的。自 API 1 以来,它们就以各种形式存在着,并且每当系统 UI 重叠显示在您的应用上方时,这个方法就会被调用。常见的例子是下拉状态栏和导航栏,或者弹出屏幕软键盘 (IME)。
示例:
xml代码:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#008B8B"
tools:context=".MainActivity">
<Button
android:id="@+id/btn_test"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="button test"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
我们让系统栏颜色透明,并且视图内容延伸到系统栏后面,java 代码:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.statusBarColor = Color.TRANSPARENT
window.navigationBarColor = Color.TRANSPARENT
}
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
)
该布局,呈现的效果如下:
现在我们的 button 被遮挡住了,点击 button ,很可能会触发 recent task, 视觉和交互上都出现了冲突,解决方案当然是把我们的 button 向上移动动一段距离,给它的父容器设置一个 padding 。但是要注意,用户有可能设置虚拟按键导航或者成全屏手势导航,这个 padding 不能硬编码,应该是通过 insets 动态获取的导航栏的高度。与前面讲异形屏处理一样。insets 对象无法直接获取,只能通过回调的方式。
ViewCompat.setOnApplyWindowInsetsListener(binding.btnTest) { v, insets ->
v.updatePadding(bottom = insets.systemWindowInsetBottom)
insets
}
效果如下:
简而言之,系统窗口区域 insets 最适合那些需要点击的控件,可以确保系统栏不遮盖住它们。
5.2.2 可点击区域
方法:getTappableElementInsets()
接下来是 Android 10 中新增的可点击区域 insets。它们与上面的系统窗口区域 insets 非常相似。可点击区域 insets 用来界定可触发系统点击行为 (tap) 的最小区域。注意,使用可点击区域里的数值进行布局时,依然可能导致自己的控件与系统 UI 在视觉上重叠,这一点与系统窗口区域 insets 不同,使用后者的值对自己的控件进行位移后能确保不会与系统/导航栏发生视觉重叠。
实测在虚拟三键导航情况下,区别不太明显,但在全屏手势导航下,区别可见:
处理系统窗口区域 insets,
ViewCompat.setOnApplyWindowInsetsListener(binding.btnTest) { v, insets ->
v.updatePadding(bottom = insets.systemWindowInsetBottom)
insets
}
处理可点击区域 insets,
ViewCompat.setOnApplyWindowInsetsListener(binding.btnTest) { v, insets ->
v.updatePadding(bottom = insets.tappableElementInsets.bottom)
insets
}
效果图分别如下:
从实用的角度出发,建议使用系统窗口区域 insets,它可以更好地满足几乎所有需要使用可点击区域 insets 的用例。
5.2.3 系统手势边衬区
方法: getSystemGestureInsets() & getMandatorySystemGestureInsets()
这是在 Android 10 中新增的, Android 10 全屏手势导航, 允许用户通过手势动作:
- 从屏幕左/右边缘向中间滑动,相当于后退按钮 (Back)。
- 从屏幕底部开始向上滑动,可以让用户切换最近使用的应用 (Recent)。
在系统手势区域中,系统手势操作优先于应用自己的手势操作。系统手势区域有两个获取方法。其中 getMandatorySystemGestureInsets()
只包含强制性系统手势区域,是系统手势区域的子集。
在 Android 10 上,系统手势区域如下:
如果有需要滑动操作的控件出现在了系统手势区域内,就可以使用对应的数值来将这些控件挪开。常见的例子包括底部导航菜单、游戏里的滑动交互、ViewPager 等。
强制系统手势边衬区是系统手势边衬区的子集,之所以称之为 "强制区域",是因为应用无法修改这些区域。强制系统手势边衬区只包含那些系统保留的区域,在这些区域内系统手势操作永远优先。在 Android 10 上,当前唯一的强制区域是屏幕底部的主屏手势区域,系统保留这个区域就可以让用户在任何时候都可以退出当前应用。
5.2.4 稳定显示边衬区
方法: getStableInsets()
这个方法与手势导航关系不大, 和系统窗口边衬区类似,稳定显示区域是系统 UI 可能在您的应用上显示的位置。在有些显示模式下 (比如放松模式和沉浸模式),系统 UI 可能会根据情况在可见与不可见之间切换 (如游戏、照片浏览、视频播放器等)。这时使用稳定显示区域就可以确保自己的控件不会被 "突然出现" 的系统 UI 挡住。
5.3 处理边衬区冲突
处理边衬区冲突,其实在上一节已经讲到,主要是利用 windowInsets 来获取边衬区域,将有冲突的控件移出边衬区域。
前面讲异形屏适配的时候讲到, WindowInsets 对象并没有直接获取方法,只能通过监听或者回调方法获取,获取 WindowInsets 对象。
比如,我们想给某个控件增加一些边距,让它不被导航栏遮挡:
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
v.updatePadding(bottom = insets.systemWindowInsets.bottom)
// Return the insets so that they keep going down the view hierarchy
insets
}
我们仅将系统窗口区域的底部边距值赋给了控件的底边距。
注意: 如果您要在 ViewGroup 上执行此操作,则可能要对其进行设置 android:clipToPadding="false"。这是因为默认情况下,所有视图都会在填充区域内裁剪图形。
5.4 使用 Jetpack
使用 insets 时,建议始终用 Jetpack 中的 WindowInsetsCompat 类,它可以兼容低版本 SDK。 Compat 版本在所有 API 级别都提供的了一致的 API 和行为。
注意 API 的一些区别:
view.setOnApplyWindowInsetsListener { v, insets ->
...
insets
}
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
...
insets
}
5.5 处理手势冲突
5.5.1. 无需处理
如果视图控件,与手势导航不存在冲突,则可以不处理。
5.5.2 将该视图/控件移出手势交互区域
这是我们前面提到的解决方案。视情况而定。
5.5.3 使用手势区域排除 API
"应用可以从系统手势区域中切出一部分用来响应自己的手势交互"。这就是 Android 10 中新引入的手势区域排除 API。
应用可以通过 Android 10 中新增的系统手势区域排除 API 来让系统边缘的一部分区域不响应系统手势。系统提供了两种不同的功能来 "切出" 交互区域: View.setSystemGestureExclusionRects()
和 Window.setSystemGestureExclusionRects()
。使用哪种取决于应用: 如使用的是 Android View,则建议首选 View API,否则请使用 Window API。
这两个 API 之间的主要区别在于,Window API 会以窗口 (Window) 坐标系计算矩形。如果使用的是 View API,则会以视图的坐标系进行操作。View API 会帮助解决坐标空间之间换算的问题。
示例
一个自定义View,手势在上面滑动会绘制对应的轨迹。
xml 文件如下:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#008B8B"
tools:context=".MainActivity">
<com.lee.windowinsetdemo.MyView
android:layout_width="match_parent"
android:layout_height="400dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:background="#B0B0B0"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
Activity 代码如下:
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
)
}
}
此时运行起来应该是这样的
下面问题是,如果我在自定义 View 上从屏幕边缘向中间画的时候画不上,会触发系统的返回。我们需要将系统手势导航区域排除掉我们需要绘画的区域。创建一个类似于下面的方法,该方法会在 onLayout() 和/或 onDraw() 时被调用。
private val gestureExclusionRects = mutableListOf<Rect>()
private fun updateGestureExclusion() {
// Skip this call if we're not running on Android 10+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val rootWindowInsets = rootWindowInsets ?: return
val gestureInsets = rootWindowInsets.systemGestureInsets
gestureExclusionRects.clear()
// Add an exclusion rect for the left gesture edge
gestureExclusionRects += Rect(0, 0, gestureInsets.left, height)
// Add an exclusion rect for the right gesture edge
gestureExclusionRects += Rect(width - gestureInsets.right, 0, width, height)
ViewCompat.setSystemGestureExclusionRects(this, gestureExclusionRects)
}
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
updateGestureExclusion()
...
}
这是会发现,自定义 View 下半部分可以由边缘向屏幕中间滑动绘制了,上半部分却依然响应系统的返回操作。此处似乎有玄机?
手势区域排除的限制
手势区域排除 API 看起来是解决所有手势冲突的完美方案,但实际上并非如此。由于这个 API 会一定程度上破坏用户习惯的操作,因此系统做出了限制: 屏幕的每个边缘最多只能被应用切除 200dp。
为什么要有限制?
手势区域排除使得应用的手势比 "返回" 等系统操作更重要。如果一个应用能够让屏幕的整个边缘都不响应系统手势,就会让用户感到困惑,这个应用也极有可能被用户卸载。开发者需要尽量确保用户使用一致的操作来与系统进行交互,如从边缘向内滑动进行返回。注意是在整个设备上,而不仅仅是在一个应用中保持一致性。
系统导航必须始终保持一致性和可用性。
为什么是200dp?
手势区域排除 API 只有在万不得已的情况下才可以使用,Google 开发人员计算了可能需要应用这套机制的触摸对象的面积。触摸对象的最小推荐尺寸是 48dp。取 4个触摸对象,即 4 × 48dp = 192dp。再加入一点富余量,即为 200dp。
1. 如果开发者要求在边缘上切出 200dp 以上的区域,系统只会兑现您的要求中位于最下方的 200dp。
2. 如果视图不在屏幕内,系统仅计算屏幕范围内的切出矩形。如果视图只有一部分显示在屏幕内,则仅计算所请求矩形的屏幕内可见部分。
示例代码中,我有意将自定义View 的高度设置为 400dp, 这也就是为什么刚好下半部分系统手势区域排除生效,上半部分不生效的原因。那么问题又来了,我就是要整个整个View都可以绘画,这只给下半部分响应从边缘向内的绘制,上半部分不能绘制,这算哪样?答案请看下一节。
5.5.3 沉浸模式下使用手势区域排除 API
前面提到沉浸模式是一种让内容全屏呈现的方式,用来隐藏系统栏,从而确保应用拥有最大的屏幕空间。此外,它还提供了防误操作的功能 (比如意外使用手势离开应用),特别适合在游戏中采用
- 沉浸模式分为两种:
1. 非粘性沉浸模式: 用户可以通过在系统栏上滑动来退出沉浸模式。
2. 粘性沉浸模式: 用户可以通过在系统栏上滑动来暂时退出沉浸模式。在经过一小段时间后 (只有几秒) 会重新自动回到沉浸模式。
- 这两种模式都有两种状态:
1. 系统栏隐藏: 在此状态下,返回主屏幕手势和后退手势均被禁用。用户必须首先从边缘向内侧滑动才能让系统栏显示。
2. 系统栏显示: 在此状态下,返回主屏幕手势和后退手势可以正常工作。
- 沉浸模式的粘性与非粘性
非粘性 (non-sticky) 沉浸模式非常适合需要全屏显示但不需要在屏幕边缘附近使用精确滑动手势的 UI。常见的例子包括全屏视频播放和照片浏览等。就手势导航而言,在此模式下,无论系统栏是否可见,每个边缘能排除的区域高度仍旧限制为 200dp。
粘性 (sticky) 沉浸模式适合那些强烈需要使用整个屏幕,并要求在整个屏幕区域内进行触摸输入的 UI。常见的例子是绘图应用,以及使用滑动操作的游戏。
1. 使用粘性沉浸模式的应用会有很强的交互性,因此手势区域排除 API 的限制会被移除,但仅限于系统栏隐藏的时候。这意味着应用可以根据需要完全占用屏幕左 / 右边缘。
2. 在系统栏可见时,系统则会忽略所有排除的手势区域,让用户可以返回,而不会受到来自应用的干扰。在粘性沉浸模式下,系统栏仅在短时间内可见,因此不会影响应用的正常交互。
屏幕底部的主屏手势区域依旧会正常存在,是无法排除的 "强制" 手势区域。处于粘性沉浸模式的应用可能会占用两个垂直边缘的整个长度,因此屏幕底部的主手势区域可能是用户呼出系统栏并退出应用的唯一方法。
我们改进之前的示例,我们需要知道视图当前是否处于沉浸模式之中:
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
updateGestureExclusion()
...
}
override fun onWindowSystemUiVisibilityChanged(visibility: Int) {
super.onWindowSystemUiVisibilityChanged(visibility)
// Update our gesture exclusions rects if we’re
// running on Android 10+
if (Build.VERSION.SDK_INT >= 29) {
updateGestureExclusionRects()
}
}
private fun updateGestureExclusion() {
if ((windowSystemUiVisibility and SYSTEM_UI_FLAG_HIDE_NAVIGATION) != 0) {
// Root window insets are null, which happens if this is called
// before we're attached and laid out. Ignore the call for now.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val rootWindowInsets = rootWindowInsets ?: return
val gestureInsets = rootWindowInsets.systemGestureInsets
gestureExclusionRects.clear()
// Add an exclusion rect for the left gesture edge
gestureExclusionRects += Rect(0, 0, gestureInsets.left, height)
// Add an exclusion rect for the right gesture edge
gestureExclusionRects += Rect(width - gestureInsets.right, 0, width, height)
ViewCompat.setSystemGestureExclusionRects(this, gestureExclusionRects)
}
} else {
// If the navigation bar is showing, we don't want to exclude any edges.
ViewCompat.setSystemGestureExclusionRects(this, emptyList())
}
}
override fun onWindowSystemUiVisibilityChanged(visible: Int) {
super.onWindowSystemUiVisibilityChanged(visible)
if (Build.VERSION.SDK_INT >= 29) {
updateGestureExclusion()
}
}
六、Android R 上新 API
如果你的应用适配 R 及以上版本,在使用 setSystemUiVisibility()
方法时,会提示你,该方法过时了,对应前面讲到的 flag 也都标记为过时。Android官方在api30之后提供 WindowInsetsController
,用于控制 window 的控制类,实现 window 控制的简单化。
先上代码:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.decorView.windowInsetsController?.hide(WindowInsets.Type.statusBars())
window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars())
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
window.setDecorFitsSystemWindows(false)
}
上面代码什么意思?
其实,windowInsetsController
用对应的 show()
和 hide()
方法,来对 statusbar 和 navigationBar 进行控制,看代码一眼就能看明白是什么意思。比之前版本的 flag 更为直观。
隐藏状态栏和导航栏:
// window.decorView.windowInsetsController?.hide(WindowInsets.Type.systemBars())
window.decorView.windowInsetsController?.hide(WindowInsets.Type.statusBars())
window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars())
布局稳定,不随状态栏和导航栏的显示隐藏变化
floating windows:
// WindowManager.LayoutParams.setFitInsetsTypes(WindowInsets.Type.systemBars())
WindowManager.LayoutParams.setFitInsetsTypes(WindowInsets.Type.statusBars())
WindowManager.LayoutParams.setFitInsetsTypes(WindowInsets.Type.navigationBars())
non-floating windows:
window.setDecorFitsSystemWindows(false)
这里要注意,悬浮窗和非悬浮窗是有区别的。
粘性与非粘性
// 非粘性
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_DEFAULT
// 粘性
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
为了实现全面屏:
override fun onWindowFocusChanged(hasFocus: Boolean) {
if (hasFocus) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// window.decorView.windowInsetsController?.hide(WindowInsets.Type.statusBars())
// window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars())
window.decorView.windowInsetsController?.hide(WindowInsets.Type.systemBars())
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
window.setDecorFitsSystemWindows(false)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
} else {
// 4.1 以下处理
...
}
}
}