强大的ConstraintLayout:使用ConstraintLayout打造响应式UI

时间:2022-06-26
本文章向大家介绍强大的ConstraintLayout:使用ConstraintLayout打造响应式UI,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

约束布局ConstraintLayout发布(2017年)至今已经好几个年头了。经过几个版本的功能迭代,现阶段的ConstraintLayout相当强大,80%以上的复杂界面都可以使用ConstraintLayout来实现;剩下的20%里,有80%是没充分利用好ConstraintLayout的特性来实现,最后的20%,才是Android Support 包团队需要加油补上的。

在这篇文章,笔者将用三个案例,给大家展示一下ConstraintLayout的强大。尤其最后一个例子,堪称经典,笔者经过一番折腾之后,认为这个例子所要实现的效果超出了ConstraintLayout的能力;当笔者敲下这个结论之后,闪过了一个“What If”的念头,经过尝试后,发现之前的结论不成立,反而进一步证明了ConstraintLayout的强大之处——灵活,这也是笔者得出“剩下的20%里,有80%是没充分利用好ConstraintLayout的特性来实现”这个论断的由来。

案例1:等分

设计稿标注如下:

设计稿标注

常规解法

很常见的设计样式,通常解法:横向线性布局套上两个竖向线性布局;横向线性布局设为等分两个子线性布局;竖向线性布局设为水平居中。布局代码大致如下:

<LinearLayout
    android:orientation="horizontal">
    <LinearLayout
        android:gravity="center"
        android:layout_weight="1"
        android:orientation="vertical">
        <TextView /> <!-- 左侧第一行文本,含顶部ICON -->
        <TextView /> <!-- 左侧第二行文本 -->
    </LinearLayout>
    <LinearLayout
        android:gravity="center"
        android:layout_weight="1"
        android:orientation="vertical">
        <TextView /> <!-- 右侧第一行文本,含顶部ICON -->
        <TextView /> <!-- 右侧第二行文本 -->
    </LinearLayout>
</LinearLayout>

这种常规方式,胜在实现简单直观,但它的缺陷也很明显:布局嵌套过多。了解Android的界面的运作机制的朋友知道,布局嵌套层级过多会带来UI布局/测量性能消耗。

从这个例子上看,总共也就两层布局,再怎么优化,也只能优化一层。但实际不是这样的:最外层的LinearLayout外还有一层布局,用于容纳和它同级的其他控件,因此,最优的解法应当能将这两层布局都优化掉。

尝试使用 RelativeLayout 优化

在Android系统提供的基础布局控件,最灵活的当属RelativeLayout相对布局。使用RelativeLayout进行求解,解题思路:

  1. 通过设置一个水平居中的参照View,用于等分两个区域。
  2. 将两个TextView作为一个整体,在布局内垂直居中。

问题出在第二点:如若不引入一层布局,将这两个TextView作为包裹起来作为一个整体,是无法实现将两个TextView作为整体进行垂直居中的。

也就是说,使用RelativeLayout优化不动。实际上,在进行第一步的实现就已经有难解决的问题,看效果:

RelativeLayout Bad Case
<RelativeLayout>
    <View
        android:layout_centerHorizontal="true"
        android:id="@+id/half_h" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignEnd="@+id/half_h"
        android:layout_alignParentStart="true"
        android:text="1" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignEnd="@+id/half_h"
        android:layout_alignParentStart="true"
        android:text="2" />

    <TextView
        android:id="@+id/right1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_alignStart="@+id/half_h"
        android:text="1" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_alignStart="@+id/half_h"
        android:text="2" />
</RelativeLayout>

可以直观看到,文本控件直接占据了一半的空间,而非像我们所需要的在布局内横向居中。虽然可以通过给文本控件设置居中对齐的方式来规避,但终究不是完美的解法。

ConstraintLayout 小试牛刀

号称比RelativeLayout更灵活的ConstraintLayout是否能胜任这个工作呢?答案当然是肯定的,不然就没法当案例来讲了。 ;-)

解题思路大同小异:

  1. 设置一个在水平方向居中的参照物,在ConstraintLayout里,它被称做GuideLine参考线,是一条虚拟的不可见的线,仅参与布局计算,不涉及UI绘制。
  2. 以此参照物为约束条件,构造文本的约束,使其在二分之一区域内水平居中。
  3. 将垂直方向上的文本串成一条线,并打包居中。在ConstraintLayout里,串成一条线的特效称为Chain,打包垂直居中的配置为layout_constraintVertical_chainStyle="packed"

最终实现核心代码大致如下:

<android.support.constraint.ConstraintLayout>
    <!--  水平方向上50%的垂直参考线 -->
    <android.support.constraint.Guideline
        android:id="@+id/half_parent_width"
        android:orientation="vertical"
        app:layout_constraintGuide_percent="0.5" />

     <!-- 左侧区域的两个文本控件 -->
    <TextView
        android:id="@+id/left_text1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@+id/left_text2"
        app:layout_constraintEnd_toStartOf="@+id/half_parent_width"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/left_text2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constrainedWidth="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/half_parent_width"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/left_text1" />

     <!-- 右侧区域的两个文本控件 -->
    <TextView
        android:id="@+id/right_text1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toTopOf="@+id/right_text2"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/half_parent_width"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/right_text2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constrainedWidth="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/half_parent_width"
        app:layout_constraintTop_toBottomOf="@+id/right_text1" />

</android.support.constraint.ConstraintLayout>

这里有一点需要注意一下:从设计稿来看,第二行文本是可能出现超长的情况,第二行文本控件的宽度设置是:wrap_content,在默认情况下,文本超长时,控件的宽度会超过约束边界,即上图这样的情况:

width over constraint edge

要限制控件宽度在约束边界内,增加一个配置:layout_constrainedWidth="true"即可。

fix width over constraint edge

至此,ConstraintLayout完全Hold住了设计稿的要求。虽然相比最初的方案,实现代码看起来很不直观,但这不是问题,核心是约束布局兼顾了灵活性和性能,只要ConstraintLayout足够万能,那么基于它实现一个UI编辑器,便完全有可能。现时ConstraintLayout已经荣升成默认根布局控件,Android Studio 的UI编辑器也深度支持了它,假以时日,拖拉一下控件,点点鼠标,不再手撸XML的一天将会到来。

案例二:根据文本宽度自适应性调整装饰线条宽度的需求

设计稿暂时还没找着,倒是翻出了当时实现这个效果的注释:

<!-- 用户名区域 -->
<!-- 实现的效果如下描述 -->
<!-- 设计师要求:两边的线随名字自适应,右边最小边距为15dp,字体更多就缩小线的宽度,线的最小宽度为30dp,字再长就省略 -->
<!-- 即:1. 用户名区域的宽度是动态的,最大可用宽度是 match_parent -->
<!--     2. 线的长度是可变的,最长是60dp,最短是30dp -->
<!-- 普通情况下:字全显示,线以最长的宽度显示,两边有空白 -->
<!-- 字普通长情况下:字全显示,线显示部分(在 30dp - 60dp 之间) -->
<!-- 字极端长情况下:字全显示部分,线以最短的宽度显示 -->

每个字都看懂,但如果没有设计稿辅助理解,就会发现:语言真的很苍白。

紧接着又翻出了实现代码:

<!-- 上面的注释放在这里 -->
<RelativeLayout
    android:id="@+id/user_page_user_name_text_view_layout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="11dp"
    android:layout_marginBottom="7dp"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent">

    <!-- 用户名 -->
    <TextView
        android:text="User Name"
        android:id="@+id/user_page_user_name_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="45dp"
        android:layout_marginEnd="45dp"
        android:ellipsize="end"
        android:maxLines="1"
        android:layout_centerInParent="true" />

    <!-- 左边的横线 -->
    <View
        android:id="@+id/user_page_user_name_left_line"
        android:layout_width="60dp"
        android:layout_height="2dp"
        android:layout_marginStart="15dp"
        android:layout_marginEnd="-30dp"
        android:background="#EEEEEE"
        android:layout_toStartOf="@+id/user_page_user_name_text_view"
        android:layout_centerVertical="true"/>

    <!-- 右边的横线 -->
    <View
        android:id="@+id/user_page_user_name_right_line"
        android:layout_width="60dp"
        android:layout_height="2dp"
        android:layout_marginStart="-30dp"
        android:layout_marginEnd="15dp"
        android:background="#EEEEEE"
        android:layout_toEndOf="@+id/user_page_user_name_text_view"
        android:layout_centerVertical="true"/>
</RelativeLayout>

注释齐全,给自己赞一个,短短几十行,一个布局,三个控件,轻描淡写地就实现了这个效果。可这到底是怎么实现的,现在看着这代码的我想了好久!(这段代码,此刻只有那时的我和上帝知道了,向接手这块的哥们致意)

当时为实现这个效果想了好久,实现肯定是可以实现,但目标是通过布局文件直接实现了这个效果,不要再在代码里去动态调控样式。

放出最终效果图,用户名的那个行的效果:

用户名不长的情况,用户名和装饰线完整显示
用户名比较长的情况,完整展示用户名,装饰线宽度在30dp~60dp之间浮动
用户名特别长的情况,装饰线宽度以30dp展示,剩余空间展示用户名,超出区域省略显示

接着在仔细看一下采用RelativeLayout的实现,整个实现方案是有Hack的成分在里头的。

切入点就在以下3个不同寻常的点里:

  1. 装饰线固定了宽度60dp
  2. 每条装饰线都有-30dp的水平margin
  3. 用户名控件水平方向上有45dp的超大margin

需求是实现随用户名控件的宽度自适应宽度的装饰线,这里非但没有丝毫和自适应相关的代码,想想都很神奇。

再来看编辑器预览:

编辑器预览的约束示意图
用户名控件的边界预览

可以看到,在两条装饰线的中间,均有多了一条切割线。再仔细看看,这条切割线在用户名控件的区域之外,再结合异常点3,可以知道,切割线是用户名控件水平方向上45dp的margin的边界。

也就是说,用户名控件将装饰线往外多推了一段距离,而装饰线则通过-30dp的margin,往昵称控件方向,挤了挤一段距离。

最终结果便是,用户名控件比左右两侧分别比实际多了30dp的宽度,这多出来的30dp的宽度显示的是往里缩了30dp的装饰线的内容。

在自适应的过程中,装饰线从始至终都没变化过,唯一变化的只有用户名控件的宽度。

翻译一下就是,从始至终就没有自适应调节装饰线控件的这回事。实际的情况是:

  1. 用户名短的情况,装饰线和用户名控件整体居中,三者均完整展示;
  2. 随着用户名宽度变长,装饰线被逐渐挤到布局外侧,造成装饰线缩短的假象;
  3. 由于用户名控件有margin,因此用户名控件最大只能撑满控件宽度 - margin宽度那么宽的区域,之前内缩30dp的装饰线,也因此得到了展示的几乎。

这也是一种思路,和做3D游戏一样,计算机UI界面的呈现本质上也是是一种视觉欺骗。

但这样写出来的代码难以维护。看看用ConstraintLayout的实现方案:

<!-- 左边的横线 -->
<View
    android:id="@+id/user_page_user_name_left_line"
    android:layout_width="0dp"
    android:layout_height="2dp"
    android:layout_marginStart="15dp"
    android:layout_toStartOf="@+id/user_page_user_name_text_view"
    android:background="#EEEEEE"
    app:layout_constraintBottom_toBottomOf="@+id/user_page_user_name_text_view"
    app:layout_constraintEnd_toStartOf="@+id/user_page_user_name_text_view"
    app:layout_constraintHorizontal_chainStyle="packed"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="@+id/user_page_user_name_text_view"
    app:layout_constraintWidth_max="60dp"
    app:layout_constraintWidth_min="30dp" />

<!-- 用户名 -->
<TextView
    android:id="@+id/user_page_user_name_text_view"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerInParent="true"
    android:layout_marginBottom="7dp"
    android:layout_marginEnd="15dp"
    android:layout_marginStart="15dp"
    android:layout_marginTop="11dp"
    android:ellipsize="end"
    android:maxLines="1"
    app:layout_constrainedWidth="true"
    app:layout_constraintEnd_toStartOf="@+id/user_page_user_name_right_line"
    app:layout_constraintStart_toEndOf="@+id/user_page_user_name_left_line"
    app:layout_constraintTop_toBottomOf="@+id/user_page_user_header_image_view" />

<!-- 右边的横线 -->
<View
    android:id="@+id/user_page_user_name_right_line"
    android:layout_width="0dp"
    android:layout_height="2dp"
    android:layout_centerVertical="true"
    android:layout_marginEnd="15dp"
    android:layout_toEndOf="@+id/user_page_user_name_text_view"
    android:background="#EEEEEE"
    app:layout_constraintBottom_toBottomOf="@+id/user_page_user_name_text_view"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toEndOf="@+id/user_page_user_name_text_view"
    app:layout_constraintTop_toTopOf="@+id/user_page_user_name_text_view"
    app:layout_constraintWidth_max="60dp"
    app:layout_constraintWidth_min="30dp" />

这里使用了layout_constraintWidth_maxlayout_constraintWidth_min这两个配置,让布局根据实际情况,动态地决定装饰线的宽度。

另外这里同样需要注意:用户名可能会超长,超出约束边界,因此需要使用app:layout_constrainedWidth="true"将它控制在边界之内。

可以看到,使用ConstraintLayout就直观很多,不像之前的实现方式,需要拐个弯才能理解。

案例三:动态适配不同尺寸的全面屏

这个案例说来话长,先看下效果图和适配规则。

效果图1
效果图2
适配规则

拆解下设计师的意思:

1.0. 首先忽略设计稿描述有误的部分:比例大于9:16,即高度小于理想尺寸;比例小于9:16,即高度大于理想尺寸

1.1. 底部面板高度在248dp ~ 298dp 之间浮动;

1.2. 在屏幕高度过长(小于9:16)的情况下,对于多出来的高度部分,优先分配给底部面板,直到底部面板到达最大值,再将剩余高度分配给中间的预览区域;

1.3. 在屏幕高度过短(大于9:16)的情况下,优先压缩操作区域,直到底部面板到达最小值,再将压缩中间的预览区域。

这里需要补充一些设计师未提及的部分:

2.1. 理想尺寸为9:16,在此尺寸下,顶部导航条为44dp,底部面板高度为248dp,中部视频预览区域为方形,宽高均为375dp。

2.2. 顶部导航栏、底部操作区域,在某些场景下,需要隐藏不可见,此时界面需要按适配规则,再次动态计算。

在2.1的前提之下,再来理解设计师的适配规则:

3.1. 在2.1的前提之下,1.2实际上是说:在尽可能保证中间视频预览区域比例为1:1的基础上,去拉伸底部面板,直到底部面板的高度到达最大值,再拉伸。

3.2. 在2.1的前提之下,1.3实际上是说:在尽可能保证中部视频预览区域比例为1:1的基础上,去拉伸底部面板,直到底部面板的高度到达最小值。

常规实现

在做这个需求的时候,笔者想来想去思前想后,没有想到如何在布局中实现这种动态效果。笔者尝试了LinearLayoutRelativeLayout,都失败了。在这两个布局里,都难以表达“在尽可能保证中部预览区域比例为1:1的情况下,优先调节底部面板的高度,直到高度达到临界值,再回过头来调整中部预览视频区域”这个意图。

最终笔者只能在布局中定义了3个竖向排列的布局区域,接着在代码中,注册(addOnLayoutChangeListener)布局改变监听(OnLayoutChangeListener),当布局有变化时(onLayoutChange),计算每个区域应有的高度,然后去调整每个区域的高度。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout android:id="@+id/adaptive_layout"
              xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="match_parent">

    <!-- 顶部区域 -->
    <View
        android:id="@+id/adaptive_header_area"
        android:layout_width="match_parent"
        android:layout_height="44dp"
        android:layout_alignParentTop="true"/>

    <!-- 底部控制区 -->
    <View
        android:id="@+id/adaptive_operation_area"
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:layout_alignParentBottom="true"/>

    <!-- 视频预览区域 -->
    <View
        android:id="@+id/adaptive_preview_area"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_below="@+id/adaptive_header_area"
        android:layout_above="@+id/adaptive_operation_area"/>

</RelativeLayout>
/**
 * 按照适配规则,排布[顶部区]、[预览区]、[操作区]的高度的辅助类
 */
class AdaptiveLayoutHelper(adaptiveLayout: View) : View.OnLayoutChangeListener {

    private val adaptiveHeader: View? = adaptiveLayout.findViewById<View>(R.id.adaptive_header_area)
    private val adaptivePreview: View = adaptiveLayout.findViewById<View>(R.id.adaptive_preview_area)
    private val adaptiveOperation: View = adaptiveLayout.findViewById<View>(R.id.adaptive_operation_area)


    override fun onLayoutChange(v: View?, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int) {

        val layoutWidthPx = right - left
        val layoutHeightPx = bottom - top

        LogUtil.d(TAG, "layout=(W:$layoutWidthPx, H:$layoutHeightPx)")

        val headerHeightPx = if (adaptiveHeader != null && adaptiveHeader.visibility == View.VISIBLE) DisplayMetricsUtil.dip2px(44f) else 0
        val isOperationAreaInvisible = adaptiveOperation.visibility != View.VISIBLE

        // 设计上,进度条归属控制区
        // 实现上,为了方便全屏功能的实现,进度条归属预览区
        // 因此,操作区的高度需要减去进度条的高度
        val progressPanelHeightDp = 40f
        val operationHeightMaxDp = 298f
        val operationHeightMinDp = 268f

        val operationHeightMaxPx = DisplayMetricsUtil.dip2px(operationHeightMaxDp - progressPanelHeightDp)
        val operationHeightMinPx = DisplayMetricsUtil.dip2px(operationHeightMinDp - progressPanelHeightDp)

        // 计算按理想状态排布顶栏和预览区后,剩余的高度:全局高度 - 顶栏高度 - 预览区高度(理想情况下预览器高度和宽度相等)
        val remainHeightPx = (layoutHeightPx - headerHeightPx - layoutWidthPx)

        // 预览区和操作区的高度
        val previewHeightPx: Int
        val operationHeightPx: Int
        when {
            isOperationAreaInvisible -> {
                // 隐藏底部操作区,如全屏
                operationHeightPx = 0
                previewHeightPx = layoutHeightPx - headerHeightPx
            }
            (remainHeightPx < operationHeightMinPx) -> {
                // 剩余高度不足,保证操作区满足最小高度
                operationHeightPx = operationHeightMinPx
                previewHeightPx = max(0, layoutHeightPx - headerHeightPx - operationHeightPx)
                LogUtil.d(TAG, "remainHeight not enough, operationHeight=$operationHeightPx, previewHeight=$previewHeightPx")
            }
            (remainHeightPx > operationHeightMaxPx) -> {
                // 剩余高度过多,保证操作区不超过最大高度
                operationHeightPx = operationHeightMaxPx
                previewHeightPx = max(0, layoutHeightPx - headerHeightPx - operationHeightPx)
                LogUtil.d(TAG, "remainHeight over enough, operationHeight=$operationHeightPx, previewHeight=$previewHeightPx")
            }
            else /* (remainHeight in operationHeightMin..operationHeightMax) */ -> {
                previewHeightPx = layoutWidthPx
                operationHeightPx = remainHeightPx
                LogUtil.d(TAG, "remainHeight just ok, operationHeight=$operationHeightPx, previewHeight=$previewHeightPx")
            }
        }

        if ((adaptiveHeader?.layoutParams?.height != headerHeightPx)
                or (adaptivePreview.layoutParams.height != previewHeightPx)
                or (adaptiveOperation.layoutParams.height != operationHeightPx)) {
            LogUtil.i(TAG, "update adaptive ui: header=$headerHeightPx, preview=$previewHeightPx, operation=$operationHeightPx")
            adaptiveHeader?.layoutParams = adaptiveHeader?.layoutParams?.apply {
                height = headerHeightPx
            }
            adaptivePreview.layoutParams = adaptivePreview.layoutParams.apply {
                height = previewHeightPx
            }
            adaptiveOperation.layoutParams = adaptiveOperation.layoutParams.apply {
                height = operationHeightPx
            }
        }
    }
}

private const val TAG = "AdaptiveLayoutHelper"

总体实现共计一个布局文件,一个计算辅助类,以及接入层的一行胶水代码,总计代码量不到150行。但这种实现方式,隐隐感觉不够优雅:

  1. 实现逻辑依靠两部分实现,布局和计算辅助类,相关逻辑不够内聚,有一定的维护成本(其他人接手时,单看布局文件,会觉得这是很简单的一个布局,尝试修改布局内的高度,却会发现无论怎么修改不生效,直到发现了胶水代码)。
  2. 此实现是通过注册OnLayoutChangeListener监听,在布局发生变化之后,进行后置干预的方式来实现;而非在布局的过程中直接处理完毕,在流程上不自然。
  3. OnLayoutChangeListener监听会在布局有任意layout变化的时候触发,此段逻辑会被重复触发执行,带来不必要的性能损耗。

ConstraintLayout的解法

先来实现3.1、3.2的场景。

  1. 对于这种三个控件竖直排列的场景,用竖直方向的链条Chain来实现;
  2. Chain需要设置为spread_inside,使得两端的控件对齐到边缘;
  3. 对于中部视频预览控件,宽高设置为0dp,即MATCH_CONSTRAINT,这样控件会占满属于它的整个约束区域;
  4. 同时对中部视频预览控件施加宽高比例为1:1的约束:app:layout_constraintDimensionRatio="1:1",最终效果便是中部视频预览控件是在约束区域内的最大正方形;
  5. 同样的,指定底部预览区域的高度值为MATCH_CONSTRAINT,同时约束高度在248dp ~ 298dp 之间:app:layout_constraintHeight_max="298dp"app:layout_constraintHeight_min="248dp"
  6. 值得注意的是,这里需要添加app:layout_constraintVertical_weight="1"的约束给底部预览区域。由于其他两个控件没有设置这个约束,因此约束布局会在满足所有控件约束的前提下,优先将剩余空间分配给底部预览区域(没有剩余空间?那就只有满足所有控件约束)。

完整布局代码如下:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:id="@+id/adaptive_header_area"
        android:layout_width="match_parent"
        android:layout_height="44dp"
        android:background="@color/colorRed"
        app:layout_constraintBottom_toTopOf="@+id/adaptive_preview_area"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="spread_inside" />

    <View
        android:id="@+id/adaptive_preview_area"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@color/i_c_blue"
        app:layout_constraintBottom_toTopOf="@+id/adaptive_operation_area"
        app:layout_constraintDimensionRatio="1:1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/adaptive_header_area" />

    <View
        android:id="@+id/adaptive_operation_area"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:background="@color/i_c_gray"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHeight_max="298dp"
        app:layout_constraintHeight_min="248dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/adaptive_preview_area"
        app:layout_constraintVertical_weight="1" />
</android.support.constraint.ConstraintLayout>

看下效果(图上增加了两条参考线,方便比对底部区域的动态拉伸的效果):

3.3'' 240 x 400 (WQVGA, ldpi)
5.0'' 1080 x 1920 (Pixel 2, 420dpi)
6.3'' 1440 x 2960 (Pixel 3 XL, 560dpi)

从预览图可以看到,ConstraintLayout的约束条件可以完整地表达:

  1. 尽可能保证中部视频预览区1:1
  2. 优先调节底部区域,再调节中部视频预览区域 这两个关键约束条件,确保所需布局效果的呈现。

不过,这个实现里,中部视频预览区并非实际想要的预览区,实际想要的部分,是包含了两侧留白的部分。

一开始,笔者一直致力于将中间的布局的边界,在保留当前效果的情况下,拓展到约束边界,最终未果。原因很简单:鱼和熊掌不可兼得,比例限制为1:1的情况下,如何能做到宽高不一致?

需要换个角度来处理这个情况。约束布局的核心是确定约束,约束布局的灵活性来自于约束参考物,约束参考物,除了父布局、约束布局提供的辅助标记,添加到布局内的控件,也是可用的约束参考物,尤其是已经确定了位置的控件。

对于这个场景来说,头部区域和底部区域,是两个已经确定了位置的布局内控件,可以作为约束参考物,确定所需的中部区域的高度:中部区域以头部区域的底为顶、以底部区域的顶为底。而原先放置在中部的1:1 控件,本质上是一个确定头部和底部的辅助约束物。

稍微调整了一下布局:

  1. 将原先的1:1中部控件,调整为不可见(避免影响绘制性能),作为确定头部和底部的辅助约束物;
  2. 新增一个控件,此控件的top紧贴头部的bottom、此控件的bottom紧贴底部的top
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:id="@+id/adaptive_header_area"
        android:layout_width="match_parent"
        android:layout_height="44dp"
        android:background="@color/colorRed"
        app:layout_constraintBottom_toTopOf="@+id/adaptive_preview_prefer_constraint"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="spread_inside" />

    <View
        android:id="@+id/adaptive_preview_prefer_constraint"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:visibility="invisible"
        app:layout_constraintBottom_toTopOf="@+id/adaptive_operation_area"
        app:layout_constraintDimensionRatio="1:1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/adaptive_header_area" />

    <View
        android:id="@+id/adaptive_operation_area"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:background="@color/i_c_gray"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHeight_max="298dp"
        app:layout_constraintHeight_min="248dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/adaptive_preview_prefer_constraint"
        app:layout_constraintVertical_weight="1" />

    <View
        android:id="@+id/adaptive_preview_area"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@color/i_c_blue"
        app:layout_constraintBottom_toTopOf="@+id/adaptive_operation_area"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/adaptive_header_area" />
</android.support.constraint.ConstraintLayout>

效果如下:

3.3'' 240 x 400 (WQVGA, ldpi)
5.0'' 1080 x 1920 (Pixel 2, 420dpi)
6.3'' 1440 x 2960 (Pixel 3 XL, 560dpi)

至此,根据屏幕大小,动态适配头部、中部、底部三个区域的需求,算是完成了。接下来实现“顶部导航栏、底部操作区域,在某些场景下,需要隐藏不可见,此时界面需要按适配规则,再次动态计算”这一条。

先依次看看,设为下面三种情况,布局会是怎样的效果(简单起见只放Pixel 3 XL的效果图):

  1. 头部设为gone
  2. 底部设为gone
  3. 头部和底部均设为gone
头部设为`gone`,6.3'' 1440 x 2960 (Pixel 3 XL, 560dpi)

头部为gone,中部区域效果看起来正常,如期拓展到顶部,但看右侧,描述1:1偏好限制的约束参照物,贴近了顶部。

底部设为`gone`,6.3'' 1440 x 2960 (Pixel 3 XL, 560dpi)

底部为gone,同样,中部区域效果看起来正常,如期拓展到底部,但看右侧,描述1:1偏好的约束参照物,贴近了底部。

顶部和底部均为`gone`,6.3'' 1440 x 2960 (Pixel 3 XL, 560dpi)

顶部和底部均为gone,这回中部区域效果就不如预期般同时拓展到顶部和底部了,从右侧看,描述1:1偏好限制的约束参照物,这回居中显示了。

虽然情况1、情况2界面能如预期展示,但实际上,这个场景下的约束关系,并不是我们想要的约束关系。对于头部区域/底部区域消失的场景,设计上是希望中部区域直接对齐到父布局的顶部/底部,而实际上,这个约束关系并没有指定,导致了预期外的情况3的出现(情况1、情况2只是碰巧没关系罢了)。

明了了原因的所在,怎么修复?约束关系的指定,只能指向一个,对这个场景而言,变成了两个:在顶部/底部区域可见时,约束指向顶部/底部区域;在顶部/底部区域不可见时,约束指向父布局。

如何做到指向多个约束关系?

这里就需要借助于辅助参照物Barrier了。根据官方文档Barrier用来创建一个虚拟的参考线,这条参考线是指定的几个控件的边缘,可选的边缘有topbottomstartendBarrier的这个特性,恰好可以用来做聚合多个控件,并作为单一的约束参照物来使用。

问题又来了,Barrier指向几个控件的边缘,在这个场景,Barrier指向父布局和顶部(或底部)区域,那么它的bottom(或top)边缘,必然恒等同于父布局的bottom(或top),不就排不上用场了?

因此,对于这个场景,需要再造两个参考物,分别指向父布局的topbottom,干这事的,可以像描述1:1偏好的约束参照物一样是一个View,但ConstraintLayout提供的GuideLine会是更好的选择(无形,不可见,指定位置方便)。

最终布局文件调整如下:

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <View
        android:id="@+id/adaptive_header_area"
        android:layout_width="match_parent"
        android:layout_height="44dp"
        android:background="@color/colorRed"
        android:visibility="visible"
        app:layout_constraintBottom_toTopOf="@+id/adaptive_preview_prefer_constraint"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="spread_inside" />

    <View
        android:id="@+id/adaptive_preview_prefer_constraint"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@android:color/holo_green_dark"
        android:visibility="invisible"
        app:layout_constraintBottom_toTopOf="@+id/adaptive_operation_area"
        app:layout_constraintDimensionRatio="1:1"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/adaptive_header_area" />

    <View
        android:id="@+id/adaptive_operation_area"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:background="@color/i_c_gray"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHeight_max="298dp"
        app:layout_constraintHeight_min="248dp"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/adaptive_preview_prefer_constraint"
        app:layout_constraintVertical_weight="1" />

    <View
        android:id="@+id/adaptive_preview_area"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@color/i_c_blue"
        android:visibility="visible"
        app:layout_constraintBottom_toTopOf="@+id/barrier_bottom"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/barrier_top" />


    <android.support.constraint.Guideline
        android:id="@+id/top_of_parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="0" />

    <android.support.constraint.Guideline
        android:id="@+id/bottom_of_parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        app:layout_constraintGuide_percent="1" />


    <android.support.constraint.Barrier
        android:id="@+id/barrier_top"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:barrierAllowsGoneWidgets="false"
        app:barrierDirection="bottom"
        app:constraint_referenced_ids="top_of_parent, adaptive_header_area" />


    <android.support.constraint.Barrier
        android:id="@+id/barrier_bottom"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:barrierAllowsGoneWidgets="false"
        app:barrierDirection="top"
        app:constraint_referenced_ids="bottom_of_parent, adaptive_operation_area" />
</android.support.constraint.ConstraintLayout>

至此,这个案例总算是完美地使用ConstraintLayout实现了,整个布局文件总计89行(含空行)。从整个实现过程来看,约束布局确实提供了远比RelativeLayout灵活的能力,用以支撑起高效率且扁平化整个UI布局的野心。

结语

本文使用三个案例,由浅入深地展示ConstraintLayout在UI布局上的灵活性,可操作性,几乎涉及ConstraintLayout提供的方方面面的能力,希望能给读者带来收获和启发。

思考题

最后,留个思考题,如何使用单层ConstraintLayout,实现如下UI。

要求:『图标 + 上层主标题 + 下层副标题』组成的整体,在ConstraintLayout内,整体居中(即水平、垂直方向都居中),需要注意的是,上层主标题和下层副标题的宽度都是可变的。

整体在水平/竖直方向上居中

后记

  1. 案例2的设计稿找到了,如下图
    案例2设计稿:自适应长度的线和自适应的文本