I/O’19 ConstraintLayout 那一些新東西
本文參照自 Google I/O’19 此 Session 的影片:
想了解更多可以直接觀看這部影片。
ConstrainLayout 是甚麼?
靈活的佈局管理者
ConstrainLayout
是一個允許你靈活地定義部件位置與大小的 ViewGroup
。
Constraint 翻譯成中文是指約束,雖然說是約束,但相對於 LinearLayout
或者是其實就只是普通地把 view render 上去的 FrameLayout
, ConstrintLayout
提供你鋪設布局的方法非常多樣化,也相對靈活的許多,但基本上要編排位置都需要一個對應的相對屬性,所以我一直覺得這東西比 RelativeLayout
更 relative 。
多樣化的輔助工具
那其實在這個 ViewGroup
當中除了把 parent(也就是 ConstraintLayout
自己)或者是其他的 Vie w當作相對的對象外, ConstraintLayout
還提供了不少的輔助道具,諸如 Guideline、Barriers、Placeholder 等,這些東西被稱為 Virtual Helper object,後簡稱 Helper 。
平整的佈局層級
此外 ConstraintLayout
除了可以靈活地放置各式各樣的 widget 以外,最討喜的事情是他可以把拉平 Layout 層級,過往可能有一個 UI 佈局,為了讓他合乎各式螢幕大小,所以要使用到 RelativeLayout
和 LinearLayout
的組合佈局去解決,在某些情況下,可能得疊上個四五層說不定,但是用 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 的
- 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
這個屬性有三種,分別是 deltaRelative
、 pathRelative
、 pathRelative
,這邊拿 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>
效果是這樣:
scaleX
、 scaleY
跟 rotation
如果有用過 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)為一次。
那我這邊的情況是希望 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 工具。
這讓我想起當初用 Flash 做動畫的樣子(不過仔細瞧瞧還是有不少出入)。
Building Custom Components
因為 MotionLayout
可以靈活地動作其內容,影片中後段很強調利用它也可以讓你的畫面變得更為動態。
如果有興趣了解他更多的做法,可以參考這裏。
結語
雖然標題叫做 ConstraintLayout
不過篇幅大半是在介紹 MotionLayout
。每次寫動畫都是很令人興奮,但寫完之後卻每次又會有出乎意料的挫折感。寫這篇文章的過程中,自己使用 MotionLayout
想達成某個效果,但是實際下去寫仍然還是有所限制,所以我自己的結論是,其實他也沒有想像中的這麼靈活,而且其實以往用 animator 或者是 transition 都做得到,但是使用它真的會少寫很多 Code 。
目前他已經進入 beta 版,真的有興趣玩的話,可以去試試看,而且他目前也能從 ConstraintLayout
無痛轉移,兩者用法基本上是相同的,所以如果有在使用 ConstraintLayout
的話,真的很推薦去玩玩看。
這篇也花了幾天去寫,過程可能有錯字或一些東西沒詳細解釋到的,如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我邊緣人我寫文章最大的動力。