Jetpack Compose处理“导航栏、状态栏、键盘” 影响内容显示的问题集锦

8,862 阅读7分钟

1.前言

写Compose相关例子的时候,突然不怎么使用xml,有些东西不清楚怎么下手,就比如Compose中状态栏,导航栏沉浸,键盘遮挡等问题如何处理,对这方面不清楚的同学,请往下翻看看我们如何去处理它的
前方高能预警:一定要记得收藏起来,划走了可就再也找不到了😅😅🙈🙈

2.初始态

默认创建一个工程,添加如下代码,页面除了“内容区域”之外,还有“导航栏、状态栏”

setContent {
      Surface(color = Color(0xFFF74C4C),modifier = Modifier.fillMaxSize()){}
}

我们可以看到状态栏颜色是style.xml中配置的,导航栏不处理的话,默认是黑色
我们下面来看Compose中如何处理“导航栏、状态栏、键盘”遮挡的问题

3.思考并解决

3.1-内容延伸到状态栏

上面的红色背景有点突兀,不方便对比,我们用图片来代替红色背景
首先把状态栏颜色设置透明,下面提供大家简单的三种方式来设置状态栏颜色,原理都是一样的,你随便使用哪种方式都可以

  • :style.xml设置状态栏透明色
<item name="android:statusBarColor">@android:color/transparent</item>
  • :window设置状态栏透明色
window.statusBarColor = ResourcesCompat.getColor(resources,android.R.color.transparent,null)
implementation "com.google.accompanist:accompanist-systemuicontroller:<version>"

代码使用如下,原理也是一样的最终都是通过window来设置的

val systemUiController = rememberSystemUiController()
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = false)

我们要把内容延伸到状态栏,可以使用如下方法:

WindowCompat.setDecorFitsSystemWindows(window,false)

3.2-状态栏遮挡列表问题

上面我们使用图片可以看到,效果还是不错的,如果不是图片,我们是列表,这个时候第一位的内容是不是跑到屏幕外面了?
如果列表足够长,最后一个条目也无法完整显示因为第一个条目顶出到状态栏外面了


列表“第一个”和“最后一个”都无法正常显示

我们来一步一步的解决这个问题,我们先解决状态栏遮挡第一个条目的内容;
我们需要有一个状态栏这么高的大小,作为内边距来保证安全边距,我们在使用LazyColumn的时候,可以看到内部有个contentPadding属性:为整个内容添加的内部填充,不是针对单独的item进行填充的;

点击查看Compose如何把px转dp,dp转px

那么我们如何获取“状态栏”的高度呢?
在Compose中有两种方式获取:

//方式一:系统状态栏的高度
fun Resources.getStatusBarHeight():Int {
    var statusBarHeight = 0
    val resourceId = getIdentifier("status_bar_height", "dimen", "android")
    if(resourceId >0 ){
        statusBarHeight = getDimensionPixelSize(resourceId)
    }
    return statusBarHeight
}

//方式二:系统状态栏的高度
//1.先依赖lib库
implementation "com.google.accompanist:accompanist-insets:<version>"
//2.依赖lib库之后,使用ProvideWindowInsets可以访问LocalWindowInsets.current值
//本质是通过CompositionLocalProvider传值的
ProvideWindowInsets {
    //这个返回的类型是PaddingValues,打印一下就可以看到状态栏的高度了
    rememberInsetsPaddingValues(LocalWindowInsets.current.statusBars)
}

使用示例如下:

ProvideWindowInsets {
    //方式一获取到的“状态栏高度”
    //注意:此种方式获取到,设置仅PaddingValues是四个方向全部都有值
    /*val sbPaddingValues = PaddingValues(with(LocalDensity.current) {
           LocalContext.current.resources.getStatusBarHeight().toDp()
    })*/
    
    //方式二获取到的“状态栏高度”
    //注意:这个方式获取到的PaddingValues只有顶部状态栏方向有值,其他方向为0.dp
    val sbPaddingValues = rememberInsetsPaddingValues(LocalWindowInsets.current.statusBars)
    LazyColumn(modifier = Modifier.fillMaxSize()
        .background(Color(0xFFDA8E70)),
        contentPadding = sbPaddingValues
    ) {
        items(12) { index ->
            Text(
                text = "Item:$index", color = Color.Black, fontSize = 20.sp,
                modifier = Modifier.padding(5.dp).height(60.dp)
            )
            Divider()
        }
    }
}

状态栏遮挡问题处理

我们可以看到状态栏不再遮挡,但是导航栏会遮挡,上面我们使用的是方式二,只有顶部才会有值,不建议设置四个方向都是状态栏高度,设置之后就会影响界面其他元素边距,那么如何正确的让底部导航栏不遮挡住列表最后一个条目呢?请往下看

3.3-导航栏遮挡列表问题

看完上面状态栏遮挡的处理方法之后,导航栏不也是同样吗?稍微不同的是,我们不能在通过类似resId方式获取,现在手机都可以动态更换状态栏了,所以我们还是使用LocalWindowInsets来动态获取

ProvideWindowInsets {
    //获取导航栏的高度
    val navPaddingValues = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars)
    LazyColumn(modifier = Modifier
        .fillMaxSize()
        .background(Color(0xFFDA8E70)),
        contentPadding = navPaddingValues
    ) {
        ......
    }
}

导航栏遮挡问题处理

可能有人看到这里突然来了句,底部怎么这么大的距离?正常吗?请仔细看一下,每个item的高度都是一样的哦,仔细看一下是正常的;

我们把导航栏背景色设置为透明色,再来看一下效果,效果更明显

val systemUiController = rememberSystemUiController()
//设置导航栏透明色
systemUiController.setNavigationBarColor(Color.Transparent, darkIcons = false)

导航栏遮挡问题处理

3.4-同时处理状态栏和导航栏遮挡

我们使用LocalWindowInsets.current.systemBars来给contentPadding赋值,它可以同时获取“状态栏、导航栏”所占的高度,防止遮挡,这个方法不含键盘高度


同时处理 状态栏和导航栏 遮挡问题

3.5-Modifier方式

为什么使用这个方式呢?不是所有控件都有contentPadding属性可以使用,那么我们可以通过Modifier修饰符来处理,我们同时添加navigationBarPadding()和statusBarsPadding(),或者只添加一个systemBarsPadding()(这个方法不含键盘高度)来处理遮挡问题,本质上内部是使用了Modifier.padding

ProvideWindowInsets {
    LazyColumn(modifier = Modifier
        .fillMaxSize()
        .background(Color(0xFFDA8E70)).systemBarsPadding()
    ) {
        ......
    }
}

但是Modifier的方式无法让内容延伸到“状态栏和导航栏”下方,视图整体是在“状态栏和导航栏”上方


Modifier.systemBarsPadding() 效果

3.6-处理键盘遮挡问题

我们上面提到键盘高度,那么键盘会有什么问题呢?我们先看下面这样的一个例子,以经典的文本输入框为例

@Composable
fun LoginTextField(
    name : String,
    updateName : (String) -> Unit,
    pwd : String,
    updatePwd : (String) -> Unit
){
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Bottom,
        //先让它在导航栏上方显示,否则会显示在导航栏下方
        //因为我们文章上面设置了内容延伸
        modifier = Modifier.fillMaxSize().navigationBarsPadding()
    ) {
        OutlinedTextField(
            value = name,
            onValueChange = updateName ,
            label = { Text("用户名") },
            placeholder = { Text(text = "请输入用户名") },
            ......
        )
        OutlinedTextField(
            value = pwd,
            onValueChange = updatePwd ,
            label = { Text("密码") },
            placeholder = { Text(text = "请输入密码") },
            ......
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
            //隐藏密码内容
            visualTransformation = PasswordVisualTransformation('*')
        )
    }
}

//使用如下:
ProvideWindowInsets {
    var name by rememberSaveable { mutableStateOf("") }
    val updateName = { _name : String ->
        name = _name
    }
    var password by rememberSaveable { mutableStateOf("") }
    val updatePassword = { _pwd : String ->
        password = _pwd
    }
    LoginTextField(
        name = name,
        updateName = updateName,
        pwd = password,
        updatePwd = updatePassword
    )
}

看一下,上面例子出现的问题,动图如下:


键盘遮挡问题

很明显,我们需要知道键盘的高度,才能做到不遮挡,我们可以使用Modifier.navigationBarsWithImePadding() 来做到安全不遮挡,会自动计算键盘打开和关闭以及导航栏的高度最大值;

我们看替换完之后的效果,一定要注意一个事情,不要因为使用了Compose而把AndroidManifest.xml遗忘了,一定要给你所在的Activity配置如下属性

android:windowSoftInputMode="adjustResize"

如有其他疑惑的小伙伴,可以在评论区留言


键盘遮挡问题修复

3.7-全屏隐藏导航栏和状态栏

我们在写这篇文章的时候,忘记写这个场景了,评论区有个小伙提了个问题:

compose 中使用systemUiController 将状态栏或者导航栏隐藏掉,只要点击屏幕,又会显示出来,并且一直存在

我们在评论区给他提供了答案,考虑到其他小伙伴,不一定会看评论,所以这里补充进来;

我们先来看看systemUiController隐藏状态栏或者导航栏,会出现什么问题:

// systemUiController 隐藏的代码示例

@Composable
fun ExampleList(){
    val systemUiController = rememberSystemUiController()
    //我们再这里调用:隐藏导航栏和状态栏
    systemUiController.isSystemBarsVisible = false
    LazyColumn{
        for (index in 0 until 100){
            item {
                Text(text = "Item$index",modifier = Modifier
                    .fillMaxWidth()
                    .height(50.dp),fontSize = 20.sp)
                Divider()
            }
        }
    }
}

我们看一下上面示例执行后在不同版本系统上的效果:


Android5.0 ~ Andoid11系统

我们可以看到,触摸到屏幕,系统栏自己就跑出来了,还不能自动隐藏,我们再看它再Android12+ 系统是什么效果,请往下看:


Android12+系统

很显然上面的效果并不是我们要的,为什么会出现这种情况呢?我们看一下源码

rememberSystemUiController() 里面记录的是AndroidSystemUiController

当我们调用了systemUiController.isSystemBarsVisible = false

windowInsetsController是WindowInsetsControllerCompat类型的数据
实际上内部调用的是
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
windowInsetsController.hide(WindowInsetsCompat.Type.navigationBars())

我们找到有四个关联hide方法注释的地方:

// (【已废弃】,高版本Android12+不再支持)
// 如果有用户交互,系统栏将被强制显示
int BEHAVIOR_SHOW_BARS_BY_TOUCH = 0;

// 通过此行为隐藏的,窗口会保持此行为,我们可以通过手势从边缘滑动,再把系统栏显示出来,滑出来之后,无法自动隐藏
int BEHAVIOR_DEFAULT = 1;

// 同上(此行为【已废弃】,如需使用:建议用BEHAVIOR_DEFAULT)
int BEHAVIOR_SHOW_BARS_BY_SWIPE = BEHAVIOR_DEFAULT;

// 在此模式下隐藏系统栏时,可以通过系统手势临时显示系统栏,
// 例如从隐藏栏的屏幕边缘滑动。这些临时系统栏将覆盖应用程序的内容,
// 可能具有一定程度的透明度,并在短时间超时后自动隐藏。
int BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE = 2;

从上面的示例演示的效果,我们发现Android12+使用的行为是1Android12以下系统使用的行为是0, 我们在原来hide方法的基础上,修改一下systemBarsBehavior

// 全屏隐藏系统栏,如:你看视频或者玩游戏的时候,就可以通过此种方式,体验是一样的
private fun hideSystemUI() {
    WindowCompat.setDecorFitsSystemWindows(window, false)
    // 如果你在Composable里面,可以参考rememberSystemUiController() 一样使用LocalView.current也可以
    WindowInsetsControllerCompat(window, window.decorView).let { controller ->
        controller.hide(WindowInsetsCompat.Type.systemBars())
        // 修改行为
        controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
    }
}

// 从全屏隐藏状态下,恢复系统栏的显示
private fun showSystemUI() {
    WindowCompat.setDecorFitsSystemWindows(window, true)
    WindowInsetsControllerCompat(window, window.decorView).show(WindowInsetsCompat.Type.systemBars())
}

别忘了一点哈,如果你的设备有刘海屏,一定要配置窗口是可以显示在刘海区域,我们需要在themes.xml中配置如下属性,如果你不懂什么是刘海屏,请看官方的文档

<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>

来看最终效果吧,这个才是我们平时玩游戏,看视频的时候一模一样的效果:


最终全屏效果

可以看到,这才是我们要的全屏效果只有我们在触摸“状态栏”和“导航栏”,才会显示出来,等几秒自动隐藏

4.总结

(1). 我们需要如下两个Lib库帮助我们

implementation "com.google.accompanist:accompanist-systemuicontroller:<version>"
implementation "com.google.accompanist:accompanist-insets:<version>"

(2). 状态栏和导航栏变色

val systemUiController = rememberSystemUiController()
//分开设置,考虑到背景颜色,我们需要动态更新图标颜色嘛
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = true)
systemUiController.setNavigationBarColor(Color.Transparent, darkIcons = false)

//或者使用,直接统一两个栏
systemUiController.setSystemBarsColor(.....)

(2). 如果是列表,需要内容延伸出状态栏和导航栏,可以使用contentPadding属性,设置内容边距

ProvideWindowInsets {
    //做到导航栏和状态栏都可以延伸内容
    val paddingValues = rememberInsetsPaddingValues(LocalWindowInsets.current.systemBars)
    LazyColumn(modifier = Modifier
        .fillMaxSize()
        .background(Color(0xFFDA8E70)),
        contentPadding = navPaddingValues
    ) {
        ......
    }
}

(3). 如果使用Modifier方式处理遮挡问题,无法做到内容延伸出“状态栏和导航栏”

Modifier.navigationBarsPadding()
Modifier.statusBarsPadding()
Modifier.systemBarsPadding()

(4). 键盘遮挡问题

AndroidManifer.xml配置:
android:windowSoftInputMode="adjustResize"
//防止键盘遮挡,文本输入框
Modifier.navigationBarsWithImePadding()

有些小伙伴可能看完会有个疑问:列表使用contentPadding的时候和Modifier.padding内部做什么,导致它们效果不同的呢?
感兴趣的同学看一下:LazyListMeasure里面的calculateItemsOffsets方法

(5). 全屏并隐藏系统导航栏和状态栏

// 全屏隐藏系统栏,如:你看视频或者玩游戏的时候,就可以通过此种方式,体验是一样的
private fun hideSystemUI() {
    WindowCompat.setDecorFitsSystemWindows(window, false)
    // 如果你在Composable里面,可以参考rememberSystemUiController() 一样使用LocalView.current也可以
    WindowInsetsControllerCompat(window, window.decorView).let { controller ->
        controller.hide(WindowInsetsCompat.Type.systemBars())
        // 修改行为
        controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
    }
}

// 从全屏隐藏状态下,恢复系统栏的显示
private fun showSystemUI() {
    WindowCompat.setDecorFitsSystemWindows(window, true)
    WindowInsetsControllerCompat(window, window.decorView).show(WindowInsetsCompat.Type.systemBars())
}

支持刘海屏,需要在themes.xml中进行如下配置:

<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>

然后我们调用hideSystemUI() 就可以全屏显示了,调用showSystemUI() 就可以恢复系统栏显示了


往期文章推荐:
1.Android跨进程传大图思考及实现——附上原理分析
2.闲聊Android悬浮的“系统文本选择菜单”和“ActionMode解析”——附上原理分析 3.Jetpack Compose实现bringToFront功能——附上原理分析
4.Jetpack Compose UI创建布局绘制流程+原理 —— 内含概念详解(满满干货)
5.Jetpack App Startup如何使用及原理分析
6.Jetpack Compose - Accompanist 组件库
7.源码分析 | ThreadedRenderer空指针问题,顺便把Choreographer认识一下
8.源码分析 | 事件是怎么传递到Activity的?
9.聊聊CountDownLatch 源码
10.Android正确的保活方案,不要掉进保活需求死循环陷进