约束布局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
进行求解,解题思路:
- 通过设置一个水平居中的参照View,用于等分两个区域。
- 将两个
TextView
作为一个整体,在布局内垂直居中。
问题出在第二点:如若不引入一层布局,将这两个TextView
作为包裹起来作为一个整体,是无法实现将两个TextView
作为整体进行垂直居中的。
也就是说,使用RelativeLayout
优化不动。实际上,在进行第一步的实现就已经有难解决的问题,看效果:
<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
是否能胜任这个工作呢?答案当然是肯定的,不然就没法当案例来讲了。 ;-)
解题思路大同小异:
- 设置一个在水平方向居中的参照物,在
ConstraintLayout
里,它被称做GuideLine
参考线,是一条虚拟的不可见的线,仅参与布局计算,不涉及UI绘制。 - 以此参照物为约束条件,构造文本的约束,使其在二分之一区域内水平居中。
- 将垂直方向上的文本串成一条线,并打包居中。在
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
,在默认情况下,文本超长时,控件的宽度会超过约束边界,即上图这样的情况:
要限制控件宽度在约束边界内,增加一个配置:layout_constrainedWidth="true"
即可。
至此,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>
注释齐全,给自己赞一个,短短几十行,一个布局,三个控件,轻描淡写地就实现了这个效果。可这到底是怎么实现的,现在看着这代码的我想了好久!(这段代码,此刻只有那时的我和上帝知道了,向接手这块的哥们致意)
当时为实现这个效果想了好久,实现肯定是可以实现,但目标是通过布局文件直接实现了这个效果,不要再在代码里去动态调控样式。
放出最终效果图,用户名的那个行的效果:
接着在仔细看一下采用RelativeLayout
的实现,整个实现方案是有Hack的成分在里头的。
切入点就在以下3个不同寻常的点里:
- 装饰线固定了宽度60dp
- 每条装饰线都有-30dp的水平margin
- 用户名控件水平方向上有45dp的超大margin
需求是实现随用户名控件的宽度自适应宽度的装饰线,这里非但没有丝毫和自适应相关的代码,想想都很神奇。
再来看编辑器预览:
可以看到,在两条装饰线的中间,均有多了一条切割线。再仔细看看,这条切割线在用户名控件的区域之外,再结合异常点3,可以知道,切割线是用户名控件水平方向上45dp的margin的边界。
也就是说,用户名控件将装饰线往外多推了一段距离,而装饰线则通过-30dp的margin,往昵称控件方向,挤了挤一段距离。
最终结果便是,用户名控件比左右两侧分别比实际多了30dp的宽度,这多出来的30dp的宽度显示的是往里缩了30dp的装饰线的内容。
在自适应的过程中,装饰线从始至终都没变化过,唯一变化的只有用户名控件的宽度。
翻译一下就是,从始至终就没有自适应调节装饰线控件的这回事。实际的情况是:
- 用户名短的情况,装饰线和用户名控件整体居中,三者均完整展示;
- 随着用户名宽度变长,装饰线被逐渐挤到布局外侧,造成装饰线缩短的假象;
- 由于用户名控件有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_max
和layout_constraintWidth_min
这两个配置,让布局根据实际情况,动态地决定装饰线的宽度。
另外这里同样需要注意:用户名可能会超长,超出约束边界,因此需要使用app:layout_constrainedWidth="true"
将它控制在边界之内。
可以看到,使用ConstraintLayout
就直观很多,不像之前的实现方式,需要拐个弯才能理解。
案例三:动态适配不同尺寸的全面屏
这个案例说来话长,先看下效果图和适配规则。
拆解下设计师的意思:
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的基础上,去拉伸底部面板,直到底部面板的高度到达最小值。
常规实现
在做这个需求的时候,笔者想来想去思前想后,没有想到如何在布局中实现这种动态效果。笔者尝试了LinearLayout
、RelativeLayout
,都失败了。在这两个布局里,都难以表达“在尽可能保证中部预览区域比例为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行。但这种实现方式,隐隐感觉不够优雅:
- 实现逻辑依靠两部分实现,布局和计算辅助类,相关逻辑不够内聚,有一定的维护成本(其他人接手时,单看布局文件,会觉得这是很简单的一个布局,尝试修改布局内的高度,却会发现无论怎么修改不生效,直到发现了胶水代码)。
- 此实现是通过注册
OnLayoutChangeListener
监听,在布局发生变化之后,进行后置干预的方式来实现;而非在布局的过程中直接处理完毕,在流程上不自然。 -
OnLayoutChangeListener
监听会在布局有任意layout
变化的时候触发,此段逻辑会被重复触发执行,带来不必要的性能损耗。
ConstraintLayout的解法
先来实现3.1、3.2的场景。
- 对于这种三个控件竖直排列的场景,用竖直方向的链条
Chain
来实现; -
Chain
需要设置为spread_inside
,使得两端的控件对齐到边缘; - 对于中部视频预览控件,宽高设置为
0dp
,即MATCH_CONSTRAINT
,这样控件会占满属于它的整个约束区域; - 同时对中部视频预览控件施加宽高比例为
1:1
的约束:app:layout_constraintDimensionRatio="1:1"
,最终效果便是中部视频预览控件是在约束区域内的最大正方形; - 同样的,指定底部预览区域的高度值为
MATCH_CONSTRAINT
,同时约束高度在248dp ~ 298dp 之间:app:layout_constraintHeight_max="298dp"
、app:layout_constraintHeight_min="248dp"
- 值得注意的是,这里需要添加
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>
看下效果(图上增加了两条参考线,方便比对底部区域的动态拉伸的效果):
从预览图可以看到,ConstraintLayout
的约束条件可以完整地表达:
- 尽可能保证中部视频预览区1:1
- 优先调节底部区域,再调节中部视频预览区域
这两个关键约束条件,确保所需布局效果的呈现。
不过,这个实现里,中部视频预览区并非实际想要的预览区,实际想要的部分,是包含了两侧留白的部分。
一开始,笔者一直致力于将中间的布局的边界,在保留当前效果的情况下,拓展到约束边界,最终未果。原因很简单:鱼和熊掌不可兼得,比例限制为1:1的情况下,如何能做到宽高不一致?
需要换个角度来处理这个情况。约束布局的核心是确定约束,约束布局的灵活性来自于约束参考物,约束参考物,除了父布局、约束布局提供的辅助标记,添加到布局内的控件,也是可用的约束参考物,尤其是已经确定了位置的控件。
对于这个场景来说,头部区域和底部区域,是两个已经确定了位置的布局内控件,可以作为约束参考物,确定所需的中部区域的高度:中部区域以头部区域的底为顶、以底部区域的顶为底。而原先放置在中部的1:1 控件,本质上是一个确定头部和底部的辅助约束物。
稍微调整了一下布局:
- 将原先的1:1中部控件,调整为不可见(避免影响绘制性能),作为确定头部和底部的辅助约束物;
- 新增一个控件,此控件的
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>
效果如下:
至此,根据屏幕大小,动态适配头部、中部、底部三个区域的需求,算是完成了。接下来实现“顶部导航栏、底部操作区域,在某些场景下,需要隐藏不可见,此时界面需要按适配规则,再次动态计算”这一条。
先依次看看,设为下面三种情况,布局会是怎样的效果(简单起见只放Pixel 3 XL的效果图):
- 头部设为
gone
- 底部设为
gone
- 头部和底部均设为
gone
头部为gone
,中部区域效果看起来正常,如期拓展到顶部,但看右侧,描述1:1偏好限制的约束参照物,贴近了顶部。
底部为gone
,同样,中部区域效果看起来正常,如期拓展到底部,但看右侧,描述1:1偏好的约束参照物,贴近了底部。
顶部和底部均为gone
,这回中部区域效果就不如预期般同时拓展到顶部和底部了,从右侧看,描述1:1偏好限制的约束参照物,这回居中显示了。
虽然情况1、情况2界面能如预期展示,但实际上,这个场景下的约束关系,并不是我们想要的约束关系。对于头部区域/底部区域消失的场景,设计上是希望中部区域直接对齐到父布局的顶部/底部,而实际上,这个约束关系并没有指定,导致了预期外的情况3的出现(情况1、情况2只是碰巧没关系罢了)。
明了了原因的所在,怎么修复?约束关系的指定,只能指向一个,对这个场景而言,变成了两个:在顶部/底部区域可见时,约束指向顶部/底部区域;在顶部/底部区域不可见时,约束指向父布局。
如何做到指向多个约束关系?
这里就需要借助于辅助参照物Barrier
了。根据官方文档,Barrier
用来创建一个虚拟的参考线,这条参考线是指定的几个控件的边缘,可选的边缘有top
、bottom
、start
、end
。Barrier
的这个特性,恰好可以用来做聚合多个控件,并作为单一的约束参照物来使用。
问题又来了,Barrier
指向几个控件的边缘,在这个场景,Barrier
指向父布局和顶部(或底部)区域,那么它的bottom
(或top
)边缘,必然恒等同于父布局的bottom
(或top
),不就排不上用场了?
因此,对于这个场景,需要再造两个参考物,分别指向父布局的top
和bottom
,干这事的,可以像描述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内,整体居中(即水平、垂直方向都居中),需要注意的是,上层主标题和下层副标题的宽度都是可变的。
后记
-
案例2的设计稿找到了,如下图