Android插图:处理恐惧并为Android Q做准备

Android Q是具有API级别29的Android的第十版。新版本的主要思想之一是边缘到边缘的概念,当应用程序从底部到顶部占据整个屏幕时。这意味着状态栏和导航栏必须是透明的。但是,如果它们是透明的,则没有系统UI-它与应用程序的交互式组件重叠。这个问题可以用插图解决。

移动开发人员避免插入,会引起恐惧。但是在Android Q中,无法避免插入-您必须研究并应用它们。实际上,插图没有什么复杂的:它们显示哪些屏幕元素与系统界面相交,并建议如何移动该元素,以免与系统UI冲突。康斯坦丁(Konstantin Tskhovrebov)将讲解插图如何工作以及如何使用


康斯坦丁Konstantin Tskhovrebov)特拉科克)在Redmadrobot中工作从事Android已有10年了,并且在各种项目中积累了很多经验,在这些项目中没有插件,他们总是设法以某种方式解决问题。康斯坦丁(Konstantin)将讲述避免插页问题的悠久历史,以及研究和对抗Android。他将从他的经验中考虑可以应用插图的典型任务,并展示如何避免害怕键盘,识别键盘的大小并响应外观。

注意。该文章基于Konstantin在Saint AppsConf 2019上的一份报告撰写该报告使用了有关插图的几篇文章的材料。最后链接到这些材料。

典型任务


颜色状态栏。在许多项目中,设计人员绘制一个颜色状态栏。当Android 5与新的Material Design一同出现时,它变得时尚起来。



如何绘制状态栏?基本-添加colorPrimaryDark并替换颜色。

 <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    ...
    <item name="colorPrimaryDark">@color/colorAccent</item>
    ...
</style>

对于高于第五版本的Android(21及更高版本的API),您可以在主题中设置特殊参数:

<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    ...
    <item name="android:statusBarColor">@color/colorAccent</item>
    ...
</style>

彩色状态栏有时,设计师在不同的屏幕上以不同的颜色绘制状态栏。



没关系,最简单的方法是在不同的活动中使用不同的主题

更有趣的方法是直接在运行时更改颜色

override fun onCreateView(...): View {
    requireActivity().window.statusBarColor = requireContext().getColor(R.color.colorPrimary)
    ...
}

该参数通过特殊标志更改。但是最主要的是不要忘记在用户退出屏幕时将颜色改回原来的颜色。

透明状态栏。这比较难。透明度通常与地图相关联,因为在地图上可以最清楚地看到透明度。在这种情况下,像以前一样,我们设置一个特殊参数:

<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">    
    ...
    <item name="android:windowTranslucentStatus">true</item>
    ...
</style>

当然,这里有一个众所周知的技巧-缩进更高,否则状态栏将叠加在图标上,并且很难看。


但是在其他屏幕上,一切都中断了。



如何解决问题?我想到的第一种方法是不同的活动:我们有不同的主题,不同的参数,它们的工作方式也不同。

使用键盘。我们不仅避免在状态栏上插入,而且在使用键盘时也避免插入。



没有人喜欢左侧的选项,但要将其变成右侧的选项,有一个简单的解决方案。

<activity
    ...
    android:windowSoftInputMode="adjustResize">
    ...
</activity>

现在,可以在出现键盘时调整活动的大小。它的工作原理简单而严格。但是不要忘记另一个窍门-把一切都包装进去ScrollView。突然,键盘将占据整个屏幕,并且顶部会出现一个小条?

当我们想在键盘出现时更改布局时,有时会更加困难。例如,否则安排按钮或隐藏徽标。



我们使用键盘和透明的状态栏训练了许多拐杖,现在很难阻止我们。转到StackOverflow并复制漂亮的代码。

boolean isOpened = false;

public void setListenerToRootView() {
    final View activityRootView = getWindow().getDecorView().findViewById(android.R.id.content);
    activityRootView.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
        @Override
        public void onGlobalLayout() {
    
            int heightDiff = activityRootView.getRootView().getHeight() - activityRootView.getHeight();      
            if (heightDiff > 100) { // 99% of the time the height diff will be due to a keyboard.       
                Toast.makeText(getApplicationContext(), "Gotcha!!! softKeyboardup", 0).show();

                if (isOpened == false) {
                   //Do two things, make the view top visible and the editText smaller
                }
                isOpened = true;
            } else if (isOpened == true) {
                Toast.makeText(getApplicationContext(), "softkeyborad Down!!!", 0).show();
                isOpened = false;
            }    
        }
    });
}

它甚至计算出键盘出现的可能性。该代码在我们甚至使用过的其中一个项目中起作用,但是时间很长。

示例中的许多拐杖与使用不同的活动有关。但是,它们的坏处不仅是拐杖,还有其他原因:“冷启动”,异步问题。我在“ 驾驶执照或为什么应用程序应该是单项活动文中更详细地描述了这些问题另外,Google的文档表明,推荐的方法是“单一活动”应用程序。

Android 10之前我们做了什么


我们(在Redmadrobot处)正在开发相当高质量的应用程序,但长期以来避免了插入。我们如何在没有大拐杖的情况下一次活动来避免它们?

注意。屏幕截图和代码取自我的宠物项目GitFox

想象一下应用程序屏幕。在开发应用程序时,我们从未想到下面会出现一个透明的导航栏。下面是否有黑条?那么,用户已经习惯了。



在顶部,我们最初将状态栏设置为黑色且透明的参数。它在布局和代码方面如何看待?



图中的抽象:红色块是应用程序的活动,蓝色是带有导航bot(带有Tabs)的片段,在其中有内容的绿色片段被切换了。可以看到工具栏不在状态栏下方。我们是如何实现的?

Android有一个棘手的标志fitSystemWindow。如果将其设置为true,则容器将为其自身添加填充,以使其中的任何内容都不会落在状态栏下。我相信这个标志是Google对那些担心插曲的人的官方拐杖。使用所有内容都将相对良好地工作,而不会产生插图。

该标志FitSystemWindow=”true”将填充添加到您指定的容器。但是层次结构很重要:如果父项之一将此标志设置为“ true”,则将不考虑其分布,因为容器已经应用了缩进。

该标志有效,但是出现另一个问题。想象一个带有两个标签的屏幕。切换时,将启动一个事务,这会导致一个replace片段彼此残缺,并且一切都会中断。



第二个片段也设置了一个标志FitSystemWindow=”true”,这不会发生。但是发生了什么,为什么呢?答案是,这是拐杖,有时不起作用。

但是我们找到了一个堆栈溢出的解决方案:使用root而不是片段FrameLayout,而是CoordinatorLayout它是为其他目的而创建的,但是可以在这里使用。

为什么行得通?


让我们从源代码中了解发生了什么CoordinatorLayout

@Override
public void onAttachedToWindow() {   
    super.onAttachedToWindow();
    ...    
    if (mLastInsets == null && ViewCompat.getFitsSystemWindows(this)) {         
        // We're set to fitSystemWindows but we haven't had any insets yet...
        // We should request a new dispatch of window insets
        ViewCompat.requestApplyInsets(this);
    }     
    ...
}

我们看到一个美丽的注释,这里需要插入,但不是必需的。当我们请求窗口时,我们需要重新询问它们。我们发现插图可以在内部工作,但我们不想与它们一起工作并离开Coordinator

在2019年春季,我们开发了该应用程序,当时Google I / O刚刚通过。我们还没有弄清一切,因此我们继续坚持偏见。但是我们注意到切换底部的Tabs有点慢,因为我们有一个复杂的布局,加载了UI。我们找到一种解决此问题的简单方法-更改replace为,show/hide这样您就不必每次都重新创建布局。



我们改变了,再次没有任何作用-一切都坏了!显然不可能分散拐杖,您需要了解拐杖的工作原理。我们研究了代码,结果发现任何ViewGroup都可以使用inset。


@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {    
    insets = super.dispatchApplyWindowInsets(insets);     
    if (!insets.isConsumed()) {         
        final int count = getChildCount();        
        for (int i = 0; i < count; i++) {
            insets = getChildAt(i).dispatchApplyWindowInsets(insets);             
            if (insets.isConsumed()) {          
                break;
            }
        }
    }
    return insets;
}

内部有这样一种逻辑:如果至少有人已经处理过插图,则ViewGroup中的所有后续View将不会收到它们。这对我们意味着什么?让我向您展示一个用于切换Tabs的FrameLayout的示例。



里面有第一个设置了标志的片段fitSystemWindow=”true”这意味着第一个片段处理插图。之后,我们将HIDE第一个片段称为SHOW第二个片段但是第一个仍然保留在布局中-他的“视图”保留了下来,只是隐藏了。

容器通过其视图:获取第一个片段,为其提供插图,然后从中获取fitSystemWindow=”true”-对其进行处理。完美的是,FrameLayout看起来已经处理了插图,并且没有将其分配给第二个片段。一切正常。但这不适合我们,我们该怎么办?

我们编写自己的ViewGroup


我们来自Java,而OOP具有所有荣耀,因此我们决定继承。我们编写了自己的ViewGroup,从中我们重新定义了方法dispatchApplyWindowInsets它按照我们需要的方式工作:它总是将子集返回给孩子,而不管我们是否对其进行处理。

class WindowInsetFrameLayout @JvmOverloads constructor(
    context: Context,      
    attrs: AttributeSet? = null,     
    defStyleRes: Int = 0
) : FrameLayout(context, attrs, defStyleRes) {

    override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { 
        for (child in (0 until childCount)) {
            getChildAt(child).dispatchApplyWindowInsets(insets)
        }
        return insets.consumeSystemWindowInsets()
    }
}

自定义ViewGroup可以工作,项目也是我们需要的一切。但是他仍然没有达成一个普遍的解决方案。当我们弄清楚他们在Google IO上告诉我们的内容时,我们意识到我们将不再能够那样做。

Android 10


Android 10向我们展示了两个严格建议遵循的重要UI概念:边缘到边缘和手势导航。

边缘到边缘。这个概念表明应用程序的内容应占据屏幕上所有可能的空间。对于我们(作为开发人员)来说,这意味着应将应用程序放置在状态栏和导航栏系统面板下。



以前,我们可以以某种方式忽略它,或者仅将其置于状态栏下方。

至于列表,它们不仅应滚动到最后一个元素,而且还应滚动到其他位置,以免保留在导航栏下方。


手势导航。第二个重要概念是手势导航它使您可以从屏幕边缘用手指控制应用程序。手势模式是非标准的,一切看起来都不同,但是现在您无法在两个不同的导航栏之间切换。

此刻,我们意识到一切都不是那么简单。如果我们要开发高质量的应用程序,将不可能进一步避免插入。是时候研究文档并了解插图了。

插图 您需要了解什么?


创建插图是为了吓developers开发人员。
自从它出现在Android 5中以来,他们就一直在完美地完成此工作

。当然,一切还不是那么可怕。插入的概念很简单- 它们在应用程序屏幕上报告系统UI覆盖。它可以是导航栏,状态栏或键盘。键盘也是常规系统UI,它叠加在应用程序的顶部。不必尝试只用插图的拐杖进行处理。

插图的目的是解决冲突。例如,如果按钮上方还有另一个元素,我们可以移动按钮,以便用户可以继续使用该应用程序。

为了处理插图,使用WindowlnsetsAndroid的10和WindowInsetsCompat其他版本。

Android 10中有5种不同类型的插图一个“红利”,不是内插图,否则称为。我们将处理所有类型,因为大多数人只知道一件事-系统窗口插入。

系统窗口插图


在Android 5.0(API 21)中引入。通过方法获得getSystemWindowInsets()

这是插图的主要类型,您需要学习如何使用它们,因为其余部分的工作方式相同。首先需要它们来处理状态栏,然后是导航栏和键盘。例如,当导航栏位于应用程序上方时,它们可以解决问题。如图所示:该按钮保留在导航栏下方,用户无法单击它,并且非常不满意。



可贴图元素插图


仅出现在Android 10中getTappableElementInsets()

这些插图仅对处理各种导航栏模式有用。就像克里斯·贝恩Chris Bane)自己说的那样,您可以忘记这种类型的插入,而只绕开系统窗口插入。但是,如果您希望应用程序是100%凉爽的,而不是99.9%凉爽的,则应该使用它。

看图片。



顶部的深红色表示系统窗口插入,将以导航栏的不同模式显示。可以看出,它们在右侧和左侧等于导航栏。

回想一下手势导航的工作原理:在正确的模式下,我们从不单击新面板,而总是将手指从下往上拖动。这意味着无法删除按钮。我们可以继续按下FAB(浮动操作按钮)按钮,没有人会干涉。因此,TappableElementInsets由于不需要移动FAB ,它将变空。但是,如果我们再高一点,那没关系。

区别仅在手势导航和透明导航栏(颜色自适应)中出现。这不是一个非常令人愉快的情况。



一切正常,但看起来不愉快。元素的接近可能会使用户感到困惑。可以解释的是,一个用于手势,另一个用于按压,但仍然不美观。因此,可以将FAB调高些或将其放在右边。

系统手势插入


仅出现在Android 10中getSystemGestureInsets()

这些插图与手势导航有关。最初,假定系统UI是在应用程序的顶部绘制的,但是系统手势插图表示不是绘制UI,而是由系统本身来处理手势。它们描述了系统默认情况下将在哪里处理手势。

这些插图的区域大约是图中黄色标记的区域。



但是我要警告您-他们不会一直这样。我们永远不会知道三星和其他制造商会推出什么样的新屏幕。已经有屏幕四面八方的设备。也许插图根本不会是我们期望的。因此,您需要像使用某些抽象一样使用它们:系统手势插入中有这样的插入,系统本身在其中处理手势。

这些插图可以被覆盖。例如,您正在开发照片编辑器。在某些地方,即使手势在屏幕边缘附近,您自己也要处理手势。指示系统您将自己处理照片一角在屏幕上的点。可以通过告诉系统重新定义它:“我将始终自己处理该点周围的正方形。”

强制系统手势插入


仅在Android 10中出现。这是系统手势插入的子类型,但不能被应用程序覆盖。

我们使用该方法getMandatorySystemGestureInsets()来确定它们将无法工作的区域。这样做是有意进行的,因此不可能开发“无希望”应用程序:从下至上重新定义导航手势,这使您可以将应用程序退出到主屏幕。在这里,系统始终处理手势本身


不一定会在设备底部,也不一定要在此尺寸下。作为抽象使用它。

这些是相对较新的插图。但是有些甚至在Android 5之前就出现了,它们的名称有所不同。

稳定的插图


与Android 5.0(API 21)一起引入。通过方法获得getStableInsets()

即使是经验丰富的开发人员也不能总是说出为什么需要它们。我将告诉您一个秘密:这些插图仅对全屏应用程序有用:视频播放器,游戏。例如,在播放模式下,任何系统UI都被隐藏,包括状态栏,该状态栏移到屏幕边缘之外。但是值得触摸屏幕,因为状态栏将显示在顶部。

如果您在屏幕顶部放置了任何“返回”按钮,而它正确处理了系统窗口插入,则对于每个选项卡,一个UI都将出现在屏幕顶部,并且该按钮会向下跳。如果您有一阵子不触摸屏幕,状态栏将消失,该按钮将弹起,因为“系统窗口插入”已消失。

对于这种情况,稳定的插入是正确的。他们说,现在您的应用程序上没有绘制任何元素,但是可以做到。通过这些插图,您可以预先知道例如在此模式下状态栏的值,并将按钮放置在所需的位置。

注意。该方法getStableInsets()仅在API 21中出现。以前,屏幕的每一侧都有几种方法。 

我们检查了5种插图,但还有另外一种不适用于直接插图。它有助于应对刘海和缺口。

刘海和领口


屏幕不再是正方形的。它们是长方形的,在相机上有一个或多个切口,在各个侧面都有刘海。



我们最多无法处理28个API。我不得不通过插图来猜测那里发生了什么。但是,随着28 API及更高版本(来自先前的Android),该类正式出现DisplayCutout它具有相同的插图,您可以从中获得所有其他类型。该类使您可以找到工件的位置和大小。

除了位置信息之外,还为开发人员提供了一组y标志WindowManager.LayoutParams它们使您可以在切口周围包含不同的行为。您的内容可能会显示在它们周围,或者可能不会显示:以横向和纵向模式以不同的方式显示。

标志window.attributes.layoutInDisplayCutoutMode =

  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT默认情况下,在肖像中-有风景;在风景中-有黑条。
  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER完全没有-黑色条纹。
  • LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES -永远是。

可以控制显示,但是我们使用它们的方式与处理其他插图相同。

insets.displayCutout
    .boundingRects
    .forEach { rect -> ... }

带有数组插入的回调来了displayCutout然后,我们可以运行它,处理应用程序中的所有切口和爆炸。您可以在本文中了解有关如何使用它们的更多信息

了解了6种插图。现在让我们谈谈它是如何工作的。

怎么运行的


首先,当在应用程序顶部绘制某些内容时,例如导航栏,需要插入。



没有插图,应用程序将没有透明的导航栏和状态栏。别为您不来而感到惊讶SystemWindowInsets,这会发生。

价差


整个UI层次结构看起来像一棵树,树的末端是View。节点通常是一个ViewGroup。它们也从View继承,因此插图以特殊的方式出现在它们身上dispatchApplyWindowInsets从根视图开始,系统会在整个树中发送插图。让我们看看在这种情况下View是如何工作的。



系统在其上调用dispatchApplyWindowInsets插入方法返回此视图应返回某些内容。如何处理此行为?

为了简单起见,我们仅考虑WindowInsets。

处理插图


首先,dispatchApplyWindowInsets在其自己的逻辑内部重新定义方法和实现似乎是合乎逻辑的:插入后,我们在内部实现了所有内容。

如果打开View类并查看在此方法顶部编写的Javadoc,您将看到:“请勿覆盖此方法!”
我们通过委派或继承处理Inset。
使用委托Google提供了暴露自己的代表的机会,该代表负责处理插图。您可以通过方法安装它setOnApplyWindowInsetsListener(listener)我们设置了一个处理这些插图的回调。

如果由于某种原因这不适合我们,则可以从View继承并重写另一个方法 onApplyWindowInsets(WindowInsets insets)我们用自己的插图代替它。

Google不在委托和继承之间进行选择,而是允许一起做所有事情。这很棒,因为我们不必重新定义所有工具栏或ListView即可处理插图。我们可以使用任何现成的View,甚至可以是库View,然后在其中设置委托,该委托将处理inset而无需重新定义。

我们将返回什么?

为什么要退货?


为此,您需要了解插图的分布方式,即ViewGroup。她在自己里面做什么。让我们使用前面显示的示例仔细研究默认行为。

系统插图位于顶部和底部,例如每个100像素。ViewGroup将它们发送到它拥有的第一个View。此视图以某种方式处理了它们并返回insets:在顶部,它减少到0,但在底部,它离开。在顶部,她添加了填充或边距,但在底部,她没有触摸并报告了此情况。接下来,ViewGroup将插图传递给第二个View。



下一个View处理并渲染插图。现在,ViewGroup可以看到在上方和下方都处理了插入物-剩下的都没有,所有参数均为零。

第三个视图甚至都不知道有一些插图并且正在发生某些事情ViewGroup会将插图返回给暴露它们的那个。

当我们开始处理该主题时,我们为自己写下了这个想法-总是返回相同的插图。我们希望避免某些View甚至不知道有插图的情况。这个想法看起来合乎逻辑。但是事实证明没有。Google并没有像在示例中那样为插图添加行为。

这是必需的,以便插图始终可以到达视图的整个层次结构,并且您可以始终使用它们。在这种情况下,当我们切换两个片段,一个已经处理,第二个还没有收到时,将不会出现任何情况。在练习部分,我们将回到这一点。

实践


理论完成了。让我们看看它在代码中的外观,因为从理论上讲,一切总是很顺利的。我们将使用AndroidX。

注意。GitFox中,代码中已经实现了所有功能,完全支持新Android的所有功能。为了不检查Android版本并且不搜索所需的Insets类型,请始终使用view.setOnApplyWindowInsetsListener { v, insets -> ... }AndroidX中版本- 代替ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> ... }

屏幕上有一个深色的导航栏。它不与任何元素重叠。一切都是我们习惯的方式。


但是事实证明,现在我们需要使其透明。怎么样?

开启透明度


最简单的事情是在主题中指示状态栏和导航栏是透明的。

<style name="AppTheme" parent="Theme.MaterialComponents.Light">
    <item name="android:windowTranslucentStatus">true</item>
    <item name="android:windowTranslucentNavigation">true</item>
</style>

此时,一切都从上方和下方开始相互重叠。

有趣的是,有两个标志,并且总是有三个选项:如果您为导航栏指定透明性,则状态栏本身将变为透明-这样的限制。您不能写第一行,但我始终喜欢清晰,因此我写了两行,以便追随者可以了解正在发生的事情,而无需深入了解。

如果选择此方法,则导航栏和状态栏将变为透明的黑色。如果应用程序是白色的,则只能从代码中添加颜色。怎么做?

用颜色打开透明度


最好有一个“单一活动”应用程序,所以我们在一个活动中放置两个标志。

rootView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or  
    View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

<style name="AppTheme" parent="Theme.MaterialComponents.Light">
    <!-- Set the navigation bar to 50% translucent white -->
    <item name="android:navigationBarColor">#80FFFFFF</item> 
</style>

那里有很多标志,但正是这两个标志有助于在导航栏和状态栏中保持透明。可以通过主题指定颜色。

这是Android的奇怪行为:通过主题进行操作,通过标志进行操作。但是我们可以在代码和主题中指定参数。这在Android上还不错,只是在较旧版本的Android上,该主题中指定的标志将被忽略。navigationBarColor它会突出显示在Android 5上不是这样,但是所有内容都会融合在一起。在GitFox中,正是我从代码中设置了导航栏的颜色。

我们进行了欺诈-表示导航栏为白色且透明。现在应用程序看起来像这样。


有什么比处理插图更容易的呢?


的确是初级。

ViewCompat
    .setOnApplyWindowInsetsListener(bottomNav) { view, insets ->  
        view.updatePadding(bottom = insets.systemWindowInsetBottom) 
        insets
    }

我们采用该方法setOnApplyWindowInsetsListener并将其传递给它bottomNav我们说当插图出现时,请安装进来的底部衬垫systemWindowInsetBottom我们希望它在下面,但是所有可点击的元素都在最上面。我们返回完整的插图,以便层次结构中的所有其他View也可以接收它们。

一切看起来都很棒。


但是有一个问题!如果在布局中我们在下面的导航栏上指示了某种填充,则此处updatePadding已将其删除-将插图设置为相等。我们的布局看起来并不理想。

要从布局保存值,必须首先保存下部填充中的内容。稍后,当插图到达时,添加并设置结果值。

val bottomNavBottomPadding = bottomNav.paddingBottom
ViewCompat
    .setOnApplyWindowInsetsListener(bottomNav) { view, insets ->                 
        view.updatePadding(
        bottom = bottomNavBottomPadding + insets.systemWindowInsetBottom
    )
    Insets
}

这样写起来很不方便:在代码中所有使用inset的地方,都需要保存布局中的值,然后将其添加到UI中。但是这里有Kotlin,这很棒-您可以编写自己的扩展程序,它将为我们完成所有这些工作。

添加科特林!


我们会记住,initialPadding并在出现新的插图时(连同它们一起)将其交给处理程序。这将帮助他们以某种方式加在一起或从上面构建某种逻辑。

fun View.doOnApplyWindowInsets(block: (View, WindowInsetsCompat, Rect) -> WindowInsetsCompat) {    

    val initialPadding = recordInitialPaddingForView(this)

    ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->        
        block(v, insets, initialPadding)
    }
    
    requestApplyInsetsWhenAttached()
}

private fun recordInitialPaddingForView(view: View) =
   Rect(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom)

  .

bottomNav.doOnApplyWindowInsets { view, insets, padding ->   
    view.updatePadding(
        bottom = padding.bottom + insets.systemWindowInsetBottom
    ) 
    insets
}

我们必须重新定义lambda。它不仅有插图,而且还有填充物。如果它是处理状态栏的工具栏,我们不仅可以从下方添加它们,还可以从上方添加它们。

遗忘的东西!


调用了一个无法理解的方法。

fun View.doOnApplyWindowInsets(block: (View, WindowInsetsCompat, Rect) -> WindowInsetsCompat) {

    val initialPadding = recordInitialPaddingForView(this)
     
    ViewCompat.setOnApplyWindowInsetsListener(this) { v, insets ->        
        block(v, insets, initialPadding)
    }
      
    requestApplyInsetsWhenAttached() 
}

private fun recordInitialPaddingForView(view: View) =
      Rect(view.paddingLeft, view.paddingTop, view.paddingRight, view.paddingBottom)

这是由于您不能仅仅希望系统会向您发送插图。如果我们通过代码创建一个View或InsetsListener稍后安装,则系统本身可能不会传递最后一个值。

我们必须检查屏幕上是否显示了“视图”,然后告诉系统:“插图已到达,我们要对其进行处理。” 我们设置了监听器并且必须发出请求requestsApplyInsets。“

fun View.requestApplyInsetsWhenAttached() {    
    if (isAttachedToWindow) {        
        requestApplyInsets()
    } else {
        addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener {            
            override fun onViewAttachedToWindow(v: View) {
                v.removeOnAttachStateChangeListener(this)
                v.requestApplyInsets()           
            }

            override fun onViewDetachedFromWindow(v: View) = Unit
        })
    }
}

如果我们创建了View代码,但尚未将其附加到布局中,那么我们必须订阅执行该代码的时间。只有这样,我们才能请求插图。

但是话又说回来,这里有Kotlin:他们写了一个简单的扩展名,我们不必再考虑了。

完善RecyclerView


现在让我们谈谈完成RecyclerView的过程。最后一个元素位于导航栏下方,而在下方-丑陋。单击它也很不方便。如果这不是一个新面板,而是一个旧面板,一个大面板,则通常整个元素可能会在其下方消失。该怎么办?



第一个想法是在下面添加一个元素,并设置高度以适合插图。但是,如果我们有一个包含数百个列表的应用程序,那么我们将不得不以某种方式将每个列表预订为inset,在其中添加新元素并设置高度。此外,RecyclerView是异步的,尚不清楚何时可以使用。有很多问题。

但是,还有另一种官方破解方法。令人惊讶的是,它有效地工作。

<androidx.recyclerview.widget.RecyclerView 
        android:id="@+id/recyclerView"        
    android:layout_width="match_parent"    
    android:layout_height="match_parent"             
    android:clipToPadding="false" />

recyclerView.doOnApplyWindowInsets { view, insets, padding ->    
    view.updatePadding(
        bottom = padding.bottom + insets.systemWindowInsetBottom
    )
}

布局中有这样的标记clipToPadding。默认情况下,它始终为true。这表明您不需要绘制出现填充的地方出现的元素。但是,如果设置了clipToPadding="false",则可以绘制。

现在,如果您在底部设置了填充,则在RecyclerView中它将像这样工作:在底部,设置了填充,并在其顶部绘制了元素,直到我们滚动。一旦到达末尾,RecyclerView会将元素滚动到我们需要的位置。

通过设置一个这样的标志,我们可以像普通View一样使用RecyclerView。不要以为有元素可以滚动-只需在下面设置填充,例如在底部导航栏中。

现在,我们总是返回插入项,就好像未处理它们一样。整个插图都来到了我们,我们对它们做了一些事情,设置了填充物,然后再次将所有的整个插图都退了回来。这是必需的,以便任何ViewGroup始终将这些插图传递给所有View。有用。

应用程序错误


在Google Play上的许多已经处理了插图的应用程序中,我注意到了一个小错误。现在,我将介绍他。

地下室有一个带有导航的屏幕。右侧是显示层次结构的同一屏幕。绿色片段在屏幕上显示内容,其中有一个RecyclerView。



谁处理了这里的插图?工具栏:他在顶部应用了填充,以便其内容在状态栏下移动。因此,底部导航栏从导航栏下方应用插图,并在导航栏上方应用插图。但是RecyclerView不会以任何方式处理插图,它不会落在它们之下,也不需要处理插图-一切都正确完成。



但是事实证明,我们想在底部导航栏不再存在的另一个地方使用绿色的RecyclerView片段。此时,RecyclerView已经开始从下面处理插图。我们需要对其应用填充以从导航栏下方正确滚动。因此,在RecyclerView中,我们还添加了insets处理。



我们返回到屏幕,每个人都处理插图。还记得没有人报告他已经处理过这些吗?



我们看到了这种情况:RecyclerView从下面处理了插图,尽管它没有到达导航栏-底部出现了一个空格。我在应用程序中注意到了这一点,并且非常流行。



如果是这种情况,请记住我们将所有插图返回给处理。因此(事实证明!)有必要报告插图已处理:导航栏应报告插图已处理。他们不应该进入RecyclerView。



为此,请安装底部导航栏InsetsListener。减去bottom + insets未处理的内容,返回0。

doOnApplyWindowInsets { view, insets, initialPadding ->    
    view.updatePadding(
        bottom = initialPadding.bottom + insets.systemWindowInsetBottom
    )
    insets.replaceSystemWindowInsets(
        Rect(
            insets.systemWindowInsetLeft,            
            insets.systemWindowInsetTop,            
            insets.systemWindowInsetRight,
            0
        )
    )
}

我们返回新的inset,其中所有旧参数均相等,但bottom + insets等于0。似乎一切都很好。我们开始胡说八道-由于某些原因,RecyclerView仍然处理inset。



我没有立即理解问题是这三个视图的容器是LinearLayout。它们内部是一个工具栏,一个带有RecyclerView的代码段,位于底部导航栏的底部。LinearLayout按顺序排列其子项,并将其套用到它们。

事实证明,底部导航栏将是最后一个。他告诉某人他已经处理了所有插图,但为时已晚。所有插图均已从上到下进行处理,RecyclerView已收到它们,但这并不能挽救我们。



该怎么办?LinearLayout不能按照我想要的方式工作,而是将它们从上到下传输,我需要先获取下一个。

重新定义全部


Java开发人员扮演了我的角色- 一切都需要重新定义好了,现在dispatchApplyWindowInsets重新定义set yLinearLayout,它将始终从下到上。他将首先将插图底部导航栏发送给其他人。

@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {     
    insets = super.dispatchApplyWindowInsets(insets);     
    if (!insets.isConsumed()) {        
        final int count = getChildCount();
        for (int i = 0; i < count; i++) {
            insets = getChildAt(i).dispatchApplyWindowInsets(insets);
            if (insets.isConsumed()) {
                break;
            }
        }
    }
    return insets;
}

但是我及时停下来,回想起没有必要重新定义此方法的评论。

在这里,救赎要高一些:ViewGroup检查是否有处理插入的委托,然后在View上调用super方法并启用其自己的处理。在此代码中,我们获得了插入物,如果尚未处理它们,我们将为子级开始标准逻辑。

我写了一个简单的扩展。应用于InsetsListener一个视图,它允许说出这些插图传递给谁。

fun View.addSystemBottomPadding(    
    targetView: View = this
) {
    doOnApplyWindowInsets { _, insets, initialPadding ->           
        targetView.updatePadding(
            bottom = initialPadding.bottom + insets.systemWindowInsetBottom
        )
        insets.replaceSystemWindowInsets(
            Rect(
                insets.systemWindowInsetLeft,
                insets.systemWindowInsetTop,     
                insets.systemWindowInsetRight,
                0
            )
        )
    }
}

targetView默认情况下,等于我们要应用的同一视图addSystemBottomPadding我们可以重新定义它。

在我的LinearLayout上,我悬挂了这样的处理程序,传递为targetView-这是我的底部导航栏。

  • 首先,他将提供插图底部导航栏。 
  • 那将处理插图,将返回零底。
  • 此外,默认情况下,它们将自上而下:工具栏,带有RecyclerView的片段。
  • 然后,也许他会再次发送插图底部导航栏。但这不再重要,一切都会很好。

我完全实现了我想要的:所有插图均按需要的顺序进行处理。



重要


需要牢记的一些重要事项。

键盘是应用程序顶部的系统UI。无需特殊对待她。如果您查看的是Google键盘,则不仅是带有可单击按钮的布局。该键盘有数百种模式:搜索gif和memes,语音输入,从10像素的高度调整到屏幕大小。不要考虑键盘,而是订阅插图。

导航抽屉仍不支持手势导航他们承诺在Google IO上开箱即用。但是在Android 10中,导航抽屉仍不支持此功能。如果您升级到Android 10并启用手势导航,导航抽屉将掉线。现在,您需要单击汉堡菜单以使其显示,或者随机情况的组合允许您拉伸它。

在Android的预发布版本中,导航抽屉有效,但我不敢更新-这是预发布版本。因此,即使您从存储库中安装了最新版本的GitFox,那里也有一个导航抽屉,但是无法将其拉出。官方支持一经发布,我就会更新,一切都会正常进行。

Android 10准备清单


从项目开始设置导航栏和状态栏的透明度。 Google强烈建议您维护透明的导航栏。对我们来说,这是重要的实践部分。如果该项目可行,但您没有打开它,请选择支持Android 10的时间。首先在主题中打开它们(例如半透明),然后更正其中断的布局。

将扩展程序添加到Kotlin-使用它们更容易。

将所有工具栏添加到填充顶部。工具栏始终位于顶部,这就是您需要做的。

对于所有RecyclerViews,从底部添加填充和滚动浏览的功能clipToPadding="false"

考虑一下屏幕(FAB)边缘上的所有按钮。 FAB很可能不在您期望的位置。

对于所有LinearLayout和其他类似情况,请不要覆盖或自行实现。像在GitFox中一样,完成技巧,或者使用我现成的扩展程序来提供帮助。

检查屏幕边缘的手势以获取自定义View。导航抽屉不支持此功能。但是,用手支撑它并不是很困难,可以使用导航抽屉在屏幕上重新定义手势的插入。也许您有图片编辑器,手势在其中起作用。

在ViewPager中滚动无法从边缘开始,而只能从中心向右和向左滚动。 Google说这很正常。如果从边缘拖动ViewPager,则将其视为按下“返回”按钮。

在一个新项目中,立即包括所有透明度您可以告诉设计者您没有导航栏和状态栏。整个方框就是您的应用程序内容。开发人员将已经弄清楚了在哪里筹集什么。

有用的链接:

  • 电报中的@rmr_spb-Redmadrobot SPb的内部mitap记录。
  • 本文中的所有源代码,以及GitFox中的更多源代码有一个单独的Android 10分支,在这里您可以通过commit看到我如何完成所有这些工作以及我如何支持新的Android。
  • 克里斯·贝恩(Chris Bane)的Insetter图书馆它包含了我展示的5-6个扩展名。要在您的项目中使用,请与该库联系,最有可能它将移至Android Jetpack。我在项目中开发了它们,在我看来,它们变得更好。
  • 克里斯·贝恩(Chris Bane)的文章
  • FunCorn, , .
  • «Why would I want to fitsSystemWindows?» .

AppConf, youtube- . , - . AppsConf , . , , stay tuned!

All Articles