草庐IT

Android Compose对Window Insets的处理

圣骑士Wind的博客 2023-03-28 原文

Android Compose的Window Insets

除了app的内容区域外, 还有一些其他的固定元素会显示在手机屏幕上, 顶部的状态栏, 刘海, 底部的导航栏, 还有输入法键盘,
它们都是系统的UI, 也叫Insets.

如图所示:

顶部的状态栏通常被用来展示通知, 设备状态等;
底部导航栏通常显示三个导航按钮: 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的设置之后的情况:

嗯, 内容是绘制出去了, 但是却被遮挡了.

这时候就需要用到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(如右图).

延伸却内嵌

紧接做了几个页面的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
    )
}

运行以后如下图中右边所示:

注意这里modifier的顺序, 上下延伸出去的颜色是不同的, 下面延伸出去的其实是Column的颜色.

左边是把insets padding加在整体布局的情况, 如果用的是system bars的话, 和默认UI效果是一样的.

具体根据需求定制即可.

LazyColumn的padding和content padding

有一个非常长的LazyColumn, 在edge-to-edge的设计下应该怎么显示呢?
这里有三种选择:

  1. List完全全屏: LazyColumn {}
  2. List留出上下padding: LazyColumn(modifier = Modifier.systemBarsPadding()) {}
  3. List留出Content padding:
LazyColumn(
    contentPadding = rememberInsetsPaddingValues(
        insets = LocalWindowInsets.current.systemBars,
        applyTop = true,
        applyBottom = true,
    )
) {}

其实1和2的行为非常类似, 只是显示区域大小的区别.
content padding只是在第一个item的上面和最后一个item的下面加上padding,
在滚动的中间过程中内容是可以全屏的, 只有到头或者到底了才会显示出padding.

content padding用动图更能说明情况:

内容区域处理总结

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
    )
}

两种效果见图:

键盘部分总结延伸

总结: 输入框键盘的处理包括了:

  • 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

有关Android Compose对Window Insets的处理的更多相关文章

  1. ruby - 如何指定 Rack 处理程序 - 2

    Rackup通过Rack的默认处理程序成功运行任何Rack应用程序。例如:classRackAppdefcall(environment)['200',{'Content-Type'=>'text/html'},["Helloworld"]]endendrunRackApp.new但是当最后一行更改为使用Rack的内置CGI处理程序时,rackup给出“NoMethodErrorat/undefinedmethod`call'fornil:NilClass”:Rack::Handler::CGI.runRackApp.newRack的其他内置处理程序也提出了同样的反对意见。例如Rack

  2. Ruby-vips 图像处理库。有什么好的使用示例吗? - 2

    我对图像处理完全陌生。我对JPEG内部是什么以及它是如何工作一无所知。我想知道,是否可以在某处找到执行以下简单操作的ruby​​代码:打开jpeg文件。遍历每个像素并将其颜色设置为fx绿色。将结果写入另一个文件。我对如何使用ruby​​-vips库实现这一点特别感兴趣https://github.com/ender672/ruby-vips我的目标-学习如何使用ruby​​-vips执行基本的图像处理操作(Gamma校正、亮度、色调……)任何指向比“helloworld”更复杂的工作示例的链接——比如ruby​​-vips的github页面上的链接,我们将不胜感激!如果有ruby​​-

  3. ruby - Faye WebSocket,关闭处理程序被触发后重新连接到套接字 - 2

    我有一个super简单的脚本,它几乎包含了FayeWebSocketGitHub页面上用于处理关闭连接的内容:ws=Faye::WebSocket::Client.new(url,nil,:headers=>headers)ws.on:opendo|event|p[:open]#sendpingcommand#sendtestcommand#ws.send({command:'test'}.to_json)endws.on:messagedo|event|#hereistheentrypointfordatacomingfromtheserver.pJSON.parse(event.d

  4. ruby - 如何使用 Ruby HTTP::Net 处理 404 错误? - 2

    我正在尝试解析网页,但有时会收到404错误。这是我用来获取网页的代码:result=Net::HTTP::getURI.parse(URI.escape(url))如何测试result是否为404错误代码? 最佳答案 像这样重写你的代码:uri=URI.parse(url)result=Net::HTTP.start(uri.host,uri.port){|http|http.get(uri.path)}putsresult.codeputsresult.body这将打印状态码和正文。

  5. ruby-on-rails - 使用 Ruby 正确处理 Stripe 错误和异常以实现一次性收费 - 2

    我查看了Stripedocumentationonerrors,但我仍然无法正确处理/重定向这些错误。基本上无论发生什么,我都希望他们返回到edit操作(通过edit_profile_path)并向他们显示一条消息(无论成功与否)。我在edit操作上有一个表单,它可以POST到update操作。使用有效的信用卡可以正常工作(费用在Stripe仪表板中)。我正在使用Stripe.js。classExtrasController5000,#amountincents:currency=>"usd",:card=>token,:description=>current_user.email)

  6. ruby-on-rails - Rails 处理 .Erb 与 Nils - 2

    当profile为nil时,总是让我感到悲伤...我该怎么办? 最佳答案 在View中使用变量之前,始终检查变量是否为nil。我确信这个问题有更优雅的解决方案,但这应该能让您入门。 关于ruby-on-rails-Rails处理.Erb与Nils,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.com/questions/2709605/

  7. ruby-on-rails - 如何在多个环境中处理 OmniAuth 回调? - 2

    我有一个应用程序专门使用Facebook作为身份验证提供程序,并正确设置了生产模式的回调。为了让它工作,您需要为您的Facebook应用程序提供一个站点URL和一个用于回调的站点域,在我的例子中是http://appname.heroku.com和appname。heroku.com分别。问题是我的Controller设置为只允许经过身份验证的session,所以我无法在开发模式下查看我的应用程序,因为Facebook应用程序的域显然没有设置为本地主机。如何在不更改Facebook设置的情况下解决这个问题? 最佳答案 创建另一个域l

  8. python - 请在 Perl 或 Ruby 中引入多处理库 - 2

    在python中,我们可以使用多处理模块。如果Perl和Ruby中有类似的库,你会教它吗?如果您能附上一个简短的示例,我将不胜感激。 最佳答案 ruby:WorkingwithmultipleprocessesinRubyConcurrencyisaMythinRubyPerl:HarnessingthepowerofmulticoreWhyPerlIsaGreatLanguageforConcurrentProgramming此外,Perl的线程是native操作系统线程,因此您可以使用它们来利用多核。

  9. ruby - 现代计算机的功能是否不足以处理字符串而无需使用符号(在 Ruby 中) - 2

    我读过的关于Ruby符号的每一篇文章都在谈论符号相对于字符串的效率。但是,这不是1970年代。我的电脑可以处理一些额外的垃圾收集。我错了吗?我拥有最新最好的奔腾双核处理器和4GBRAM。我认为这应该足以处理一些字符串。 最佳答案 您的计算机可能能够处理“一点点额外的垃圾收集”,但是当“一点点”发生在运行数百万次的内部循环中时呢?如果它在内存有限的嵌入式系统上运行呢?有很多地方你可以随意使用字符串,但在某些地方你不能。这完全取决于上下文。 关于ruby-现代计算机的功能是否不足以处理字符串

  10. ruby-on-rails - Rspec - Controller 测试错误 - Paperclip::AdapterRegistry::NoHandlerError: 找不到 "#<File:0x531beb0>"的处理程序 - 2

    我如下询问了我的Rspec测试。Rspec-RuntimeError:Calledidfornil,whichwouldmistakenlybe4在相同的代码上(“items_controller.rb”的Rspec测试),我试图对“PUTupdate”进行测试。但是我收到错误消息“Paperclip::AdapterRegistry::NoHandlerError:找不到“#”的处理程序。我的Rspec测试如下。老实说,我猜这次失败的原因是“let(:valid_attributes)”上的“photo”=>File.new(Rails.root+'app/assets/images

随机推荐