帶你領(lǐng)略MotionLayout的魅力(中)

距離上一篇文章「 MotionLayout:打開(kāi)動(dòng)畫新世界大門(part I)」已經(jīng)過(guò)去了很久未桥,由于個(gè)人原因巢掺,MotionLayout 系列文章姍姍來(lái)遲。在之前的文章中律想,我們領(lǐng)略到了 MotionLayout 的魅力逞壁,了解到它繼承自 ConstraintLayout流济,并具有它“約束布局”的特性。同時(shí)腌闯,關(guān)于如何創(chuàng)建和使用 MotionScene 及其內(nèi)部的 KeyFrameSet 也都做了一些簡(jiǎn)單介紹绳瘟。那么,本文來(lái)帶大家進(jìn)一步探索 KeyFrameSet 這個(gè)大家族中的“神秘寶藏”姿骏,并針對(duì)上文中留下的一些彩蛋進(jìn)行講解糖声,來(lái)看看如何實(shí)現(xiàn) MotionLayout 與其他控件的聯(lián)動(dòng)

再探索 KeyFrameSet

在上文中我們說(shuō)到 KeyFrameSet 能夠讓單調(diào)的動(dòng)畫獨(dú)樹一幟分瘦,可以根據(jù)我們的意愿來(lái)描述動(dòng)畫運(yùn)動(dòng)的軌跡姨丈。之前只是比較詳細(xì)介紹了 KeyFrameSet 這個(gè)大家族中的 Keyposition,那么本文就來(lái)和大家窺探一下其他寶藏的秘密吧擅腰。

首先,我們來(lái)看一張熟悉的 MotionLayout 結(jié)構(gòu)圖:

image

從上圖我們可以看到翁潘,KeyFrameSet 中主要包含了KeyPosition趁冈、KeyAttribute 以及 KeyCycle 三種類型的關(guān)鍵幀。其實(shí)除此以外拜马,KeyFrameSet 還提供了 KeyTimeCycleKeyTrigger渗勘,具體的用法和使用場(chǎng)景會(huì)在后續(xù)文章進(jìn)行介紹。本文中俩莽,我們先來(lái)詳細(xì)看一下 KeyAttribute 以及 KeyCycle旺坠。

KeyAttribute

我們知道,KeyPosition 描述的是目標(biāo) View 在某個(gè)位置的關(guān)鍵幀扮超,進(jìn)而改變動(dòng)畫的移動(dòng)軌跡取刃,至于 KeyAttribute蹋肮,則是描述這個(gè) View 在某個(gè)關(guān)鍵幀時(shí)所處的“狀態(tài)”,即所謂的”高矮胖瘦“璧疗。前者側(cè)重的是改變動(dòng)畫的軌跡坯辩,后者則是強(qiáng)調(diào)更改 View 自身的屬性

key attribute結(jié)構(gòu)圖

從上圖的 KeyAttribute 結(jié)構(gòu)圖中我們可以看到崩侠,它支持各種屬性漆魔,足夠我們來(lái)描述一個(gè) View 的狀態(tài)了。假如我們希望實(shí)現(xiàn)如下效果:

小球運(yùn)動(dòng)效果

其實(shí)上面的動(dòng)畫實(shí)現(xiàn)很簡(jiǎn)單却音,只需要在特定位置添加一些“關(guān)鍵幀”就可以了:

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

    <Transition
            app:constraintSetStart="@+id/start"
            app:constraintSetEnd="@+id/end"
            app:duration="3200"
            app:motionInterpolator="bounce">
        <KeyFrameSet>
            <KeyAttribute
                    app:motionTarget="@+id/loading_ball"
                    app:framePosition="20"
                    android:scaleX="1.5"
                    android:scaleY="1.5"
                    android:alpha="0.7"/>
            <KeyAttribute
                    app:motionTarget="@+id/loading_ball"
                    app:framePosition="35"
                    android:scaleX="1"
                    android:scaleY="1"
                    android:alpha="1"/>
            <KeyAttribute
                    app:motionTarget="@+id/loading_ball"
                    app:framePosition="50"
                    android:scaleX="1.5"
                    android:scaleY="1.5"
                    android:alpha="0.7"/>
            <KeyAttribute
                    app:motionTarget="@+id/loading_ball"
                    app:framePosition="65"
                    android:scaleX="1"
                    android:scaleY="1"
                    android:alpha="1"/>
            <KeyAttribute
                    app:motionTarget="@+id/loading_ball"
                    app:framePosition="80"
                    android:scaleX="1.5"
                    android:scaleY="1.5"
                    android:alpha="0.7"/>
            <KeyAttribute
                    app:motionTarget="@+id/loading_ball"
                    app:framePosition="95"
                    android:scaleX="1"
                    android:scaleY="1"
                    android:alpha="1" />
        </KeyFrameSet>
        <OnClick app:targetId="@+id/loading_ball"
                app:clickAction="toggle"/>
    </Transition>
    <ConstraintSet android:id="@+id/start">
        <Constraint
                android:id="@id/loading_ball"
                android:layout_width="32dp"
                android:layout_height="32dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="0.15"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintVertical_bias="0.5"/>
    </ConstraintSet>

    <ConstraintSet android:id="@+id/end">
        <Constraint
                android:id="@+id/loading_ball"
                android:layout_width="32dp"
                android:layout_height="32dp"
                app:layout_constraintBottom_toBottomOf="parent"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintHorizontal_bias="0.85"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"
                app:layout_constraintVertical_bias="0.5" />
    </ConstraintSet>
</MotionScene>

最終代碼如上所示改抡,是不是很 easy?這里我們?cè)谕窘?jīng)路線中添加一些特定的 keyAttribute 系瓢,并改變它們的屬性狀態(tài)阿纤,這里變化的屬性只涉及到 scaleXscaleYalpha 八拱。

考慮到 KeyAttribute 中提供的屬性有限阵赠,所以,CustomAttribute 橫空出世肌稻,它支持任意自定義的屬性清蚀,常見(jiàn)的有 TextViewtextColorbackground 或者是 ImageViewsrc 爹谭、tint 等枷邪。當(dāng)然還不止這些,我們平時(shí)自定義 View 中提供的自定義屬性同樣支持哦诺凡。就像 GitHub 上的一個(gè) ShapeOfView 的開(kāi)源項(xiàng)目东揣,可以提供給我們自定義控件形狀的功能,那么結(jié)合了 MotionLayout 中的 CustomAttribute腹泌,我們就可以達(dá)到下面這種平滑轉(zhuǎn)換的效果:

配合shapeview的效果

舉個(gè)簡(jiǎn)單的例子嘶卧,上面的小球加載動(dòng)畫我們希望它能夠在運(yùn)動(dòng)過(guò)程中顏色也隨之變化,然而 <KeyAttribute> 中并沒(méi)有提供相關(guān)屬性凉袱,這里我們就可以借助于 <CustomAttribute> 來(lái)實(shí)現(xiàn)啦芥吟。改動(dòng)部分代碼如下所示:

......
<ConstraintSet android:id="@+id/start">
    <Constraint
            android:id="@id/loading_ball"
            android:layout_width="32dp"
            android:layout_height="32dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.15"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.5">
        <CustomAttribute
            app:attributeName="colorFilter"
                app:customColorValue="@android:color/holo_blue_light"/>
    </Constraint>

</ConstraintSet>

<ConstraintSet android:id="@+id/end">
    <Constraint
            android:id="@+id/loading_ball"
            android:layout_width="32dp"
            android:layout_height="32dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.85"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.5">
        <CustomAttribute
                app:attributeName="colorFilter"
                app:customColorValue="@color/colorAccent"/>
    </Constraint>
</ConstraintSet>

我們?cè)O(shè)置了 colorFilter 屬性,作用相當(dāng)于 tint专甩,重新運(yùn)行后钟鸵,可以看到如下效果:

彩色小球運(yùn)動(dòng)

需要我們注意的是,這里的自定義屬性的 attributeName 對(duì)應(yīng)的值并不一定是在 xml 布局文件中控件對(duì)應(yīng)的屬性名稱涤躲,而是在對(duì)應(yīng)控件中擁有 setter 設(shè)置的屬性名稱棺耍。怎么理解呢?其實(shí)歸根結(jié)底 CustomAttribute 內(nèi)部還是利用的反射种樱,從下面的部分源碼中就能夠察覺(jué)到:

public void applyCustomAttributes(ConstraintLayout constraintLayout) {
        int count = constraintLayout.getChildCount();

        for(int i = 0; i < count; ++i) {
            View view = constraintLayout.getChildAt(i);
            int id = view.getId();
            if (!this.mConstraints.containsKey(id)) {
                Log.v("ConstraintSet", "id unknown " + Debug.getName(view));
            } else {
                if (this.mForceId && id == -1) {
                    throw new RuntimeException("All children of ConstraintLayout must have ids to use ConstraintSet");
                }

                if (this.mConstraints.containsKey(id)) {
                    ConstraintSet.Constraint constraint = (ConstraintSet.Constraint)this.mConstraints.get(id);
                    ConstraintAttribute.setAttributes(view, constraint.mCustomConstraints);
                }
            }
        }

    }

......
  
public static void setAttributes(View view, HashMap<String, ConstraintAttribute> map) {
        Class<? extends View> viewClass = view.getClass();
        Iterator var3 = map.keySet().iterator();

        while(var3.hasNext()) {
            String name = (String)var3.next();
            ConstraintAttribute constraintAttribute = (ConstraintAttribute)map.get(name);
            String methodName = "set" + name;

            try {
                Method method;
                switch(constraintAttribute.mType) {
                case COLOR_TYPE:
                    method = viewClass.getMethod(methodName, Integer.TYPE);
                    method.invoke(view, constraintAttribute.mColorValue);
                    break;
                case COLOR_DRAWABLE_TYPE:
                    method = viewClass.getMethod(methodName, Drawable.class);
                    ColorDrawable drawable = new ColorDrawable();
                    drawable.setColor(constraintAttribute.mColorValue);
                    method.invoke(view, drawable);
                    break;
                case INT_TYPE:
                    method = viewClass.getMethod(methodName, Integer.TYPE);
                    method.invoke(view, constraintAttribute.mIntegerValue);
                    break;
                case FLOAT_TYPE:
                    method = viewClass.getMethod(methodName, Float.TYPE);
                    method.invoke(view, constraintAttribute.mFloatValue);
                    break;
                case STRING_TYPE:
                    method = viewClass.getMethod(methodName, CharSequence.class);
                    method.invoke(view, constraintAttribute.mStringValue);
                    break;
                case BOOLEAN_TYPE:
                    method = viewClass.getMethod(methodName, Boolean.TYPE);
                    method.invoke(view, constraintAttribute.mBooleanValue);
                    break;
                case DIMENSION_TYPE:
                    method = viewClass.getMethod(methodName, Float.TYPE);
                    method.invoke(view, constraintAttribute.mFloatValue);
                }
            } catch (NoSuchMethodException var9) {
                Log.e("TransitionLayout", var9.getMessage());
                Log.e("TransitionLayout", " Custom Attribute \"" + name + "\" not found on " + viewClass.getName());
                Log.e("TransitionLayout", viewClass.getName() + " must have a method " + methodName);
            } catch (IllegalAccessException var10) {
                Log.e("TransitionLayout", " Custom Attribute \"" + name + "\" not found on " + viewClass.getName());
                var10.printStackTrace();
            } catch (InvocationTargetException var11) {
                Log.e("TransitionLayout", " Custom Attribute \"" + name + "\" not found on " + viewClass.getName());
                var11.printStackTrace();
            }
        }

    }  

首先在 MotionLayout 中蒙袍,如果是自定義屬性俊卤,那么會(huì)執(zhí)行 ConstraintSet 類中的 applyCustomAttributes 方法,接著會(huì)調(diào)用 ConstraintAttribute 類中的 setAttributes 方法左敌,就如上代碼中所寫的那樣瘾蛋,它會(huì)根據(jù)屬性名稱組裝成對(duì)應(yīng)的 set 方法,然后通過(guò)反射調(diào)用矫限。是不是有種恍然大悟的感覺(jué)哺哼?話說(shuō),這樣的機(jī)制是不是好像哪里見(jiàn)到過(guò)叼风?沒(méi)錯(cuò)取董,正是屬性動(dòng)畫

KeyCycle

什么是 KeyCycle 呢无宿?下面是來(lái)自 Gal Maoz 的總結(jié):

A KeyCycle is a highly-detailed, custom-made interpolator for a specific view, whereas the interpolator is influencing the entire scene, with a large focus on repetitive actions (hence the cycle in the name).

簡(jiǎn)單來(lái)說(shuō)茵汰,KeyCycle 是針對(duì)特定視圖的非常詳細(xì)的定制化插值器。它比較適合我們常說(shuō)的波形或周期運(yùn)動(dòng)場(chǎng)景孽鸡,比如實(shí)現(xiàn)控件的抖動(dòng)動(dòng)畫或者周期性的循環(huán)動(dòng)畫蹂午。

keycycle結(jié)構(gòu)圖

如上圖所示,KeyCycle 主要由以上幾個(gè)屬性組成彬碱,前兩個(gè)相信大家都比較熟悉了豆胸,這里不必多說(shuō),另外 view properties 正如之前的 KeyAttribute 結(jié)構(gòu)圖中所描述的那樣巷疼,代表View的各種屬性晚胡,如 rotation、translation嚼沿、alpha 等等估盘。 這里主要介紹另外三個(gè)比較重要且具有特色的屬性:

  • wavePeriod:這個(gè)表示在當(dāng)前場(chǎng)景位置下需要執(zhí)行動(dòng)畫的波(周期)的數(shù)量。這樣說(shuō)可能不太容易理解骡尽,別急遣妥,我們待會(huì)舉個(gè)例子說(shuō)明。
  • waveOffset:表示當(dāng)前控件需要變化的屬性的偏移量攀细,即 view properties 所對(duì)應(yīng)的初始值或者基準(zhǔn)值箫踩。例如,如果我們?cè)趧?dòng)畫執(zhí)行的某個(gè)位置設(shè)置了 scaleX 為 0.3辨图,而設(shè)置了 waveOffset 值為 1,那么肢藐,動(dòng)畫執(zhí)行到該位置故河,控件的實(shí)際寬度會(huì)變?yōu)?1 + 0.3 = 1.3,也就是會(huì)擴(kuò)大為 1.3 倍吆豹,而不是縮小為之前的 0.3 倍鱼的。
  • waveShape:這個(gè)屬性比較好理解理盆,即波的形狀,常見(jiàn)的值有:sin凑阶、cos猿规、sawtooth 等,更多可參考官網(wǎng)API:https://developer.android.com/reference/androidx/constraintlayout/motion/widget/MotionLayout#keycycle

下面舉個(gè)簡(jiǎn)單的例子幫助理解宙橱,以下面這個(gè)效果為例:

按鈕回彈效果

對(duì)應(yīng)的 KeyFrameSet 代碼如下所示:

<KeyFrameSet>
    <KeyCycle 
            motion:framePosition="0"
            motion:target="@+id/button"
            motion:wavePeriod="0"
            motion:waveOffset="1"
            motion:waveShape="sin"
            android:scaleX="0.3"/>
    <KeyCycle 
            motion:framePosition="18"
            motion:target="@+id/button"
            motion:wavePeriod="0"
            motion:waveOffset="1"
            motion:waveShape="sin"
            android:scaleX="0.3"/>
    <KeyCycle 
            motion:framePosition="100"
            motion:target="@+id/button"
            motion:wavePeriod="3"
            motion:waveOffset="1"
            motion:waveShape="sin"
            android:scaleX="0"/>
</KeyFrameSet>

根據(jù)動(dòng)畫效果結(jié)合代碼可以知道姨俩,我們這個(gè)放大的Q彈的效果只是改變了 scaleX 這個(gè)屬性,并且讓它“搖擺了”大概三個(gè)來(lái)回(周期)师郑,恰好 wavePeriod 屬性值為 3环葵。也許動(dòng)畫不太方便察覺(jué),這樣宝冕,我們借助于 Google 提供的專門用來(lái)查看 KeyCycle 波形變化的快捷工具來(lái)查看它波形變化過(guò)程:

CycleEditor展示波形圖

如此一來(lái)张遭,我們就很直觀地看到上圖中描繪的波形變化過(guò)程了,的確是三個(gè)周期沒(méi)有錯(cuò)地梨,并且是以正弦 sin 來(lái)變化的菊卷。

關(guān)于這款工具的使用,大家可以前往:https://github.com/googlearchive/android-ConstraintLayoutExamples/releases/download/1.0/CycleEditor.jar 上下載宝剖,然后通過(guò)執(zhí)行 java -jar [xx/CycleEditor.jar] 即可看到可視化界面洁闰,然后將 KeyFrameSet 部分的代碼 copy 到編輯欄,然后點(diǎn)擊 File -> parse xml 即可看到代碼對(duì)應(yīng)的波形走勢(shì)诈闺。如下所示:

CycleEditor編輯頁(yè)面

我們來(lái)看看下面這個(gè)效果:

KeyCycle應(yīng)用

這個(gè)Q彈的效果就是基于 KeyCycle 實(shí)現(xiàn)的渴庆,我們來(lái)看看它的場(chǎng)景實(shí)現(xiàn):

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

    <Transition
        app:constraintSetStart="@+id/start"
        app:constraintSetEnd="@+id/end"
        app:motionInterpolator="easeInOut"
        app:duration="5200">
        <KeyFrameSet>
            <KeyCycle
                app:motionTarget="@+id/image"
                app:framePosition="10"
                android:rotationY="22"
                app:wavePeriod="2"
                app:waveShape="sin"
                app:waveOffset="1"/>
            <KeyCycle
                app:motionTarget="@+id/image"
                app:framePosition="30"
                android:rotationX="15"
                app:wavePeriod="1"
                app:waveShape="sin"
                app:waveOffset="0"/>
            <KeyCycle
                app:motionTarget="@+id/image"
                app:framePosition="65"
                android:rotationY="14"
                app:wavePeriod="1"
                app:waveShape="sin"
                app:waveOffset="0"/>
            <KeyCycle
                app:motionTarget="@+id/image"
                app:framePosition="92"
                android:rotationY="0"
                android:rotationX="2"
                app:wavePeriod="0"
                app:waveShape="sin"
                app:waveOffset="0"/>
        </KeyFrameSet>
        <OnClick app:targetId="@+id/image"
                app:clickAction="toggle"/>
    </Transition>
    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@+id/image"
            android:layout_width="120dp"
            android:layout_height="120dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.76"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.45"/>

    </ConstraintSet>

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@+id/image"
            android:layout_width="120dp"
            android:layout_height="120dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.76"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintVertical_bias="0.45"/>
    </ConstraintSet>
</MotionScene>

我們?cè)趧?dòng)畫路徑上添加一些關(guān)鍵幀,并稍微改變控件的旋轉(zhuǎn)角度雅镊,配合 keyCycle 就能達(dá)到上面的彈性動(dòng)畫襟雷,大家可以自己動(dòng)手嘗試體驗(yàn)一下。

MotionLayout 的聯(lián)動(dòng)性

很多時(shí)候仁烹,我們的控件并不只是單一的個(gè)體耸弄,而是需要與其他控件產(chǎn)生“交互上的關(guān)聯(lián)”,常見(jiàn)地卓缰,Android 的Material design components 全家桶中提供了一套“優(yōu)雅靈動(dòng)”的組件计呈,相信大家都體驗(yàn)過(guò)了,那么征唬,我們的 MotionLayout 可以與它們碰撞出怎樣的火花呢捌显?

一切從“頭”開(kāi)始

Material design 組件庫(kù)中提供了一個(gè) AppBarLayout 組件,我們經(jīng)常使用它來(lái)配合 CoordinatorLayout 控件實(shí)現(xiàn)一些簡(jiǎn)單的交互動(dòng)作总寒,例如頭部導(dǎo)航欄的伸縮效果扶歪,各位應(yīng)該或多或少都用到過(guò),這里不再介紹摄闸。下面我們就從 AppBarLayout 開(kāi)始善镰,看看如何實(shí)現(xiàn)與 MotionLayout 的聯(lián)動(dòng)妹萨。首先,我們先來(lái)看下面這個(gè)簡(jiǎn)單的效果:

與appbar聯(lián)動(dòng)

我們知道炫欺,通過(guò) CoordinatorLayoutAppBarLayout 也可以實(shí)現(xiàn)類似的交互效果乎完,但顯然 MotionLayout 會(huì)更加靈活多變。其實(shí)上面的動(dòng)畫效果很簡(jiǎn)單品洛,只是在 AppBarLayout 高度變化過(guò)程中改變背景色树姨、標(biāo)題的位置和大小即可,對(duì)應(yīng)的 MotionScene 文件代碼如下所示:

<?xml version="1.0" encoding="utf-8"?>
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:motion="http://schemas.android.com/tools">
    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@+id/background"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            motion:layout_constraintBottom_toBottomOf="parent">
            <CustomAttribute
                app:attributeName="backgroundColor"
                app:customColorValue="@color/blue_magic"/>
        </Constraint>
        <Constraint
            android:id="@+id/tipText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:scaleY="1.6"
            android:scaleX="1.6"
            android:alpha="1.0"
            android:layout_marginStart="62dp"
            android:layout_marginTop="12dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"/>
    </ConstraintSet>

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

        <Constraint
            android:id="@id/background"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            motion:layout_constraintBottom_toBottomOf="parent">
            <CustomAttribute
                app:attributeName="backgroundColor"
                app:customColorValue="@color/bgColor_dark"/>
        </Constraint>
        <Constraint
            android:id="@id/tipText"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="20dp"
            android:layout_marginBottom="12dp"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>
    </ConstraintSet>

    <Transition
        app:constraintSetStart="@id/start"
        app:constraintSetEnd="@id/end"
        app:duration="4000">
        <KeyFrameSet>
            <KeyPosition
                app:framePosition="60"
                app:motionTarget="@id/tipText"
                app:keyPositionType="parentRelative"
                app:percentY="0.7"/>
        </KeyFrameSet>
    </Transition>

</MotionScene>

結(jié)合以上效果圖毫别,我們很容易理解上面的場(chǎng)景實(shí)現(xiàn)代碼娃弓,那么,我們?cè)賮?lái)看下布局文件:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/content"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="false"
    android:background="@android:color/white"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appBarLayout"
        android:layout_width="match_parent"
        android:layout_height="260dp"
        android:theme="@style/AppTheme.AppBarOverlay">
        <com.moos.constraint.widget.MotionToolBar
            android:id="@+id/motionLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:motionDebug="NO_DEBUG"
            app:layoutDescription="@xml/motion_scene_simple_appbar"
            android:minHeight="52dp"
            app:layout_scrollFlags="scroll|enterAlways|snap|exitUntilCollapsed">
            <View
                android:id="@+id/background"
                android:layout_width="match_parent"
                android:layout_height="300dp"
                android:background="@color/blue_magic" />

            <TextView
                android:id="@+id/tipText"
                android:text="Time flies fast"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textSize="20sp"
                android:textColor="@color/white"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toTopOf="parent"/>
        </com.moos.constraint.widget.MotionToolBar>
    </com.google.android.material.appbar.AppBarLayout>
    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textColor="@color/content_text_color"
            android:lineSpacingExtra="8dp"
            android:padding="12dp"
            android:text="@string/long_text_en"/>

    </androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

觀察上面布局文件岛宦,其實(shí)代碼與傳統(tǒng) CoordinatorLayout & AppBarLayout 交互的代碼大同小異台丛,只不過(guò)我們?cè)?AppBarLayout 內(nèi)部添加了一個(gè) MotionToolBar 控件,這其實(shí)是個(gè) MotionLayout砾肺,只不過(guò)內(nèi)部根據(jù) AppBarLayout 伸縮的高度動(dòng)態(tài)改變動(dòng)畫進(jìn)度而已挽霉,我們來(lái)看下具體實(shí)現(xiàn):

class MotionToolBar @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : MotionLayout(context, attrs, defStyleAttr), AppBarLayout.OnOffsetChangedListener {

    override fun onOffsetChanged(appBarLayout: AppBarLayout?, verticalOffset: Int) {
        Log.e("MotionToolBar", "onOffsetChanged: ----->$verticalOffset, scroll range--> ${appBarLayout?.totalScrollRange}")
        val seekPosition = -verticalOffset / (appBarLayout?.totalScrollRange!!.toFloat()/5*3)
        progress = seekPosition
    }

    override fun onAttachedToWindow() {
        super.onAttachedToWindow()
        (parent as? AppBarLayout)?.addOnOffsetChangedListener(this)
    }
}

代碼量很少,通過(guò)在 onOffsetChanged 方法中監(jiān)聽(tīng) AppBarLayout 的伸縮高度变汪,并經(jīng)過(guò)換算后得到當(dāng)前的進(jìn)度值傳遞給 progress侠坎,該字段就對(duì)應(yīng)著 MotionLayout 的 setProgress 方法,如此一來(lái)就能夠動(dòng)態(tài)的改變其動(dòng)畫進(jìn)度了裙盾。

理解了上述代碼实胸,就不難實(shí)現(xiàn)下面的效果了:

與appbar聯(lián)動(dòng)進(jìn)階

具體代碼就不貼了,文末會(huì)附上 GitHub 倉(cāng)庫(kù)地址番官,所有效果實(shí)現(xiàn)代碼都能夠在里面找到庐完。

Lottie 與 MotionLayout 的雙劍合璧

Lottie 想必大家都了解過(guò),它是一個(gè)動(dòng)畫工具徘熔,能夠?qū)?UI 的設(shè)計(jì)動(dòng)畫效果轉(zhuǎn)為 Json 格式的數(shù)據(jù)文件门躯,然后各端都提供了相應(yīng)的庫(kù)來(lái)解析并執(zhí)行動(dòng)畫文件,很多時(shí)候需要花費(fèi)大量時(shí)間去借助于代碼實(shí)現(xiàn)的復(fù)雜動(dòng)畫酷师,如今不費(fèi)吹灰之力就搞定了讶凉,很大程度上解放了我們的雙手。

那么山孔,Lottie 與 MotionLayout 一起能夠碰撞出怎樣的火花呢懂讯?我們以下面的一個(gè)簡(jiǎn)單效果為例:

與Lottie聯(lián)動(dòng)

其實(shí)簡(jiǎn)單來(lái)說(shuō),MotionLayout 能夠?qū)⒆陨淼膭?dòng)畫過(guò)程與 Lottie 同步台颠,就像圖中的安卓機(jī)器人動(dòng)畫就是 MotionLayout 實(shí)現(xiàn)的褐望,而下面的卡通人物眼神游離的動(dòng)畫則是 Lottie 動(dòng)畫,從圖中可以看到,通過(guò)手勢(shì)滑動(dòng) ViewPager 兩個(gè)動(dòng)畫一直保持著“同步運(yùn)動(dòng)”譬挚。下面我們來(lái)看看如何實(shí)現(xiàn)的,首先是布局文件酪呻,比較簡(jiǎn)單:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/motionView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/white"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <com.moos.constraint.widget.ViewpagerHeader
        android:id="@+id/header"
        android:layout_width="match_parent"
        android:layout_height="300dp"
        app:layoutDescription="@xml/motion_with_view_pager"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:motionProgress="0">
        <com.airbnb.lottie.LottieAnimationView
            android:id="@+id/lottieView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:lottie_rawRes="@raw/face"/>
        <ImageView
            android:id="@+id/ic_robot"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@drawable/ic_launcher_foreground"/>
    </com.moos.constraint.widget.ViewpagerHeader>
    <com.google.android.material.tabs.TabLayout
        android:id="@+id/tabLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:tabSelectedTextColor="@color/colorAccent"
        app:tabTextColor="@color/content_text_color"
        app:layout_constraintTop_toBottomOf="@+id/header"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent">
    </com.google.android.material.tabs.TabLayout>

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintTop_toBottomOf="@+id/tabLayout"
        app:layout_constraintBottom_toBottomOf="parent">
    </androidx.viewpager.widget.ViewPager>
</androidx.constraintlayout.widget.ConstraintLayout>

至于這個(gè) ViewPagerHeader 相信大家也猜到了减宣,其實(shí)也是個(gè) MotionLayout :

class ViewpagerHeader @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : MotionLayout(context, attrs, defStyleAttr), androidx.viewpager.widget.ViewPager.OnPageChangeListener {

    override fun onPageScrollStateChanged(state: Int) {
    }

    override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
        val animateProgress = (position.toFloat() + positionOffset)/3
        Log.e("LottieMotionActivity", "viewpager scroll progress is: $animateProgress")
        progress = animateProgress
    }

    override fun onPageSelected(position: Int) {
    }
}

只不過(guò)它內(nèi)部實(shí)現(xiàn)了 ViewPageronPageChangeListener,以監(jiān)聽(tīng)頁(yè)面的滑動(dòng)狀態(tài)玩荠,然后計(jì)算出此時(shí) MotionLayout 的動(dòng)畫進(jìn)度漆腌,這里由于 json 動(dòng)畫文件存在問(wèn)題,所以只截取了一部分動(dòng)畫過(guò)程來(lái)執(zhí)行阶冈。說(shuō)了這么多闷尿,它的 MotionScene 是什么樣的呢?其實(shí)很 easy:

<?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"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <Transition
        motion:constraintSetStart="@+id/start"
        motion:constraintSetEnd="@+id/end">
    </Transition>

    <ConstraintSet android:id="@+id/start">
        <Constraint
            android:id="@id/lottieView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            motion:progress="0"/>
        <Constraint
            android:id="@id/ic_robot"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="20dp"
            motion:layout_constraintTop_toTopOf="parent"
            motion:layout_constraintStart_toStartOf="parent">
            <CustomAttribute
                app:attributeName="colorFilter"
                app:customColorValue="@android:color/holo_blue_light"/>
        </Constraint>
    </ConstraintSet>

    <ConstraintSet android:id="@+id/end">
        <Constraint
            android:id="@id/lottieView"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            motion:progress="1"/>
        <Constraint
            android:id="@id/ic_robot"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="20dp"
            motion:layout_constraintTop_toTopOf="parent"
            motion:layout_constraintEnd_toEndOf="parent">
            <CustomAttribute
                app:attributeName="colorFilter"
                app:customColorValue="@color/colorAccent"/>
        </Constraint>
    </ConstraintSet>
</MotionScene>

唯一需要值得注意的是:這里我們分別在 MotionLayout 的起始位置和終止位置設(shè)置了 motion:progress 屬性為 0 和 1女坑,由于 LottieAnimationView 內(nèi)部擁有 setProgress 方法填具,這樣做的目的就是將 Lottie 的動(dòng)畫過(guò)程與 MotionLayout 進(jìn)行綁定,我們只需要改變這個(gè)屬性匆骗,就能夠間接控制 Lottie 動(dòng)畫啦劳景。

最后,我們只需要在 Activity 中設(shè)置如下代碼就可以成功執(zhí)行啦:

    val adapter = ViewPagerAdapter(supportFragmentManager)
    adapter.addPage("Now", R.layout.holder_layout)
    adapter.addPage("Discover", R.layout.holder_layout)
    viewPager.adapter = adapter
    tabLayout.setupWithViewPager(viewPager)
    viewPager.addOnPageChangeListener(header as                 androidx.viewpager.widget.ViewPager.OnPageChangeListener)

當(dāng)然碉就,MotionLayout 還能和很多組件進(jìn)行聯(lián)動(dòng)盟广,篇幅有限就不一一介紹啦,到這里瓮钥,我們本篇文章內(nèi)容也差不多該告一段落了筋量,關(guān)于 MotionLayout 系列文章的所有示例代碼都能夠在 GitHub 倉(cāng)庫(kù)中找到:

https://github.com/Moosphan/MotionLayoutSamples

后續(xù)

如此一來(lái),MotionLayout 系列已經(jīng)完成兩篇文章了碉熄,剩下的內(nèi)容應(yīng)該還需要一篇文章來(lái)容納桨武,后續(xù)可能還會(huì)額外提供一篇實(shí)戰(zhàn)系列文章。下一篇文章主要介紹 KeyFrameSet 家族最后一個(gè)成員以及 MotionLayout 多狀態(tài)場(chǎng)景的使用具被,同時(shí)玻募,也會(huì)介紹如何實(shí)現(xiàn)與 RecyclerView “強(qiáng)強(qiáng)聯(lián)合”。最后一姿,Google 在 Android studio 4.2 終于推出了 Motion Editor 工具七咧,下篇文章也會(huì)通過(guò)一個(gè)小實(shí)戰(zhàn)項(xiàng)目來(lái)介紹其用法,拭目以待叮叹。

筆者說(shuō)

最近這兩篇文章都盡量做到每個(gè)重要知識(shí)點(diǎn)都提供一個(gè)實(shí)戰(zhàn)的小示例艾栋,力求做到加深理解,文中很多內(nèi)容都參考自 Nicolas Roard 對(duì)于 MotionLayout 的系列教程和 Android 官方文檔蛉顽,并加入自己的理解蝗砾。從去年編撰第一篇文章時(shí)來(lái)看,國(guó)內(nèi)對(duì)于 MotionLayout 的系列文章非常少,寫文章的目的其實(shí)很簡(jiǎn)單悼粮,讓自己消化新知識(shí)的同時(shí)闲勺,也能夠讓更多國(guó)人知道、認(rèn)識(shí)和嘗試使用 MotionLayout 這個(gè)全新的動(dòng)畫組件扣猫。

由于個(gè)人技術(shù)能力和表述能力有限菜循,很多內(nèi)容可能并沒(méi)有講解全面和透徹,如果有什么建議或者問(wèn)題申尤,歡迎留言區(qū)探討癌幕,一起進(jìn)步。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末昧穿,一起剝皮案震驚了整個(gè)濱河市勺远,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌时鸵,老刑警劉巖胶逢,帶你破解...
    沈念sama閱讀 218,858評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異饰潜,居然都是意外死亡宪塔,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,372評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門囊拜,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)某筐,“玉大人,你說(shuō)我怎么就攤上這事冠跷∧咸埽” “怎么了?”我有些...
    開(kāi)封第一講書人閱讀 165,282評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵蜜托,是天一觀的道長(zhǎng)抄囚。 經(jīng)常有香客問(wèn)我,道長(zhǎng)橄务,這世上最難降的妖魔是什么幔托? 我笑而不...
    開(kāi)封第一講書人閱讀 58,842評(píng)論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮蜂挪,結(jié)果婚禮上钟沛,老公的妹妹穿的比我還像新娘榆骚。我一直安慰自己睡扬,他們只是感情好比吭,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,857評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著严肪,像睡著了一般史煎。 火紅的嫁衣襯著肌膚如雪谦屑。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書人閱讀 51,679評(píng)論 1 305
  • 那天篇梭,我揣著相機(jī)與錄音氢橙,去河邊找鬼。 笑死恬偷,一個(gè)胖子當(dāng)著我的面吹牛充蓝,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播喉磁,決...
    沈念sama閱讀 40,406評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼官脓!你這毒婦竟也來(lái)了协怒?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書人閱讀 39,311評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤卑笨,失蹤者是張志新(化名)和其女友劉穎孕暇,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體赤兴,經(jīng)...
    沈念sama閱讀 45,767評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡妖滔,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了桶良。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片座舍。...
    茶點(diǎn)故事閱讀 40,090評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖陨帆,靈堂內(nèi)的尸體忽然破棺而出曲秉,到底是詐尸還是另有隱情,我是刑警寧澤疲牵,帶...
    沈念sama閱讀 35,785評(píng)論 5 346
  • 正文 年R本政府宣布承二,位于F島的核電站,受9級(jí)特大地震影響纲爸,放射性物質(zhì)發(fā)生泄漏亥鸠。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,420評(píng)論 3 331
  • 文/蒙蒙 一识啦、第九天 我趴在偏房一處隱蔽的房頂上張望负蚊。 院中可真熱鬧,春花似錦颓哮、人聲如沸盖桥。這莊子的主人今日做“春日...
    開(kāi)封第一講書人閱讀 31,988評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)揩徊。三九已至腰鬼,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間塑荒,已是汗流浹背熄赡。 一陣腳步聲響...
    開(kāi)封第一講書人閱讀 33,101評(píng)論 1 271
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留齿税,地道東北人彼硫。 一個(gè)月前我還...
    沈念sama閱讀 48,298評(píng)論 3 372
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像凌箕,于是被迫代替她去往敵國(guó)和親拧篮。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,033評(píng)論 2 355