Android Compose的Window Insets 除了app的内容区域外, 还有一些其他的固定元素会显示在手机屏幕上, 顶部的状态栏, 刘海, 底部的导航栏, 还有输入法键盘, 它们都是系统的UI, 也叫Insets. 如图所示:
DSC0000.png
2022-1-29 10:10 上传
顶部的状态栏通常被用来展示通知, 设备状态等; 底部导航栏通常显示三个导航按钮: back, home, recent. 它们两个合称为system bars. Android的Insets类描述的是偏移尺寸信息, 确实我们开发中更关注的也就是这些系统UI的尺寸信息. 本文介绍用Compose做UI之后, 借助于Accompanist Insets: https://google.github.io/accompanist/insets/. 几种常见的和Insets相关的情形是如何做的. 内容区域 Going Edge-to-Edge 新创建一个用Compose写的app, 默认是一个没有Inset处理的普通App. 那能不能让app的内容显示在这些system bars区域, 做成edge-to-edge的形式? 当然是可以的. 这里澄清两个概念:
- edge-to-edge: app的内容在system bars后面绘制, system bars仍然以半透明的形式存在.
- 不同于"沉浸式"(immersive mode), 沉浸式需要将system bars隐藏, app内容完全全屏, 多用于看视频, 画画等场景.
内容区域延伸到system bars 内容延伸到status bar和navigation bar区域很容易, 只需要加一行代码:
WindowCompat.setDecorFitsSystemWindows(window, false)
这个值默认是true, 表示默认行为: app的内容会自动找到内嵌区域绘制. 设置为false之后, app的内容就会延伸到system bars下层. 区别见下图: 左边为默认显示, 右边为添加了这个flag为false的设置之后的情况:
DSC0001.png
2022-1-29 10:10 上传
嗯, 内容是绘制出去了, 但是却被遮挡了. 这时候就需要用到systemuicontroller来改颜色: 加上这么几行就可以改自己喜欢的颜色:
val systemUiController = rememberSystemUiController() val useDarkIcons = MaterialTheme.colors.isLight SideEffect { systemUiController.setSystemBarsColor( color = Color.Green.copy(alpha = 0.1f), darkIcons = useDarkIcons ) }
这里改的是system bars, 也即status bar和navigation bar都改了. 也有单独只改一个的方法. 为了demo, 把颜色设置成透明的绿(如左图); 正常应用场景有可能得用Color.Transparent(如右图).
DSC0002.png
2022-1-29 10:10 上传
延伸却内嵌 紧接做了几个页面的UI之后, 发现有的内容遮盖在状态栏和底部, 体验不是很好. 能不能把有文字内容的部分让出来呢? 于是, 添加了这个依赖: Insets for Jetpack Compose 简单两行就把上下的距离留了出来:
ProvideWindowInsets { Sample1(modifier = Modifier.systemBarsPadding()) }
等等, 这么一处理, 如果忽略system bars颜色的设置. 和最开始默认的情形看起来是一模一样. 那么我们是不是可以直接删掉WindowCompat.setDecorFitsSystemWindows(window, false)这行, 用默认设置就好了?
- 是. 如果你的需求真的是这样.
- 不是. 如果你需要把app背景绘制出去; 如果你还有输入框的处理.
如果需求想要的是背景延伸出去, 文字内嵌. 分别给上下两个元素加了不同的padding:
Column( modifier = modifier.fillMaxSize() .background(color = Color.Blue.copy(alpha = 0.3f)), verticalArrangement = Arrangement.SpaceBetween ) { Text( modifier = Modifier.fillMaxWidth() .background(color = Color.Yellow.copy(alpha = 0.5f)) .statusBarsPadding(), text = "Top Text", style = MaterialTheme.typography.h2 ) Text(text = "Content", style = MaterialTheme.typography.h2) Text( modifier = Modifier.fillMaxWidth() .navigationBarsPadding() .background(color = Color.Yellow.copy(alpha = 0.5f)), text = "Bottom Text", style = MaterialTheme.typography.h2 ) }
运行以后如下图中右边所示:
DSC0003.png
2022-1-29 10:10 上传
注意这里modifier的顺序, 上下延伸出去的颜色是不同的, 下面延伸出去的其实是Column的颜色. 左边是把insets padding加在整体布局的情况, 如果用的是system bars的话, 和默认UI效果是一样的. 具体根据需求定制即可. LazyColumn的padding和content padding 有一个非常长的LazyColumn, 在edge-to-edge的设计下应该怎么显示呢? 这里有三种选择:
- List完全全屏: LazyColumn {}
- List留出上下padding: LazyColumn(modifier = Modifier.systemBarsPadding()) {}
- List留出Content padding:
LazyColumn( contentPadding = rememberInsetsPaddingValues( insets = LocalWindowInsets.current.systemBars, applyTop = true, applyBottom = true, ) ) {}
其实1和2的行为非常类似, 只是显示区域大小的区别. content padding只是在第一个item的上面和最后一个item的下面加上padding, 在滚动的中间过程中内容是可以全屏的, 只有到头或者到底了才会显示出padding.
DSC0004.png
2022-1-29 10:10 上传
content padding用动图更能说明情况:
DSC0005.gif
2022-1-29 10:10 上传
内容区域处理总结 Insets这个库提供了这么几个Modifier:
- Modifier.statusBarsPadding()
- Modifier.navigationBarsPadding()
- Modifier.systemBarsPadding()
- Modifier.imePadding()
- Modifier.navigationBarsWithImePadding()
- Modifier.cutoutPadding()
可以直接在布局中用上, 就获取了应该有的padding, 比如statusBarPadding是top, navigationBarsPadding是bottom. 这都不用开发者自己想.
如果这些都不满足你的需求, 也可以直接用尺寸:
- Modifier.statusBarsHeight()
- Modifier.navigationBarsHeight()
- Modifier.navigationBarsWidth()
或者更直接地用LocalWindowInsets.current自己获取想要inset类型的相关尺寸.
输入框元素和键盘 on-screen keyboard, 又叫IME (Input Method Editor), 一般点击输入框会弹出, IME也是一种Inset. 输入框被键盘遮挡问题 当输入框处于屏幕上半屏的时候, 基本不用考虑键盘遮挡的问题. 但是当输入框在屏幕下半屏, 我们需要在键盘弹出来的时候让输入框完全显示出来而不被盖住. 解决这个问题需要这么几个东西:
- Activity的android:windowSoftInputMode="adjustResize", 表示在键盘弹出时, Activity会改变布局大小, 这种改变是挤压型的.
- Modifier.imePadding的使用, 给布局加上一个恰好等于键盘高度的bottom padding. 通常是给输入框的父布局, 加在哪一层视情况而定.
- 如果上面两个都设置了仍然不能把输入框完全显示出来, 可能需要再加入点强力的唤醒行为.
根据这个issue下的这条comment, 可以用这个Modifier, 在这个ui获取到焦点的时候, 自己把自己bring into view.
@ExperimentalComposeUiApi fun Modifier.bringIntoViewAfterImeAnimation(): Modifier = composed { val imeInsets = LocalWindowInsets.current.ime var focusState by remember { mutableStateOf<FocusState?>(null) } val relocationRequester = remember { RelocationRequester() } LaunchedEffect( imeInsets.isVisible, imeInsets.animationInProgress, focusState, relocationRequester ) { if (imeInsets.isVisible && !imeInsets.animationInProgress && focusState?.isFocused == true) { relocationRequester.bringIntoView() } } relocationRequester(relocationRequester) .onFocusChanged { focusState = it } }
这个ReloactionRequest已经deprecated了, Compose新版的叫BringIntoViewRequester. IME padding计算和布置 .imePadding()的值是变化的, 在没有键盘的情况下是0, 等有键盘的时候变为键盘高度. 计算键盘弹出的高度要注意:
- 最简单的情况直接用.imePadding()完事, 布局的bottom padding自动和IME贴合.
- 如果整体已经有了navigation bar的高度, 可以考虑用.navigationBarsWithImePadding(), 它是取IME和navigation bar高度的最大值.
- 如果键盘上方出现了白条, 说明padding算多了, 要么是布局中已经有inner padding, 要么就是已经加过navigationBarsPadding. 这时候可以自己做一个减法处理.
比如这个:
LazyColumn( contentPadding = PaddingValues( bottom = with(LocalDensity.current) { LocalWindowInsets.current.ime.bottom.toDp() - innerPadding.bottom }.coerceAtLeast(0.dp) ) ) { /* ... */ }
.imePadding放在哪里, 关系到什么样的区域会被显示出来, 被包裹的区域会显示在键盘上方. 来举个例子, 有个带输入框的界面. 我们给它整体设置一个.navigationBarsWithImePadding(), 表示没键盘的时候, 底部留navigation bar的高度, 有键盘的时候留键盘的高度:
Column( modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsWithImePadding() .background(color = Color.Cyan.copy(alpha = 0.2f)), verticalArrangement = Arrangement.SpaceBetween ) { Text( modifier = Modifier.fillMaxWidth() .background(color = Color.Yellow.copy(alpha = 0.5f)), text = "Top Text", style = MaterialTheme.typography.h2 ) Text(text = "Content", style = MaterialTheme.typography.h2) MyTextField("Text Field 1") MyTextField("Text Field 2") Text( modifier = Modifier.fillMaxWidth() .background(color = Color.Yellow.copy(alpha = 0.5f)), text = "Bottom Text", style = MaterialTheme.typography.h2 ) }
键盘弹出时, Bottom Text也会被顶上去, 这是因为imePadding作用于整块的布局. 如果我们这样改, 只包裹输入框的部分, 那么键盘就不会把底部的UI顶上去:
Column( modifier = Modifier.fillMaxSize().statusBarsPadding() .background(color = Color.Cyan.copy(alpha = 0.2f)), verticalArrangement = Arrangement.SpaceBetween ) { Text( modifier = Modifier.fillMaxWidth() .background(color = Color.Yellow.copy(alpha = 0.5f)), text = "Top Text", style = MaterialTheme.typography.h2 ) Text(text = "Content", style = MaterialTheme.typography.h2) Text( modifier = Modifier.fillMaxWidth() .background(color = Color.Yellow.copy(alpha = 0.5f)), text = "Bottom Text", style = MaterialTheme.typography.h2 ) }
两种效果见图:
DSC0006.png
2022-1-29 10:10 上传
键盘部分总结延伸 总结: 输入框键盘的处理包括了:
- adjustResize.
- 设置合理的bottom padding: 在哪里设置, 需要设置多少.
- 让View主动bring自己到可见位置.
Insets库里还提供了键盘随着滚动消失和出现的例子. 感兴趣可以看下. accompanist insets使用总结 accompanist insets库帮我们做了两部分内容:
- 获取各种insets信息然后用CompositionLocalProvider提供.
- Provider内部, 通过Modifier获取直接可用的modifier或者尺寸, 也可以直接获取.
但是这个库用起来也有一些需要注意的地方, 比如:
- 如果忘记设置WindowCompat.setDecorFitsSystemWindows(window, false), 得到的值都是0.
- ProvideWindowInsets的参数: consumeWindowInsets这个值默认是true, 建议设置为false, 方便内层的ui继续用这些inset的值.
@Composable fun ProvideWindowInsets( consumeWindowInsets: Boolean = true, windowInsetsAnimationsEnabled: Boolean = true, content: @Composable () -> Unit )
- 如果在布局中嵌套使用ProvideWindowInsets, 可能就无法按照预期工作, (不知道是不是暂时性的issue).
References
- Lay out your app within window insets
- Accompanist Insets: https://google.github.io/accompanist/insets/
- Sample: https://github.com/google/accompanist/tree/main/sample/src/main/java/com/google/accompanist/sample/insets
- Video: Animating your keyboard using WindowInsets
|