最初接觸到 MotionLayout 是在國外知名博客的 Android 專欄上。第一眼見到 MotionLayout 時無疑是興奮的,在經過使用和熟悉了這個布局組件之后,我就想將這份喜悅傳遞給國內開發者,從此“拳打”設計,“腳踢”產品😁。當然,由於關於 MotionLayout 的外文專欄相關介紹已足夠詳細,所以本文僅對其進行總結和簡單應用。老規矩,正文開始前先上一張圖:

簡介
由於本文的受眾需要有一點 ConstraintLayout 的用法基礎,如果你對它並不熟悉,可以先去花幾分鍾看一下本人之前的譯文:帶你領略 ConstraintLayout 1.1 的新功能。回到正題,什么是 MontionLayout ?很多人可能會對這個名詞比較陌生,但如果說到它的前身 — ConstraintLayout,大家應該就多少有些了解了。MontionLayout 其實是 Google 在去年開發者大會上新推的布局組件。我們先來看看 Android 官方對於它的定義:
MotionLayout is a layout type that helps you manage motion and widget animation in your app. MotionLayout is a subclass of
ConstraintLayoutand builds upon its rich layout capabilities.
簡單翻譯過來就是:MontionLayout 是一個能夠幫助我們在 app 中管理手勢和控件動畫的布局組件。它是 ConstraintLayout 的子類並且基於它自身豐富的布局功能來進行構建。
當然,你也可以按照字面意思將它簡單理解為“運動布局”。為什么這么說呢?通過上圖來對比傳統的布局組件(如:FrameLayout、LinearLayout 等),我們不難發現:MotionLayout 是布局組件中的一個“里程碑”,由此開始就告別了 XML 文件中只能”靜態“操作 UI 的歷史。通過 MotionLayout,我們就能更加輕易處理其內部子 View 的手勢操作和"運動"效果了。正如 Nicolas Roard 所說的那樣:
你可以在 MontionLayout 功能方面將其看作是屬性動畫、TransitionManager 和 CoordinatorLayout 的結合體。
MotionLayout 基礎
首先,我們需要從 MotionLayout 的一些基本屬性和用法講起,這樣對於我們后面的實際操作將會很有幫助。
引入 MotionLayout 庫
dependencies {
implementation 'com.android.support.constraint:constraint-layout:2.0.0-beta2'
}
目前,MotionLayout 仍處於 beta 版本,雖然官方之前說過 MotionLayout 的動畫輔助工具將會在 beta 版本推出,但目前還沒有出現,不出意外應該是在下一個版本了。到時候應該就可以像 ConstraintLayout 那樣直接通過布局編輯器來進行部分預覽和參數操作了。
在布局文件中使用 MotionLayout
想要使用 MotionLayout,只需要在布局文件中作如下聲明即可:
<android.support.constraint.motion.MotionLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/scene1">
</android.support.constraint.motion.MotionLayout>
由於 MotionLayout 作為 ConstraintLayout 的子類,那么就自然而然地可以像 ConstraintLayout 那樣使用去“約束”子視圖了,不過這可就有點“大材小用了”,MotionLayout 的用處可遠不止這些。我們先來看看 MotionLayout 的構成:

由上圖可知,MotionLayout 可分為 <View> 和 <Helper> 兩個部分。<View> 部分可簡單理解為一個 ConstraintLayout,至於 <Helper> 其實就是我們的“動畫層”了。MotionLayout 為我們提供了 layoutDescription 屬性,我們需要為它傳入一個 MotionScene 包裹的 XML 文件,想要實現動畫交互,就必須通過這個“媒介”來連接。
MotionScene:傳說中的“百寶袋”
什么是 MotionScene?結合上圖 MotionScene 主要由三部分組成:StateSet、ConstraintSet 和 Transition。為了讓大家快速理解和使用 MotionScene,本文將重點講解 ConstarintSet 和 Transition,至於 StateSet 狀態管理將會在后續文章中為大家介紹具體用法和場景。同時,為了幫助大家理解,此處將開始結合一些具體小實例來幫助大家快速理解和使用它。
首先,我們從實現下面這個簡單的效果講起:

GIF 畫質有點渣,見諒,但從上圖我們可以發現這是一個簡單的平移動畫,通過點擊自身(籃球)來觸發,讓我們來通過 MotionLayout 的方式來實現它。首先來看下布局文件:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.motion.MotionLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/step1"
tools:context=".practice.MotionSampleActivity">
<ImageView
android:id="@+id/ball"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_basketball"/>
</android.support.constraint.motion.MotionLayout>
布局文件很簡單,只不過你可能會注意到,我們對 ImageView 並沒有添加任何約束,原因在於:我們會在 MotionScene 中聲明 ConstraintSet,里面將包含該 ImageView 的“運動”起始點和終點的約束信息。當然你也可以在布局文件中對其加以約束,但 MotionScene 中對於控件約束的優先級會高於布局文件中的設定。這里我們通過 layoutDescription 來為 MotionLayout 設置它的 MotionScene 為 step1,接下來就讓我們一睹 MotionScene 的芳容:
<?xml version="1.0" encoding="utf-8"?>
<!--describe the animation for activity_motion_sample_step1.xml-->
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<!-- A transition describes an animation via start and end state -->
<Transition
app:constraintSetStart="@id/start"
app:constraintSetEnd="@id/end"
app:duration="2200">
<OnClick
app:targetId="@id/ball"
app:clickAction="toggle" />
</Transition>
<!-- Constraints to apply at the start of the animation -->
<ConstraintSet android:id="@+id/start">
<Constraint
android:id="@+id/ball"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
</ConstraintSet>
<!-- Constraints to apply at the end of the animation -->
<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@+id/ball"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</ConstraintSet>
</MotionScene>
首先,可以發現我們定義了兩個 <ConstraintSet>,分別描述了這個🏀 ImageView 的動畫起始位置以及結束位置的約束信息(僅包含少量必要信息,如:width、height、margin以及位置屬性等)。顯而易見,籃球的起始位置為屏幕左上角,結束位置為屏幕右下角,那么問題來了,如何讓它動起來呢?這就要依靠我們的 <Transition> 元素了。事實上,我們都知道,動畫都是有開始位置和結束位置的,而 MotionLayout 正是利用這一客觀事實,將首尾位置和動畫過程分離,兩個點位置和距離雖然是固定的,但是它們之間的 Path 是無限的,可以是“一馬平川”,也可以是"蜿蜒曲折"的。
回到上面這個例子,我們只需要為 Transition 設置起始位置和結束位置的 ConstraintSet 並設置動畫時間即可,剩下的都交給 MotionLayout 自動去幫我們完成。當然你也可以通過 onClick 點擊事件來觸發動畫,綁定目標控件的 id 以及通過 clickAction 屬性來設置點擊事件的類型,這里我們設置的是 toggle,即通過反復點擊控件來切換動畫的狀態,其他還有很多屬性可以參照官方文檔去研究,比較簡單,這里就不一一講解它們的效果了。如此一來,運行一下就能看到上面的效果了。另外,為了方便測試,我們可以給 MotionLayout 加上調試屬性:app:motionDebug="SHOW_PATH",然后就能輕易的查看其動畫內部的運動軌跡:

什么?你說這個動畫效果太基礎?那好,我就來個簡陋版的“百花齊放”效果吧,比如下面這樣:

首先,讓我們分析一下這個效果:仔細看我們可以發現,通過向上滑動藍色的 Android 機器人,紫色和橙色的機器人會慢慢淡出並分別忘左上角和右上角移動。布局文件很簡單,一把梭就OK了😂:
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.motion.MotionLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:motionDebug="SHOW_PATH"
app:layoutDescription="@xml/step2"
tools:context=".practice.MotionSampleActivity">
<ImageView
android:id="@+id/ic_android_blue"
android:layout_width="42dp"
android:layout_height="42dp"
android:src="@mipmap/android_icon_blue"/>
<ImageView
android:id="@+id/ic_android_left"
android:layout_width="42dp"
android:layout_height="42dp"
android:src="@mipmap/android_icon_purple"/>
<ImageView
android:id="@+id/ic_android_right"
android:layout_width="42dp"
android:layout_height="42dp"
android:src="@mipmap/android_icon_orange"/>
<TextView
android:id="@+id/tipText"
android:text="Swipe the blue android icon up"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginEnd="16dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toTopOf="parent"/>
</android.support.constraint.motion.MotionLayout>
下面我們來看下 step2 中的 MotionScene:
<?xml version="1.0" encoding="utf-8"?>
<!--describe the animation for activity_motion_sample_step2.xml-->
<!--animate by dragging target view-->
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<!--At the start, all three stars are centered at the bottom of the screen.-->
<ConstraintSet android:id="@+id/start">
<Constraint
android:id="@+id/ic_android_blue"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_marginBottom="20dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Constraint
android:id="@+id/ic_android_left"
android:layout_width="42dp"
android:layout_height="42dp"
android:alpha="0.0"
android:layout_marginBottom="20dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Constraint
android:id="@+id/ic_android_right"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_marginBottom="20dp"
android:alpha="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</ConstraintSet>
<!--Define the end constraint to set use a chain to position all three stars together below @id/tipText.-->
<ConstraintSet android:id="@+id/end">
<Constraint
android:id="@+id/ic_android_left"
android:layout_width="58dp"
android:layout_height="58dp"
android:layout_marginEnd="90dp"
android:alpha="1.0"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/ic_android_blue"
app:layout_constraintTop_toBottomOf="@id/tipText"/>
<Constraint
android:id="@+id/ic_android_blue"
android:layout_width="58dp"
android:layout_height="58dp"
app:layout_constraintEnd_toStartOf="@id/ic_android_right"
app:layout_constraintStart_toEndOf="@id/ic_android_left"
app:layout_constraintTop_toBottomOf="@id/tipText"/>
<Constraint
android:id="@+id/ic_android_right"
android:layout_width="58dp"
android:layout_height="58dp"
android:layout_marginStart="90dp"
android:alpha="1.0"
app:layout_constraintStart_toEndOf="@id/ic_android_blue"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/tipText"/>
</ConstraintSet>
<!-- A transition describes an animation via start and end state -->
<Transition
app:constraintSetStart="@id/start"
app:constraintSetEnd="@id/end">
<!-- MotionLayout will track swipes relative to this view -->
<OnSwipe app:touchAnchorId="@id/ic_android_blue"/>
</Transition>
</MotionScene>
上面代碼其實很好理解,之前我們定義了一個控件的 Constraint,現在只需要多加兩個即可。由於三個 Android 機器人起點位置是一樣的,而只有藍色的顯示,那么只要在開始位置將另外的兩個機器人透明度設置為 0 即可,然后在結束位置將三個小機器人分開擺放,這里設計到 ConstraintLayout 的基礎,就不多說了。接着將結束位置的左、右 Android 機器人透明度設置為 1,動畫開始后,MotionLayout 會自動處理目標控件 alpha 屬性的變化效果,讓其看起來依舊絲滑。
另外,我們這里沒有再通過 <OnClick> 來觸發動畫效果,類似的,我們使用了 <OnSwipe> 手勢滑動來觸發動畫,只需要指定 touchAnchorId 為藍色小機器人即可,怎么樣,是不是有種“拍案驚奇”的感覺😁。此外,你可以通過指定 touchAnchorSide 和 dragDirection 等來指定自己想要的滑動手勢和滑動方向,默認為向上滑動,手勢滑動我們將在后面示例中穿插使用和講解,這里不做具體介紹,忍不住的小伙伴可以去查看一下官方文檔介紹。OK,就這樣,我們上面的偽“百花齊放”效果就已經實現了,沒什么難的對吧😄。
到這里,你可能會說:前面兩個示例的動畫軌跡一直是"直線",如果想要某段動畫過程的軌跡是"曲線"效果可以嗎?當然沒問題!Keyframes 關鍵幀幫你安排!
KeyFrameSet:讓動畫獨樹一幟
如果我們想實現“獨樹一幟”的動畫交互效果,那就離不開 KeyFrameSet 這個強大的屬性。它可以改變我們動畫過程中某個關鍵幀的位置以及狀態信息。這樣說可能不太好理解,我們先來看下面這個示例:

以大家的慧眼不難發現:風車的運動軌跡為曲線,並且旋轉並放大至中間位置時會達到零界點,然后開始縮小。布局代碼就不上了,很簡單,里面唯一重要的就是我們需要實現的 MontionScene 效果 — step3.xml 了:
<?xml version="1.0" encoding="utf-8"?>
<!--describe the animation for activity_motion_sample_step3.xml-->
<!--animate in the path way on a view-->
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<!-- Constraints to apply at the start of the animation -->
<ConstraintSet android:id="@+id/start">
<Constraint
android:id="@id/windmill"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:layout_marginBottom="12dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Constraint
android:id="@id/tipText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="@id/windmill"
app:layout_constraintTop_toTopOf="@id/windmill"/>
</ConstraintSet>
<!-- Constraints to apply at the end of the animation -->
<ConstraintSet android:id="@+id/end">
<!--this view end point should be at bottom of parent-->
<Constraint
android:id="@id/windmill"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginBottom="12dp"
android:layout_marginEnd="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Constraint
android:id="@+id/tipText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:alpha="1.0"
android:layout_marginEnd="72dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</ConstraintSet>
<!-- A transition describes an animation via start and end state -->
<Transition
app:constraintSetStart="@id/start"
app:constraintSetEnd="@id/end">
<KeyFrameSet>
<KeyPosition
app:framePosition="50"
app:motionTarget="@id/windmill"
app:keyPositionType="parentRelative"
app:percentY="0.5"/>
<!--apply other animation attributes-->
<!--前半段的動畫效果:逆時針旋轉一圈,同時放大一倍-->
<KeyAttribute
app:motionTarget="@id/windmill"
android:rotation="-360"
android:scaleX="2.0"
android:scaleY="2.0"
app:framePosition="50"/>
<!--后半段的動畫效果:逆時針旋轉一圈,同時變回原樣-->
<KeyAttribute
app:motionTarget="@id/windmill"
android:rotation="-720"
app:framePosition="100"/>
<!--延遲動畫——0-85過程中將透明度一直維持在0.0-->
<KeyAttribute
app:motionTarget="@id/tipText"
app:framePosition="85"
android:alpha="0.0"/>
</KeyFrameSet>
<OnSwipe
app:touchAnchorId="@id/windmill"
app:touchAnchorSide="bottom"
app:dragDirection="dragRight"/>
</Transition>
</MotionScene>
從上述代碼我們可以發現:KeyFrameSet 需要被包含在 Transition 里面,同時 KeyFrameSet 中定義了 <KeyPosition> 和 <KeyAttribute> 兩種元素,它們主要用來設置動畫某個位置的關鍵幀,進而為某段動畫指定所期望的效果。顧名思義,KeyPosition 用於指定動畫某個關鍵幀的位置信息,而 KeyAttribute 則用來描述動畫某關鍵幀的屬性配置(如:透明度、縮放、旋轉等)。除此以外,KeyFrameSet 中還支持 <KeyCycle> 和 <KeyTimeCycle> 來讓動畫變得更加有趣和靈活,因篇幅有限,將在后續文章對二者進行講解。
我們先來看下 KeyPosition 的構成:

從上圖可見,keyPositionType 一共有三種,本文使用的是 parentRelative,即以整個 MotionLayout 的布局為坐標系,左上角為坐標原點,即參考 View 的坐標系即可,而另外兩種將在后續文章統一講解和應用,它們的區別在於坐標系選取的參考點不同而已。我們通過 framePosition 屬性來指定關鍵幀所在的位置,取值范圍為 0 - 100,本示例中設置的 50 即為動畫中點位置。另外,可以通過指定 percentX 和 percentY 來設置該關鍵幀位置的偏移量,它們取值一般為 0 — 1,當然也可以設置為負數或者大於一,比如,本示例中如果沒有設置偏移量,那么動畫的軌跡無疑是一條平行於 x 軸的直線,但通過設置 app:percentY="0.5",那么風車就會在動畫中點位置向 y 軸方向偏移一半的高度,即下圖的效果(開始 debug 模式):

可能會有人問了:為什么軌跡不是三角形,而是曲線呢?哈哈,這個問題問得好!因為 MotionLayout 會自動地將關鍵幀位置盡量銜接的圓滑,讓動畫執行起來不那么僵硬。其他代碼應該就比較好理解了,可以參照文檔理解。
了解完 KeyFrameSet 的用法,那么我們就很輕易的實現下面這個效果啦:

代碼就不貼了,MotionLayout 系列代碼都會上傳至 GitHub 上,感興趣的小伙伴可以去看一下。不知不覺已經講了這么多,但發現還有很多內容沒有涉及到或是講清楚,由於篇幅有限,就只能放在后面幾期來為大家介紹啦😄。如果大家覺得對本文有什么問題或者建議,歡迎評論區留言,知無不言,言無不盡。
本文全部代碼:https://github.com/Moosphan/ConstraintSample
后續文章將繼續跟進相關進階用法,該倉庫也將持續更新,敬請期待~
參考和感謝:
Introduction to MotionLayout (part I)
Introduction to MotionLayout (part II)
Introduction to MotionLayout (part III)
最后
本文的出發點是希望僅僅為大家提供一個“鑰匙孔”的角色,通過這個“孔”,大家可以依稀看見門里“寶藏”的余光,想要打開門尋得寶藏,就需要大家"事必躬親",拿到“鑰匙”來打開這扇門了😄。當然,大家也可以繼續關注我的后續之作,來發現更多
MontionLayout的寶藏。
