最初接觸到 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
ConstraintLayout
and 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
的寶藏。