在之前的很长一段时间里,绝大多数的手机屏幕使用的都是16:9的比例。当时普遍认为16:9就是最合适的设备屏幕比例,因为手机上方还要给听筒摄像头留足空间,下方还要给Home键留足空间。然而,根据我所能查到的最早资料,小米是第一个敢于打破这个限制的手机厂商(不保证一定正确)。在2016年的时候,小米推出了MIX一代手机,将屏幕做到了接近18:9的比例,并首次提出了全面屏的概念。但是要做到真正的全面屏并不是一件容易的事情,像Home键之类的实体按键还可以用虚拟按键来替代,但是前置摄像头这种就是实打实的硬件传感器了,必须得要占据一定的空间。因此,小米MIX选择了将摄像头做到了屏幕下方,形成了一个比较宽的下巴。
这里我并不想评价小米MIX手机是不是一个成功的产品,或者是否具备划时代的意义。但是至少从那个时候开始,绝大多数的手机厂商都开始想要突破16:9的屏幕比例限制了,其中也包括苹果。
2017年,苹果推出了iPhone产品的10周年纪念手机——iPhone X,库克声称,它将会引导未来10年的手机发展方向。iPhone X最主要的变化就是其首次在iPhone设备上应用了全面屏的概念,将屏幕做到了19.5:9的比例,取消了使用10年之久的Home按键,改为使用FaceId来进行身份认证。
而FaceId是需要通过摄像头进行面部识别来认证身份的,因此苹果同样无法绕过的一个问题就是,前置摄像头应该放在哪里?
和小米MIX的做法不同,iPhone X采用了将屏幕切出一个凹口的方式来放置前置摄像头以及其他传感器硬件,因此形成了一种非标准矩形屏幕。由于切出的凹口很像人的刘海,又被大家戏称为刘海屏。
不过,苹果向来是手机行业的风向标,iPhone X推出之后,大家应该能猜想到接下来会发生什么事情吧?没错,就是一大堆的国产手机厂商也立即跟风去生产刘海屏的Android手机。但是注意,当时的Android 8.0系统还不支持刘海屏设备。那该怎么办呢?这个时候就只能八仙过海,各显神通了。每个手机厂商为了不落后于他人,都通过修改ROM的方式制订了一套自己的API标准,有的是在AndroidManifest里面配置属性的,有的是通过反射来进行赋值的,有的是读取system feature的,总之什么方式的都有。
后来2018年,Google推出了Android 9.0系统,官方正式对刘海屏设备进行了支持。但是刚推出的新系统普及率很低,市场上大部分都还是各个厂商自己定制的8.0系统版的刘海屏手机。如果那个时候来写本篇文章,一定会有许多读者问,华为的刘海屏手机怎么适配?OPPO的刘海屏手机又该怎么适配?VIVO的呢?最终就只能是各种乱七八糟的适配满天飞,而我是很不愿意去写这样一篇文章的。
但是现在不同了,Android 9.0系统发布一年之后,以前的刘海屏手机基本上都已经完成了9.0系统的升级。也就是说,现在我们可以不用再去考虑那些不同手机厂商之间的适配问题了,只需要按照Android官方提供的标准API来进行刘海屏设备的适配即可。因此,我也终于决定要好好写一篇关于Android 9.0系统刘海屏设备适配的文章。
不过,iPhone手机刘海的位置和大小都是固定的,至少目前来说是固定的,而Android手机的刘海却可能以不同的形式出现在不同的位置,因此不要想着可以用硬编码的方式来简单进行适配。
下面我们就来探讨一下,Android手机通常可能会有哪几种刘海形式吧(以下图片创意来自于我参加的一场Google开发者会议,并非是我所原创)。
首先,最常见和最普遍,同时可能也是最受欢迎的,就是“张雨绮”式刘海(Top Center)。
由于iPhone X使用的就是这种形式的刘海,多数Android手机厂商也跟风学习,因此这种刘海的市场占有率极高。
不过,有些手机也可能会使用“桂纶镁”式刘海(Top Corner)。
这种刘海的特点是只会占用屏幕的一个角落空间,手机的屏占比将会更大。
当然,也有一些手机可能会使用“关二爷”式刘海(Bottom)。
这种刘海和“张雨绮”式刘海其实就是相对的,只是一个刘海在上,一个刘海在下而已。
最后,手机厂商也可能会选择使用混合刘海的方式(Top + Bottom)。
常见的刘海大概就是以上这几种吧,但是看到这里你可能会犯难了,我到哪里去找这么多不同种类的刘海屏手机来进行测试呢?不用担心,只要你手上有任何一部Android 9.0或以上系统的手机,都是可以模拟出各种不同类型的刘海的。当然,即使你手上没有任何一部手机,也可以正常进行测试,只需要借助Android官方的模拟器即可。
这里我创建了一台Android 10.0系统的模拟器,在开发者选项当中,将可以找到Display cutout这个栏目,如下图所示。
点击该栏目,会弹出一个如下图所示的菜单。
这里的第一个选项表示无刘海模式,接下来的每一个选项都可以模拟出一种不同的刘海模式,我们可以把每个选项都点一点,比如说下图对应的就是“张雨绮”式的刘海。
而下图对应的是“桂纶镁”式的刘海。
最后,下图对应的是混合模式的刘海。
了解了各种不同的刘海模式,以及其对应的模拟方式,这样我们就将准备工作都完成了,接下来终于可以进入到具体的编码适配环节了。
思考一下,其实对于刘海屏的适配并不应该是一件复杂的事情,因为我们的目标很简单,就是不要让刘海部分遮挡到应用程序,或者影响到应用程序的正常使用即可。
为此,Android 9.0系统中提供了3种layoutInDisplayCutoutMode属性来允许应用自主决定该如何对刘海屏设备进行适配。
LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT:这是一种默认的属性,在不进行明确指定的情况下,系统会自动使用这种属性。这种属性允许应用程序的内容在竖屏模式下自动延伸到刘海区域,而在横屏模式下则不会延伸到刘海区域。
LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES:这种属性表示,不管手机处于横屏还是竖屏模式,都会允许应用程序的内容延伸到刘海区域。
LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER:这种属性表示,永远不允许应用程序的内容延伸到刘海区域。
那么我们具体应该如何对layoutInDisplayCutoutMode属性的值进行指定呢?主要有两种方式,在主题xml文件中进行指定,以及在代码中动态进行指定。
如果选择在主题xml文件中进行指定,需要先创建一个values-v28文件夹,并在其中创建一个styles.xml文件,然后在指定Activity的主题下加入如下配置即可:
shortEdges
如果选择在代码中进行指定,只需要在Activity中加入如下代码即可:
if (Build.VERSION.SDK_INT >= 28) {
WindowManager.LayoutParams params = getWindow().getAttributes();
params.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
getWindow().setAttributes(params);
}
了解了以上内容之后,接下来我们就可以动手进行实现了。首先创建一个CutoutTest项目,并让Android Studio帮我们自动生成一个空的Activity。在不编写任何额外代码的情况下直接运行该项目,效果如下图所示。
可以看到,在竖屏模式下应用程序的状态栏部分刚好占据了手机的刘海区域,并且系统还会根据刘海的高度来自动调整状态栏的高度,这样应用程序中的内容自然是不会被刘海部分遮挡到的。
现在如果我们旋转一下手机,横屏模式下的效果如下图所示。
这个时候,手机的刘海区域会整个变成一条大黑边,应用程序的内容是不允许延伸到这部分区域里的,这样也不会产生内容被遮挡的情况。
也就是说,即使我们不做任何的适配工作,绝大多数的程序在默认情况下也是可以自动适配刘海屏手机的,并不会产生应用程序无法使用等问题的发生。但是,假如你开发的是一款视频类应用或者游戏的话,充分利用屏幕的空间明显可以带来更好的用户体验,界面上留着一条大黑边对用户总归是不够友好的。这个时候我们就可以通过指定layoutInDisplayCutoutMode属性的值,来让应用程序具备更好的屏幕适配性。
这里我就使用LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES属性,并且配合着沉浸式模式的代码,来编写一个全屏的UI界面,以此模拟视频和游戏类App的效果。
首先为了防止界面出现一片空白的情况,我对activity_main.xml布局的内容进行了修改,如下所示:
android:id="@+id/rootLayout" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/bg">
这里给最外层的FrameLayout指定了一个背景图,随便使用什么图片都可以,我们只是为了便于进行演示。
接下来修改MainActivity中的代码,为其指定LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES属性。另外,为了让界面效果更加贴近于视频应用或游戏,这里我将MainActivity调整成了沉浸式模式,代码如下所示:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
setContentView(R.layout.activity_main);
View decorView = getWindow().getDecorView();
int option = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN;
decorView.setSystemUiVisibility(option);
ActionBar actionBar = getSupportActionBar();
if (actionBar != null) {
actionBar.hide();
}
if (Build.VERSION.SDK_INT >= 28) {
WindowManager.LayoutParams params = getWindow().getAttributes();
params.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
getWindow().setAttributes(params);
}
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus && Build.VERSION.SDK_INT >= 19) {
View decorView = getWindow().getDecorView();
decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
}
其实这段代码需要我们关心的就是if (Build.VERSION.SDK_INT >= 28)的这个逻辑判断中的内容,在这里我们将当前Activity的layoutInDisplayCutoutMode属性指定成LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES,这样就可以让应用程序的内容延伸到刘海区域了。其他部分的代码都是用于设置沉浸式模式的,关于这部分内容我就不展开进行介绍了,更多沉浸式模式的讲解,可以参考我之前的这篇文章 Android状态栏微技巧,带你真正理解沉浸式模式 。
现在重新运行一下程序,效果如下图所示。
可以看到,程序进入了全屏沉浸式体验的效果,并且我们在布局文件中设置的背景图是可以延伸到刘海区域的,这就使得手机屏幕的空间得到了更充分的利用。
现在旋转一下手机屏幕,效果如下图所示:
很明显,这比之前在刘海区域空出一条大黑边的用户体验要好上太多了。
不过,虽然现在我们已经实现了让应用程序的内容延伸到刘海区域的功能,却无法保证刘海部分不会影响到应用程序的正常使用。什么意思呢?假设你正在玩一款游戏,刘海区域遮挡掉了一小块游戏背景地图,这可能并不会造成什么大的影响,你还是可以正常进行游戏。但如果刘海区域刚好遮挡掉的是一个攻击按钮,那么这款游戏你就完全无法玩了。因此,对于任何应用程序或者是游戏而言,都需要在这方面进行适配,保证自己的可交互控件绝对不能被刘海区域遮挡住。
那么具体应该如何实现这个功能呢?Android在9.0系统中提供了一套专门用于获取安全显示区域的API,我们只需要确认出哪些位置是有可能被遮挡到的,然后对可交互控件进行相应的位置偏移就可以了,示例代码如下所示:
if (Build.VERSION.SDK_INT >= 28) {
rootLayout.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() {
@Override
public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) {
DisplayCutout displayCutout = windowInsets.getDisplayCutout();
if (displayCutout != null) {
int left = displayCutout.getSafeInsetLeft();
int top = displayCutout.getSafeInsetTop();
int right = displayCutout.getSafeInsetRight();
int bottom = displayCutout.getSafeInsetBottom();
}
return windowInsets.consumeSystemWindowInsets();
}
});
}
可以看到,这里先在最外层布局上调用了setOnApplyWindowInsetsListener()方法,然后当发生了onApplyWindowInsets()回调时,就立即获取DisplayCutout对象,接着从这个对象中分别取出上下左右所应当偏移的距离即可。
下面我们还是通过具体的Demo来体验一下实际的效果。首先在activity_main.xml看加入两个按钮,一个在顶部,一个在侧边,如下所示:
android:id="@+id/rootLayout" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/bg">
那么如果现在直接运行程序的话会是什么样的结果呢?我只能说,这一定不是你想要的,如下图所示。
可以看到,顶部可交互控件被手机刘海遮挡掉了大半,这种用户体验是非常差的。
而横屏情况下的结果也好不到哪儿去,如下图所示。
这次变成了侧边可交互控件被刘海遮挡了,所以这两种情况我们都必须要进行适配。
好在适配的方法并不复杂,使用前面介绍的那段示例代码即可轻松完成适配工作。现在修改MainActivity中的代码,如下所示:
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
……
final FrameLayout rootLayout = findViewById(R.id.rootLayout);
final Button topButton = findViewById(R.id.topButton);
final Button sideButton = findViewById(R.id.sideButton);
if (Build.VERSION.SDK_INT >= 28) {
rootLayout.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() {
@Override
public WindowInsets onApplyWindowInsets(View view, WindowInsets windowInsets) {
DisplayCutout displayCutout = windowInsets.getDisplayCutout();
if (displayCutout != null) {
int left = displayCutout.getSafeInsetLeft();
int top = displayCutout.getSafeInsetTop();
int right = displayCutout.getSafeInsetRight();
int bottom = displayCutout.getSafeInsetBottom();
FrameLayout.LayoutParams topParams = (FrameLayout.LayoutParams) topButton.getLayoutParams();
topParams.setMargins(left, top, right, bottom);
FrameLayout.LayoutParams sideParams = (FrameLayout.LayoutParams) sideButton.getLayoutParams();
sideParams.setMargins(left, top, right, bottom);
}
return windowInsets.consumeSystemWindowInsets();
}
});
}
}
}
这段代码并没有什么难理解的地方,和刚才所讲解的示例代码是差不多的。只是在得到上下左右方向上的偏移距离之后,我们通过给按钮的layout设置margin的方式来让控件在四个方向上进行相应的偏移。如果你是在开发游戏的话,也可以同样套用这段代码,只是在获取到相应的偏移距离之后,将这几个值传递给游戏层逻辑即可,由游戏层来控制如何对可交互的控件进行偏移。
现在来重新运行一下代码吧,竖屏模式下的结果如下图所示。
可以看到,顶部可交互控件自动向下偏移了一段距离,刚好可以保证不被刘海区域遮挡到。
那么再来看一下横屏模式下的结果吧,如下图所示。
没有问题,横屏模式下侧边可交互控件自动向右偏移了一段距离,从而也不会被刘海区域遮挡到了。
不过你会发现,在横屏模式下,顶部可交互控件并没有处于屏幕中间的位置,这是因为屏幕的左侧存在刘海,因此DisplayCutout会告诉我们要向左偏移一定的距离。但是我们并没有判断哪些控件需要偏移,哪些控件不需要偏移,而是直接将所有控件都进行偏移,才出现了这种没有居中对齐的情况。
至于解决办法其实并没有什么简单的方式,就是增加逻辑判断即可,在横屏模式下我们可以断定顶部可交互控件是绝对不可能被刘海遮挡到的,因此只需要对侧边可交互控件进行偏移即可,具体的代码我就不再进行演示了。