深度理解ConstraintLayout的布局构造

3,737 阅读15分钟

这算LostLord的第一次写blog,这个系列的出现是因为本人上软件工程需要Android去完成这门课的大作业,所以不得不去学Android,所以就有了这个系列的出现。之后我去B站找相关视频学习,然后找到了一个极好的教学视频,下面这是传送门: Android开发教程(2019最新版,使用JetPack)

但是,里面用的是Java,因为本人之前一直做的是前端,写习惯了JS,虽然自己也会一些Java,但是Java这种强类型语言写起来有点难受,而且kotlin是目前google所推行的,与Java相比较而言会精简,并且写起来比较舒服,Android Studio对kotlin支持性起来越好,所以我选择了kotlin,也是对自己所学知识的一种巩固。

因为本人也算是首次写安卓,也是边学边写blog,可能在blog中有表述不恰当的地方,请各位大佬指正。

这个系列需要的前置知识

  • kotlin
  • Android中对xml的基础了解
  • 对Android中项目不同文件结构的基础了解
  • 对Android中的控件有一定了解

一、为什么是ConstraintLayout

在我们开发中,常常会遇到比较复杂的UI布局,会出现许多层不断嵌套的情况,当嵌套的层次不断增加时,绘图渲染所需要的计算量会越来越大,性能会相应地降低。举个例子:

这个布局如果使用LinearLayout实现:

<LinearLayout>
  <LinearLayout>
    <TextView/>
    <TextView/>
    <TextView/>
  </LinearLayout>
  <LinearLayout>
    <TextView/>
    <TextView/>
   </LinearLayout>
</LinearLayout>

上述代码仅描述结构,会发现LinearLayout中仍然需要嵌套LinearLayout,加深了嵌套层次,增大渲染计算量。

如果换成ConstraintLayout,代码结构则会变成:

<ConstraintLayout>
  <TextView/>
  <TextView/>
  <TextView/>
  <TextView/>
  <TextView/>
</ConstraintLayout>

五个平级的TextView完全可以实现,实现细节放在后面的讲述中。

有的人会说,RelativeLayout也可通过五个平级TextView进行实现,但是ConstraintLayout更加灵活,性能更加出色,而且,ConstraintLayout可以按照比例约束控件位置和尺寸,适配性更好。

而且ConstraintLayout更加适合可视化编辑,具体可看B站UP: longway777的教程:ConstraintLayout:在图形化下设计UI界面

本文更多的是从xml代码中去分析ConstraintLayout的使用与其原理,更加透彻地理解ConstraintLayout。

二、ConstraintLayout的基础使用

1. 控件的基础认识

相对定位

对于每个控件来说,都有五个边界,这些边界便是用来实现约束布局的,下面这是言之官方给的图:

纵向:

  • top: 控件的上边界
  • baseline: 控件中文字的下边界,常用来不同大小的控件进行文字对齐
  • bottom: 控件的下边界

横向:

  • start(left): 控件的左边界
  • right(end): 控件的右边界

确定位置

在一个屏幕渲染中,确定一个控件的位置,需要两个参数,即xy,那么在约束布局中,也需要这两个值,即在纵向的约束和在横向的约束。而确定约束条件完成定位的属性有以下几种:

  • layout_constraintLeft_toLeftOf
  • layout_constraintLeft_toRightOf
  • layout_constraintRight_toLeftOf
  • layout_constraintRight_toRightOf
  • layout_constraintTop_toTopOf
  • layout_constraintTop_toBottomOf
  • layout_constraintBottom_toTopOf
  • layout_constraintBottom_toBottomOf
  • layout_constraintBaseline_toBaselineOf
  • layout_constraintStart_toEndOf
  • layout_constraintStart_toStartOf
  • layout_constraintEnd_toStartOf
  • layout_constraintEnd_toEndOf

这些属性的值为其他控件的id或指向父组件,即parent。通过这些属性的英文可以了解其意思,如buttonB的属性layout_constraintLeft_toRightOf="@id/buttonA",指的是A的left边界受约束于B的right边界,借用官方的图片,即:

不管buttonA的位置如何变化,buttonB的left始终会与A的right在同一y轴上进行对齐。

同上述所说,确定一个控件的位置需要xy的约束,那么换成边界,即在横向上start(left)、end(right)至少有一者受约束于其他控件或parent,在纵向上baseline、top、bottom至少有一者受约束于其他控件或parent,这样便可确定一个控件的位置。

2. Margins

在默认状态下,控件的在一个方向上仅设置一个约束,那么受约束的边界会与约束的控件的边界在该方向上重合,举个例子:

<androidx.constraintlayout.widget.ConstraintLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

TextView仅设置了start、top约束,并都约束于parent,即ConstraintLayout,那么TextView的位置应该在哪里,很简单,就是左上角:

那么如果需要离边界一定的位置,那么如何实现,这就引出了margin属性,可用属性如下:

  • android:layout_marginStart
  • android:layout_marginEnd
  • android:layout_marginLeft
  • android:layout_marginTop
  • android:layout_marginRight
  • android:layout_marginBottom

此时,需要TextView距离parent上面20dp,左边20dp,便可这么写

<androidx.constraintlayout.widget.ConstraintLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView"
        android:layout_marginStart="20dp"
        android:layout_marginTop="20dp"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

效果变成了如下:

相对应的会提出这么个问题,此时我设置marginEndmarginBottom会不会有效果,如果有,是什么效果?

很遗憾,在控件仅设置了topstart约束的情况下,marginEndmarginBottom没有任何效果,因为控件在x轴上仅有start约束,且约束于parent,设置marginStart,控件知道要与parent保持marginStart的距离,那么marginEnd也需要知道与哪个控件保持距离,然而现在没有设置这个方向的约束,所以marginEnd不知道与哪个控件维持这个距离,就导致了这个属性无效,marginBottom也是同理。

由此可以得出这个结论:在ConstraintLayout中,只有在某个方向上存在约束,那么该方向上的margin才会有效。

baseline

baseline是实现文字底线对齐的边界,也是在y轴方向上,那么marginTop与marginBottom会不会有效果?

然而,设置marginTop和marginBottom没有任何效果,与上述结论一样,如果有效的话,也应该存在marginBaseline这种属性,然而没有这种属性,就没法实现这种效果。如果要实现相应的效果,目前我所了解的实现方式也就tanslationY,可以算是一种怪异实现方式。

3. 居中与偏置

对于布局中,最常见的需求就是居中了,而在ConstraintLayout中很容易实现居中,就拿初始化Android项目的例子来说

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="TextView"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

TextView会处于整个屏幕的中心,那么是为何居中的?

借用官方的图

buttonA的right和left均有约束,那么right和left形成约束的两个控件在x轴之间的距离是确定的,可以认为形成了一条线,而buttonA就挂在了这条线上,可以在这条线上任意左移右移,运用到上面的例子中,TextView的layout_constraintStart_toStartOflayout_constraintEnd_toEndOf确定的线便是ConstraintLayout的左边界和右边界之间形成的一条线,TextView可以在这上面任意平移,默认是在中间的,这两个属性只确定了TextViw在x轴上的位置,那么同理,TextView在y轴上的位置可以由layout_constraintTop_toTopOflayout_constraintEnd_toEndOf实现,默认位置也在中间,所以最后效果便是相对于parent水平垂直居中。

Bias

那么buttonA如何在这条线上任意的平移?这就涉及到下面两个属性

  • layout_constraintHorizontal_bias
  • layout_constraintVertical_bias

这两个属性分别决定了控件在纵向和横向上的偏置

如官方给的图片,buttonA没有居中,反而往左偏了一点,这个实现如下

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/button"
        ...
        app:layout_constraintHorizontal_bias="0.3"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

layout_constraintHorizontal_bias设置为0.3,便可以看出buttonA向左偏移了一些。默认水平居中也是Android将layout_constraintHorizontal_bias默认设置为0.5。

那么这个比值是如何计算的?是否是以buttonA的中心在这条线上的位置比值进行计算的?

layout_constraintHorizontal_bias设置为0,效果如下

button的左边界与parent的左边界重合,如果按中心来,应该是中心与parent左边界重合

layout_constraintHorizontal_bias设置为1,效果如下

button的右边界与parent的右边界重合,如果按中心来,应该是中心与parent右边界重合

由此可见,这个比值实际上是button的left到形成约束的left边界与right到约束形成约束的right边界的距离之比,纵向上也是如此。

对齐

既然,button可以在整个ConstrainLayout中居中,那么两个控件的对齐也可以实现,这也是很常见的需求,举个例子:

代码如下

<Button
    ...
    android:text="A" />

<Button
    ...
    app:layout_constraintBottom_toBottomOf="@+id/buttonA"
    app:layout_constraintTop_toTopOf="@+id/buttonA" />

buttonB的top与bottom均约束于A,始终会在x轴上保持一致,就达到了水平对齐的效果,在纵向上也可以设置start和end实现垂直对齐的效果。

bias无效问题

代码和上面的一样,只不过buttonB的textSize改为50sp,那么这两个button的高度不一致,设置layout_constraintVertical_bias时,会发现,当值为0时,buttonB的top与buttonA的top对齐,当值为1时,buttonB的bottom与buttonA的bottom对齐,其他值时则会在这两个状态中变化。

那么当A和B的高度一致时,layout_constraintVertical_bias不同值会形成什么效果。

在官方文档上有这么一句话:

Unless the ConstraintLayout happens to have the exact same size as the Button, both constraints cannot be satisfied at the same time (both sides cannot be where we want them to be).

在这个例子中,也就是在纵轴上,当B和A的height一致时,layout_constraintVertical_bias属性是无效的,无论设置为什么值,B和A始终处于同一水平线上。这个问题的原因便是,buttonB的top是无法超过buttonA的top,即始终在buttonA的top之上,buttonB的bottom是无法超过buttonA的bottom,即始终在buttonA的bottom之下。在此基础上,为layout_constraintVertical_bias的比值计算提供的可能性,即top_B-top_Abottom_A-bottom_B的比值,当B与A的height一致时,top_B-top_Abottom_A-bottom_B始终为0,无论layout_constraintVertical_bias设置为何值,buttonB的top只能始终与buttonA的top维持同一水平线,bottom也是如此。

圆形定位

ConstraintLayout提供了一种圆形定位,这是在其他布局难以实现的,涉及下列三个属性:

  • layout_constraintCircle : 指向其他控件的id
  • layout_constraintCircleRadius: 距离其他控件的距离,即半径
  • layout_constraintCircleAngle : 角度,值是从0-360,0是指整上方

官方例子:

xml代码:

<Button android:id="@+id/buttonA" ... />
<Button android:id="@+id/buttonB" ...
    app:layout_constraintCircle="@+id/buttonA"
    app:layout_constraintCircleRadius="100dp"
    app:layout_constraintCircleAngle="45" />

4. 约束与margin

前面说明了约束形成的原因以及如何定位一个控件的,当一个控件存在约束时,设置margin,控件会如何变化,又为什么发生这种变化?

拿初始化项目的例子来说,TextView四个方向均有约束,处于水平垂直居中的状态,此时给TextView设置marginRight,会发现TextView左移了一段距离,查看布局,实际上是TextView的right约束向左移动了一段距离,right约束与parent的right边界维持了50pt距离,TextView在新的约束中进行居中对齐。

5. 可见性行为

现在存在这么一个例子,B约束于A,C约束于B,如果此时B的visibility属性设置为gone,C怎么变化?

当一个控件的visibility为gone时,应该消失在整个布局中,然而ConstraintLayout做了相应的处理,现在有如下布局

<Button
  android:id="@+id/A"
  android:layout_marginStart="40dp"
  android:layout_marginTop="116dp"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toTopOf="parent" />

<Button
  android:id="@+id/B"
  android:layout_marginStart="32dp"
  app:layout_constraintBottom_toBottomOf="@+id/A"
  app:layout_constraintStart_toEndOf="@+id/A"
  app:layout_constraintTop_toTopOf="@+id/A" />

<Button
  android:id="@+id/C"
  android:layout_marginTop="28dp"
  android:layout_marginEnd="56dp"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/C" />

此时将B设置为gone,效果如下

可以发现B变成的一个点,高度、宽度、margin之类的全部失效,这个点的生成位置就是top与bottom的中点为y值,如果只设置一个,则为该值,x值的生成也是如此,那么C可以以这个点所在x轴与y轴作为C的约束,进而确定位置。

考虑了这种情况,google提供了如下属性

  • layout_goneMarginStart
  • layout_goneMarginEnd
  • layout_goneMarginLeft
  • layout_goneMarginTop
  • layout_goneMarginRight
  • layout_goneMarginBottom

在这个例子中,当B设置为gone时,C的goneMargin*属性会替代对应的margin*属性。

6. 尺寸约束

对于每个在ConstraintLayout布局中的控件的 android:layout_widthandroid:layout_height的值可以设置成三种

  • 确定尺寸,如23dp
  • wrap_content,根据内容自适应
  • 0dp,即match_constraint

还是借用官方的例子

(a)为wrap_content,自适应内容宽度,(b)为0dp,占满整个约束宽度,如果设置了margin,可以参考之前的约束与margin的原理,导致了约束宽度变小,A的0dp计算的宽度也会变小

wrap_content: 强制执行约束

官方是这么说的:如果将尺寸设置为wrap_content,则在1.1之前的版本中,它们将被视为文字尺寸-意味着约束不会限制结果尺寸。通常,这已经足够了(并且更快),但在某些情况下,您可能想使用wrap_content,但仍要强制执行约束以限制结果尺寸。在这种情况下,您可以添加相应的属性之一:

  • app:layout_constrainedWidth=”true|false”
  • app:layout_constrainedHeight=”true|false”

这到底什么意思呢,下面举个例子:

现在A的约束宽度应该是deviceWidth - buttonWidth,A的宽度不应该超过这个值,现在A的文字内容变多,但是仍在一行中,会发现A的宽度超过了这个值,导致A的水平约束均失效,A的left与button的right不再对齐。

解决这个问题对办法就是给A设置app:layout_constrainedWidth=”true”,注意控件的左右都应该有约束条件。

A的宽度就会强制不大于约束宽度,内部文字会换行

match_constraint尺寸

当尺寸设置为match_constraint时,默认行为是控件尺寸会占满整个约束宽度,那么如果我想占满约束宽度的一定比例呢,这就涉及到下面几个属性:

  • layout_constraintWidth_minlayout_constraintHeight_min:将为此尺寸设置最小尺寸
  • layout_constraintWidth_maxlayout_constraintHeight_max:将为此尺寸设置最大尺寸
  • layout_constraintWidth_percentlayout_constraintHeight_percent:将此尺寸的尺寸设置为约束宽度的百分比

其中,maxmin可以设置的值可以是具体尺寸,或wrapwrap的取值和wrap_content效果一样。

比例尺寸

在日常开发中,会出现图片适配的需求,比如,主页的图片宽度与屏幕一致,宽高比为16:9,约束布局实现如下

<ImageView
  android:layout_width="0dp"
  android:layout_height="0dp"
  android:scaleType="centerCrop"
  android:src="@mipmap/icon"
  app:layout_constraintDimensionRatio="H,16:9"
  app:layout_constraintLeft_toLeftOf="parent"
  app:layout_constraintRight_toRightOf="parent"
  app:layout_constraintTop_toTopOf="parent"/>

新增下面这个属性app:layout_constraintDimensionRatio="H,16:9",在官方描述中,将小部件的一个尺寸定义为另一尺寸的比例。为此,您需要至少将一个约束维度设置为0dp(即match_constraint),并将属性layout_constraintDimensionRatio设置为给定的比率。

这个例子中宽度与约束宽度一样,高度与计算好的宽度成16:9的比例。layout_constraintDimensionRatio中可以有两种形式:

  • 浮点值,表示宽度和高度之间的比率
  • 形式为“宽度:高度”的比率

其中前面的W表示宽度由高度决定,H表示高度由宽度决定

7. 链(Chain)

链使单个轴上(水平或垂直)将一组控件相互关联起来,提供类似组的行为。成为一条链的条件:**一组控件它们通过一个双向的约束关系链接起来。**通过设置链头的属性来控制整个链。

链头

在横链中,链头元素为最左边的元素;在纵轴中,链头元素为最上面的元素。

链的样式

可以设置链头元素的layout_constraintHorizontal_chainStylelayout_constraintVertical_chainStyle,来控制整个链的样式,总共有下面三个取值

  • spread(默认值)
  • spread_inside
  • packed

Spread Chain

这种样式是默认样式,每个控件会在链中均匀排布,即控件之间的链的长度均分剩余空间,属性值为spread

Spread Inside Chain

属性值对应的为spread_inside,链上的两边的元素会向两边靠拢,剩下的元素会均匀分布。

Weighted Chain

属性值对应的为spread,如果一个或多个元素正在使用match_constraint,则它们将使用可用的空白空间(在它们之间平均分配)。属性layout_constraintHorizontal_weightlayout_constraintVertical_weight 将使用来控制如何在元素之间分配空间match_constraint

分配权重的原理

A的宽度为wrap_content,所以它的宽度与约束宽度(constraintWidth)无关,B和C的width均设置了0dp,那么宽度与constraintWidth有关,layout_constraintHorizontal_weight决定的所占剩余空间的大小,因为A的宽度是确定的,所以剩余空间为constraintWidth - Awidth,A所占宽度为\frac{weight_B}{weight_B+weight_C}*剩余空间,同理,B所占宽度为\frac{weight_B}{weight_B+weight_C}*剩余空间,这就是权重分配的原理。

Packed Chain

属性值对应的为packed,链上的所有元素之间的链没有长度,紧密形成一个组,可以看成是一个控件整体在横向约束上的位置。组内的元素之间距离由单个元素的margin进行控制。

Packed Chain with Bias

因为可以形成了一个组,可以看作是一个元素,其分布原理和单个元素在约束布局中的分布相同,bias的值由链头元素进行控制。

链与margin

A、B、C三个元素是按默认的chain进行排布,此时给B设置一个marginRight=100dp,此时剩余空间的计算会减去所有margin,然后平分给所有链。

计算链用于定位项目的剩余空间时,将项目及其边距一起考虑。

三、总结

本文只描述了ConstraintLayout中控件的行为方式,以及一些常用属性,关于ConstraintLayout的工具控件:Group、Guideline、Barrier,之后我有可能会继续写。

PS:这是我第一次写blog,有可能在叙述与整体逻辑上不太好的地方,请指正,如果觉得这种讲述方式还行的话,我会继续写这个系列的教程。