I/O’19 ConstraintLayout 那一些新東西

Jast Lai
Jastzeonic
Published in
31 min readMay 17, 2019

本文參照自 Google I/O’19 此 Session 的影片:

想了解更多可以直接觀看這部影片。

ConstrainLayout 是甚麼?

靈活的佈局管理者

ConstrainLayout 是一個允許你靈活地定義部件位置與大小的 ViewGroup

Constraint 翻譯成中文是指約束,雖然說是約束,但相對於 LinearLayout 或者是其實就只是普通地把 view render 上去的 FrameLayoutConstrintLayout 提供你鋪設布局的方法非常多樣化,也相對靈活的許多,但基本上要編排位置都需要一個對應的相對屬性,所以我一直覺得這東西比 RelativeLayout 更 relative 。

多樣化的輔助工具

那其實在這個 ViewGroup 當中除了把 parent(也就是 ConstraintLayout 自己)或者是其他的 Vie w當作相對的對象外, ConstraintLayout 還提供了不少的輔助道具,諸如 Guideline、Barriers、Placeholder 等,這些東西被稱為 Virtual Helper object,後簡稱 Helper 。

加上 Guidline 和 Barriers 的 Hello World TextView

平整的佈局層級

此外 ConstraintLayout 除了可以靈活地放置各式各樣的 widget 以外,最討喜的事情是他可以把拉平 Layout 層級,過往可能有一個 UI 佈局,為了讓他合乎各式螢幕大小,所以要使用到 RelativeLayoutLinearLayout 的組合佈局去解決,在某些情況下,可能得疊上個四五層說不定,但是用 ConstraintLayout 一層就能解決了。

可封裝的動作約束屬性

ConstraintLayout 通常在寫完之後會有海票屬性,這邊要更動甚麼的都很麻煩,那這邊很酷的是,androidx.constraintLayout 的 implementation 有提供一個 class 叫 ConstrainSet,搭配 Transtition 的話甚至可以辦到簡易的動畫效果。

ConstraintLayout 2.0

現在 ConstraintLayout 來到了 2.0.0-beta1。

implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta1'

2.0 除了優化 ConstraintLayout 其佈局效能,為了讓開發方向更靈活,所以新增了一點東西。

ConstraintHelper

這回開放了 ConstraintHelper 的 class ,也就是說,除了使用原本就有的 Helpers 以外,還可以自己構建自己的 Helper 了。

VirtualLayouts

VirtualLayout 是繼承自 ConstraintHelper 的抽象類別,意思就是提供給自訂 Helper 的一個類別之一,他擁有以下特性:

  • 保持 Layout 層級的平整
  • 可以幫助 Widget 定位
  • 同時也可以像 Barrir 提供參照 widget 的屬性變化
  • 然後可以 Runtime changes

Flow

他就是 VirtualLayout 的實作 ,他支援數種佈局的模式 :

  • Chains

利用 Flow 這個 helper ,可以輕易地建立利用它參照的 widget 建立 chain 的

Chain Style : spread_inside
Chain Style : packed
Chain Style : spread
  • Wrap chains

以前,用 Chain 當數量過多,整個長度總和超過一定量時,會直接 render 到邊界外,現在利用 Flow ,可以很輕鬆地解決這個問題。

另外也可以設定每行每列的數量上限:

  • 根據 rows && columns 對齊來包裹元件

上面的情況可以應付大多的問題,然而仍然是有例外地,例如說每一個 widget 的大小不同時呢?

會歪掉…

這個時候就可換成 wrap aligned mode 了。

那也由於 flow 是一個 VirtualLayout 物件,他其實只是提供給 ConstraintLayout 參照的物件,並不會讓 Layout 的層數增加,同時依然可以讓其他未被這個 Flow 參照的 widget 去對被其參照的 widget 去設定對應的屬性的物件。

Programming APIs

除了原有的 ConstraintSetAPI,也增加了更多的 API 以提供開發者使用

新增 ConstraintProperties 可以提供開發者設定 layout 上的 widget 屬性(不然原先麻煩死了)。

MotionLayout

影片裡前面帶很快,大概花了十分鐘講述上面的東西,所以有近三分之二的時間在講這個。然後現在 googleSample/ConstraintLayout 的 Description 也被 MotionLayout 佔住了大部分的篇幅了。

Motion 顧名思義,就是希望能夠製成一個很動態的 Layout ,而他是繼承自 ConstraintLayout 的,同時也被包在 ConstraintLayout 的 implementation 裏頭,可以在 2.0.0-alpha1 以後的版本找到。

那因為繼承自 ConstraintLayout 的緣故,所以理論上可以無痛轉移至 MotionLayout( tag 名稱直接換就好,其餘 ConstraintLayout 的東西都不用動)。

那其實 MotionLayout 最具體的差異就是他有個獨立的 xml 檔來做約束,以及一個叫 MotionScene file 的動作元素檔案,這些組合最酷的地方就是可以直接支援當 Layout 在數個 ConstraintSet 之間的變化用動畫來呈現。

Motion Concepts

那要如何讓 MotionLayout 動起來呢?首先要有一個開始(start)和結束(End)的模樣,最糟的情況應該用 Code 一行一行硬幹,不過也有更好的方法,那一個是直接建兩個 layout xml,另一個方法是把它寫在 MotionScene 裏頭用 ConstraintSet 包裝開始和結束得狀態,使用 KeyFrame 控制細節的變化,另外使用 OnSwipe 去決定觸發的時機點。

這邊簡單 Demo 一個動畫一段:

<androidx.constraintlayout.motion.widget.MotionLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/motionLayout"
app:layoutDescription="@xml/scene_02_autocomplete_false"
android:layout_width="match_parent"
android:layout_height="match_parent">

<View
android:id="@+id/button"
android:background="@color/colorAccent"
android:layout_width="64dp"
android:layout_height="64dp"
/>

</androidx.constraintlayout.motion.widget.MotionLayout>

與其對應的是 /res/xml 裏頭的 ConstraintSet ,開始與結束的要給 ID,然後在 Transition 給的開始與結束的 ConstaintSet id。

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start"
motion:duration="1000"/>

<ConstraintSet android:id="@+id/start">
<Constraint android:id="@id/button">
<Layout
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="8dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />
</Constraint>
</ConstraintSet>

<ConstraintSet
android:id="@+id/end"
motion:deriveConstraintsFrom="@id/start">

<Constraint android:id="@id/button">
<Layout
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginEnd="8dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintTop_toTopOf="parent"/>
</Constraint>
</ConstraintSet>

</MotionScene>

那因為這段並沒有一個觸發點,所以需要在 Code 裡面在加入 transitionToEnd()

import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

motionLayout.postDelayed({

motionLayout.transitionToEnd()
},
1000)
}
}

樣子就像是這樣。

看起來就像是用 Transition 去做的效果。

那這個也可以透過觸控去操作:只要加上 onSwap 就可以:

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start"
motion:duration="1000">
<OnSwipe
motion:dragDirection="dragRight"
motion:touchAnchorId="@id/button"
motion:onTouchUp="stop"
motion:touchAnchorSide="right" />
</Transition>


<ConstraintSet android:id="@+id/start">
<Constraint android:id="@id/button">
<Layout
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginStart="8dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintStart_toStartOf="parent"
motion:layout_constraintTop_toTopOf="parent" />
</Constraint>
</ConstraintSet>

<ConstraintSet
android:id="@+id/end"
motion:deriveConstraintsFrom="@id/start">

<Constraint android:id="@id/button">
<Layout
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginEnd="8dp"
motion:layout_constraintBottom_toBottomOf="parent"
motion:layout_constraintEnd_toEndOf="parent"
motion:layout_constraintTop_toTopOf="parent"/>
</Constraint>
</ConstraintSet>

</MotionScene>

看起來就會像這樣:

那上面的方法其實能移動的速度和範圍都比較線性一點,下面介紹一點 atrribute 可以讓 Motion 的動畫效果有點變化。

Keyframes

Keyframes 可以讓你在兩個狀態(通常是 start 和 end)之間,做針對該瞬間的 value 變化,那具體我們會透過下面幾個 xml 的 tag 去實現。

KeyPostion

這東西很有趣,可以讓目標物件有曲線效果,以上面的例子來說,因為是水平(X軸移動),所以需要動的

<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start"
motion:duration="1000">
<OnSwipe
motion:dragDirection="dragRight"
motion:touchAnchorId="@id/button"
motion:onTouchUp="stop"
motion:touchAnchorSide="right" />
<KeyFrameSet>
<KeyPosition
motion:keyPositionType="pathRelative"
motion:percentY="-0.5"
motion:framePosition="50"
motion:motionTarget="@id/button"/>
</KeyFrameSet>

</Transition>

//...ignore

</MotionScene>

keyPostionType 這個屬性有三種,分別是 deltaRelativepathRelativepathRelative,這邊拿 pathRelative 做範例。

percentY 代表垂直(Y 軸)起伏的幅度,一般限制在 -1 到 1 之間,

framePostion 代表變化最大影格位置,範圍是 0–100 ,意思是我們把這一整套動作拆成大約 100 格,假設我希望要動作的對象目標能轉 180 度再轉回來,那設定 framePosition 若為 20 ,那影格到 20 的時候會轉 180 度。

motionTarget 就是要動作的目標,不多做解釋。

加上這段的效果是這樣:

KeyAttribute

除了可以移動目標位置外,也可以讓物件本身的屬性變化,例如我希望他可以旋轉,或者是變大,那這時候就會用到 KeyAttribute 這個 tag。

以上面那個例子為例:

<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start"
motion:duration="1000">
<OnSwipe
motion:dragDirection="dragRight"
motion:touchAnchorId="@id/button"
motion:onTouchUp="stop"
motion:touchAnchorSide="right" />
<KeyFrameSet>
<KeyAttribute
android:scaleX="2"
android:scaleY="2"
android:rotation="180"
motion:framePosition="50"
motion:motionTarget="@id/button" />
</KeyFrameSet>

</Transition>

//...ignroe
</MotionScene>

效果是這樣:

想要放慢效果,結果手骨不順w

scaleXscaleYrotation 如果有用過 Android 的 Transition 應該都不會太陌生,分別是,寬度變形、高度變形跟轉動。

此外這是可以跟 KeyPosition 並用的,兩者並不衝突。

我預期的是它會轉動 180 度,之後轉一圈回到原點,但實際呈現出來的效果是,他轉到 180 度的位置後再反轉回來,原因很簡單,對 MotionLayout 來說,他只知道要轉到 180 度後再轉到 0 度,所以很自然轉到 180 後再撿回 0 度,所以這邊我讓他知道要轉到 360 度就可以。

<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start"
motion:duration="1000">
<OnSwipe
motion:dragDirection="dragRight"
motion:touchAnchorId="@id/button"
motion:onTouchUp="stop"
motion:touchAnchorSide="right" />
<KeyFrameSet>
<KeyAttribute
android:scaleX="2"
android:scaleY="2"
android:rotation="180"
motion:framePosition="50"
motion:motionTarget="@id/button" />

<KeyAttribute
android:rotation="360"
motion:framePosition="100"
motion:motionTarget="@id/button" />

</KeyFrameSet>
</Transition>

//..ignore
</MotionScene>

結果會是這樣:

嘛,程式是照寫的跑,不是照想的跑,這樣很正常,知道怎麼改就好。

那如果我希望背景能跟著變化呢?

這邊就會用到 CustomAttribute 了。

<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start"
motion:duration="2000">
<OnSwipe
motion:dragDirection="dragRight"
motion:touchAnchorId="@id/button"
motion:onTouchUp="stop"
motion:touchAnchorSide="right"/>
<KeyFrameSet>
<KeyFrameSet>
//...ignore
<KeyAttribute
motion:framePosition="25"
motion:motionTarget="@+id/background">
<CustomAttribute
motion:attributeName="BackgroundColor"
motion:customColorValue="#97C0Be"/>
</KeyAttribute>
</KeyFrameSet>
</KeyFrameSet>
</Transition>

<ConstraintSet android:id="@+id/start">
<Constraint android:id="@+id/background">
<CustomAttribute
motion:attributeName="BackgroundColor"
motion:customColorValue="@android:color/white"/>
</Constraint>
<Constraint android:id="@id/button">
//...ignore
</Constraint>
</ConstraintSet>

<ConstraintSet
android:id="@+id/end"
motion:deriveConstraintsFrom="@id/start">
<Constraint android:id="@+id/background">
<CustomAttribute
motion:attributeName="BackgroundColor"
motion:customColorValue="@android:color/holo_purple"/>
</Constraint>

<Constraint android:id="@id/button">
//...ignore
</Constraint>
</ConstraintSet>

</MotionScene>

樣子看起來就會是這樣:

CustomAttribute 可以用得方法也不少,詳細可以看這裡

KeyCycle

Cycle 指得是週期,在這邊意思就是一次波的週期,幾乎玩動畫不可避免地要聊到三角函數(眼神死),我這邊不想講得太複雜。

我這邊純以 sin 舉例,一次 sin 的週期,就是從平緩處(0)至波峰(1)再至波谷(-1)最後回到平緩處(0)為一次。

一次 sin 的 cycle 代表從 0 至 1 再從 1 至 -1 最後回到 0

那我這邊的情況是希望 motion 對象會上下擺動,xml 會寫成這樣

<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start"
motion:duration="2000">
<OnSwipe
motion:dragDirection="dragRight"
motion:touchAnchorId="@id/button"
motion:onTouchUp="stop"
motion:touchAnchorSide="right"/>
<KeyFrameSet>

<KeyCycle
motion:framePosition="0"
motion:motionTarget="@id/button"
motion:waveOffset="0"
motion:wavePeriod="0"
motion:waveShape="sin"
android:translationY="100dp"/>

<KeyCycle
motion:framePosition="50"
motion:motionTarget="@id/button"
motion:waveOffset="0"
motion:wavePeriod="1"
motion:waveShape="sin"
android:translationY="100dp"/>
<KeyCycle
motion:framePosition="100"
motion:motionTarget="@id/button"
motion:waveOffset="0"
motion:wavePeriod="0"
motion:waveShape="sin"
android:translationY="100dp"/>

</KeyFrameSet>
</Transition>
//...ignore

</MotionScene>

樣子看起來會是這樣:

解釋一下幾個 attribute 的意思

waveOffset 可以理解為開始的預設數值,以上面這例子來說如果設定為 10 ,則一開始 motion 對象則會在開始時轉動 10 度。

waveShape 指的是波的類型,類型很多,有sin|square|triangle|sawtooth|reverseSawtooth|cos|bounce,以這個 case 來說差別在波的形狀,換成 scale 或是 transition 會在極端時表現得比較銳利一點,這裡單純一點直接用 sin。

wavePeriod 指的是鄰近區域的週期次數。

可以簡單理解成 f(x) = sin(wavePeriod * x)。

鄰近區域指的是從當前這個 tag 的 framePosition 與前一個 tag framePosition 中間點,到和後一個 tag 的 framePosition 的中間點。而 0 和 100 是例外,這兩個因為是開始和結束的點,所以分別會變成是後一個 tag 的開始點和前一個 tag 的結束點。而 warePeriod 就會是這兩個點之間的波數。

比如說這裡設定了一個 famePosition 分別為 0 、 20 、40、80、100 ,然後 warePeriod 設定分別為 1、2、2、1、1 意思就是 0 到 30 這個區段會有 3 個波,30 到 60 這個區段會有 2 個波,而 60 到 100 這個區段會有 2 個波。

意思就是路線會變成 sin(3x) -> sin(2x) -> sin(2x),如下圖:

那個尖角歪掉請無視,現實是螢幕大小不實際能整除,所以會有一點小誤差

那要注意的是如果沒有給 framePosition = 0 的 tag ,可能是因為需要一個對應的點做開頭,所以會找 framePosition 最接近 0 的做填滿,波數會變成 (最接近 0 的wavePeriod)*2 + 其餘的 wavePeriod。

PS:上面那段話因為還在 beta 階段,原始碼還只能 byte code decompiler 的狀態下,我研究了一個早上 + 半個夜晚,這個特性我覺得有點莫名,在猜以後有可能會變。

另外 value (translation 、 scale、rotation 設定的值)則會根據後一個 tag 的 value 大小分別遞增遞減。

KeyTimeCycle

KeyCycle 類似,但最大的差別在於 wavePeroid 在這個 tag 底下指的是每一秒週期的次數(在這個 tag 終於顧名思義了),那要注意的是這個秒數跟 Trasition 的時長無關,只要在 frams 設定的區域範圍內,就會觸發。

至於區域的界定與 KeyCycle 一樣,然後如果沒有一個點的 wavePeroid 是 0 的話,動作是永遠不會停的。

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:motion="http://schemas.android.com/apk/res-auto">

<Transition
motion:constraintSetEnd="@+id/end"
motion:constraintSetStart="@+id/start"
motion:duration="2000">
<OnSwipe
motion:dragDirection="dragRight"
motion:touchAnchorId="@id/button"
motion:onTouchUp="stop"
motion:touchAnchorSide="right"/>
<KeyFrameSet>
<KeyTimeCycle
motion:framePosition="0"
motion:motionTarget="@id/button"
motion:waveOffset="0"
motion:wavePeriod="0"
motion:waveShape="sin"
android:translationY="100dp"
/>

<KeyTimeCycle
motion:framePosition="20"
motion:motionTarget="@id/button"
motion:waveOffset="0"
motion:wavePeriod="2"
motion:waveShape="sin"
android:translationY="100dp"/>

<KeyTimeCycle
motion:framePosition="40"
motion:motionTarget="@id/button"
motion:waveOffset="0"
motion:wavePeriod="2"
motion:waveShape="sin"
android:translationY="100dp"/>

<KeyTimeCycle
motion:framePosition="80"
motion:motionTarget="@id/button"
motion:waveOffset="0"
motion:wavePeriod="1"
motion:waveShape="sin"
android:translationY="100dp"/>
<KeyTimeCycle
motion:framePosition="100"
motion:motionTarget="@id/button"
motion:waveOffset="0"
motion:wavePeriod="0"
motion:waveShape="sin"
android:translationY="100dp"/>
</KeyFrameSet>
</Transition>
//...ignore

</MotionScene>

樣子看起來就是這樣:

那個活塞裝置…

Programmatic Control

照影片裡說的, MotionLayout 也提供了很多方法給開發者,讓他變成可用 Code 去做控制的,也就是可以根據使用者的操作更多樣化了,像上面 KeyCycle 的波形圖我便是使用 TransitionListener 去實作的,其他在影片裡有羅列幾個 method。

transitionTo(R.id.state)這個就很直白的 transition to constraintSet,可以做到類似的效果:

setMotionScene(R.xml.ms)我找了老半天找不到這個 method ,比較類似的是 loadLayoutDescription(R.xml.ms) 這也確實會直接切換 MotionScene

getConstraintSet(R.id.state) 這個可以從當前的 MotionScene 取出 ConstraintSet,而你可以針對其屬性做修改這樣。

Integration with helpers

此外也因為是繼承自 ConstrainLayout ,所以理論上 MotionScene 與 helper 整合互動的,這樣會讓該 Layout 的變化更多樣化。

然後中後段 demo 了一個很酷的 Time Line 工具。

有 Time Line 的 Demo ,非常酷,不過仍在開發階段

這讓我想起當初用 Flash 做動畫的樣子(不過仔細瞧瞧還是有不少出入)。

Building Custom Components

因為 MotionLayout 可以靈活地動作其內容,影片中後段很強調利用它也可以讓你的畫面變得更為動態。

稍微玩了一下,真的相對於之前少寫很多 Code

如果有興趣了解他更多的做法,可以參考這裏

結語

雖然標題叫做 ConstraintLayout 不過篇幅大半是在介紹 MotionLayout。每次寫動畫都是很令人興奮,但寫完之後卻每次又會有出乎意料的挫折感。寫這篇文章的過程中,自己使用 MotionLayout 想達成某個效果,但是實際下去寫仍然還是有所限制,所以我自己的結論是,其實他也沒有想像中的這麼靈活,而且其實以往用 animator 或者是 transition 都做得到,但是使用它真的會少寫很多 Code 。

目前他已經進入 beta 版,真的有興趣玩的話,可以去試試看,而且他目前也能從 ConstraintLayout 無痛轉移,兩者用法基本上是相同的,所以如果有在使用 ConstraintLayout 的話,真的很推薦去玩玩看。

這篇也花了幾天去寫,過程可能有錯字或一些東西沒詳細解釋到的,如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我邊緣人我寫文章最大的動力。

--

--

Jast Lai
Jastzeonic

A senior who happened to be an Android engineer.