帶你領略 MontionLayout 的魅力(上)


最初接觸到 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 的子類並且基於它自身豐富的布局功能來進行構建。

當然,你也可以按照字面意思將它簡單理解為“運動布局”。為什么這么說呢?通過上圖來對比傳統的布局組件(如:FrameLayoutLinearLayout 等),我們不難發現: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的構成

由上圖可知,MotionLayout 可分為 <View><Helper> 兩個部分。<View> 部分可簡單理解為一個 ConstraintLayout,至於 <Helper> 其實就是我們的“動畫層”了。MotionLayout 為我們提供了 layoutDescription 屬性,我們需要為它傳入一個 MotionScene 包裹的 XML 文件,想要實現動畫交互,就必須通過這個“媒介”來連接。

MotionScene:傳說中的“百寶袋”

什么是 MotionScene?結合上圖 MotionScene 主要由三部分組成:StateSetConstraintSetTransition。為了讓大家快速理解和使用 MotionScene,本文將重點講解 ConstarintSetTransition,至於 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 設置它的 MotionScenestep1,接下來就讓我們一睹 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",然后就能輕易的查看其動畫內部的運動軌跡:

開啟debug模式

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

百花齊放效果

首先,讓我們分析一下這個效果:仔細看我們可以發現,通過向上滑動藍色的 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 為藍色小機器人即可,怎么樣,是不是有種“拍案驚奇”的感覺😁。此外,你可以通過指定 touchAnchorSidedragDirection 等來指定自己想要的滑動手勢和滑動方向,默認為向上滑動,手勢滑動我們將在后面示例中穿插使用和講解,這里不做具體介紹,忍不住的小伙伴可以去查看一下官方文檔介紹。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 的構成:

KeyPosition的組成

從上圖可見,keyPositionType 一共有三種,本文使用的是 parentRelative,即以整個 MotionLayout 的布局為坐標系,左上角為坐標原點,即參考 View 的坐標系即可,而另外兩種將在后續文章統一講解和應用,它們的區別在於坐標系選取的參考點不同而已。我們通過 framePosition 屬性來指定關鍵幀所在的位置,取值范圍為 0 - 100,本示例中設置的 50 即為動畫中點位置。另外,可以通過指定 percentXpercentY 來設置該關鍵幀位置的偏移量,它們取值一般為 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)

Defining motion paths in MotionLayout

MotionLayout development

最后

本文的出發點是希望僅僅為大家提供一個“鑰匙孔”的角色,通過這個“孔”,大家可以依稀看見門里“寶藏”的余光,想要打開門尋得寶藏,就需要大家"事必躬親",拿到“鑰匙”來打開這扇門了😄。當然,大家也可以繼續關注我的后續之作,來發現更多 MontionLayout 的寶藏。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM