Android动画框架——Transtion详解

2,204 阅读15分钟

[TOC]

介绍

起源

随着Android系统的不断迭代优化,动效功能也越来越强大。Android从API 19开始引入Transition概念,用以对组件间、页面间的动效转场,这也是首次Android官方开始支持页面间的动效。

核心概念

Transition两个核心概念为:场景(scenes)和变换(transitions),场景是UI当前状态,变换则定义了在不同场景之间动画变化的过程。所以Transition主要负责两个方面的事,一是保存开始和结束场景的两种状态,二是在两种状态之间创建动画。由于场景记录了内部所有View的开始和结束状态,所以Transition动画更具连贯性。谁执行动画呢?TransitionManager负责执行动画的任务。

Scene

Scene指的是场景,内部包含一个完整的视图结构,从根布局到所有子View,还有他们的状态信息。

Transition

Transition指的是变换,从一个Scene转场到另一个Scene中,各个视图可以采用不同的Transition变换。Transition变换可以是视图任意属性的变化,可能是透明度、缩放值、高度等等。其内部也是采用属性变化来支持。

开始使用

Transition框架是一个非常简洁的框架,主要包含以下API:

Scene

Scene(下文也称场景)一般利用getSceneForLayout(ViewGroup sceneRoot, int layoutId, Context context)函数生成场景

参数说明:

sceneRoot:根ViewGroup

layoutId: view的布局资源id,代表一个场景,就是要参与动效的布局

context:上下文

Transition

Transition指的是两个Scene之间视图的变化方式。其工作原理是内部会记录一个Target列表(要做变换的视图列表),然后会记录每个视图变换前后的一些特定信息,在动效期间,根据这些信息来生成各个视图的Animator,最后执行这些Animator即可。

Android系统库自带一些常见的变换效果:

  • ChangeBounds:检测view的位置边界创建移动和缩放动画
  • ChangeTransform:检测view的scale和rotation创建缩放和旋转动画
  • ChangeClipBounds:检测view的剪切区域的位置边界,和ChangeBounds类似。不过ChangeBounds针对的是view而ChangeClipBounds针对的是view的剪切区域(setClipBound(Rect rect) 中的rect)。如果没有设置则没有动画效果
  • ChangeImageTransform:检测ImageView的尺寸,位置以及ScaleType,并创建相应动画。
  • Fade,Slide,Explode:根据view的visibility的状态执行渐入渐出,滑动,分解动画

在创建完Transition实例后,我们可以指定这个变换效果应用于哪些视图中。系统默认会应用于变换前后两个Scene中所有id相同的视图,当然也可以通过设置transitionName来指定两个id不同的视图。

Transition类中有以下几种方式来指定或排除视图:

  • addTarget(int targetId)添加id为指定id的视图到变换中
  • addTarget(String targetName)添加transitionName为指定transitionName的视图到变换中
  • addTarget(Class targetType)添加class类型为指定class类型的视图到变换中
  • addTarget(View targetView)添加实例视图到变换中
  • excludeTarget(String targetName, boolean exclude)排除对应transitionName,不执行变换
  • excludeTarget(int targetId,boolean exclude)排除对应id的视图,不执行变换

总结而言,Transition类就是变换的具体实现,内部通过属性动画,对多个View同时进行处理。

TransitionManager

TransitionManager在场景变换时控制transitions的执行。

通过TransitionManager可以添加场景和Transition变换,但为场景变化设置一个默认transitions是没有必要的,因为默认会使用AutoTransition。

TransitionManager当场景变换时开启动画的方式: beginDelayedTransition(ViewGroup sceneRoot, Transition transition) beginDelayedTransition(ViewGroup sceneRoot)

场景变换时传入场景的view根sceneRoot,和transition动画。如果不指定Transition,默认为AutoTransition。

调用场景变换有两种方式

  • go(Scene scene, Transition transition)
  • go(Scene scene)

go的方式需要传入scene,scene由Scene利用view生成。如果不指定Transition,则默认为AutoTransition。

举例

效果视频

我们先来看要实现的效果:

www.bilibili.com/video/BV1Ep…

界面中有三个元素,其中底部是一个按钮,用于切换场景。左上角是一个Text,右下角是一个魔方的图片。

在点击切换按钮后,文本和图片会调换位置,这个效果用Transition来实现再合适不过!

代码实现

首先,我们来分析一下页面的布局结构。我把页面分为两部分,其中页面上方是场景容器,用于承载动效的实现;另外一部分是底部按钮,来启动变换动效。

因此,fragment中的布局代码是:

<!--    fragment_first.xml-->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".FirstFragment">

<!--    场景容器-->
    <FrameLayout
        android:padding="20dp"
        android:id="@+id/fl_scene_root"
        android:layout_width="match_parent"
        android:layout_height="200dp"
        app:layout_constraintTop_toTopOf="parent" />

<!--    启动变换的按钮-->
    <Button
        android:id="@+id/button_first"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="50dp"
        android:text="@string/change_scene"
        android:textColor="@color/black"
        android:visibility="visible"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

然后在fragment中,我们会初始化场景动效,进行相关准备。

在onViewCreated方法中,先找到场景容器和切换按钮的视图,然后初始化两个场景,分别是scene1和scene2,注意构造函数里的sceneRoot要传入场景容器,也就是我们之前已经定义好的FrameLayout。

初始化完毕后,调用 TransitionManager的go方法,默认先进入到场景1中。

// FirstFragment.kt 
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

//        view.findViewById<Button>(R.id.button_first).setOnClickListener {
//            findNavController().navigate(R.id.action_FirstFragment_to_SecondFragment)
//        }
        
        changeSceneButton = view.findViewById(R.id.button_first)
        //场景容器
        sceneRoot = view.findViewById(R.id.fl_scene_root)
        
        changeSceneButton.setOnClickListener {
            changeScene()
        }

        //变换前后的两个场景
        scene1 = Scene.getSceneForLayout(sceneRoot, R.layout.scene_1, context)
        scene2 = Scene.getSceneForLayout(sceneRoot, R.layout.scene_2, context)

        //默认先展示场景1
        TransitionManager.go(scene1)
        isScene1 = true
    }

在实例化Scene中,需要传入layout,在这里指的是变换前后的页面布局。

<!--    scene_1.xml-->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

<!--    文本在左上方-->
    <TextView
        android:id="@+id/text_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:text="I am the transiton text"
        android:textColor="@color/colorPrimary"
        android:textSize="18sp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

<!--    魔方图在右下方-->
    <ImageView
        android:id="@+id/image_1"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:src="@drawable/rubik_cube"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintRight_toRightOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
<!--    scene_2.xml-->
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

<!--    文本在右下方-->
    <TextView
        android:id="@+id/text_1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="30dp"
        android:layout_marginBottom="50dp"
        android:text="I am the transiton text"
        android:textColor="@color/colorPrimary"
        android:textSize="18sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintRight_toRightOf="parent" />

<!--    魔方图在左上方-->
    <ImageView
        android:id="@+id/image_1"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:src="@drawable/rubik_cube"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

上面两个xml分别定了动效前后的布局结构,会在按钮点击后进行切换。

切换使用 TransitionManager的go方法即可,其中toScene就是切换后的场景,changeBounds就是切换时具体的变换方式。

// FirstFragment.kt
private fun changeScene() {
    val toScene = if (isScene1) scene2 else scene1
    isScene1 = !isScene1
    val changeBounds = ChangeBounds()
    TransitionManager.go(toScene, changeBounds)
}

至此,一个简单的场景切换动效就能实现了,很简单有木有!

源码分析

基于Android SDK 29源代码,我们来进一步分析下Transition框架是如何运作的。

我们将从创建场景、变换场景两个步骤来分析源代码。

创建场景

场景,也就是Scene,一般是通过Scene的静态方法 getSceneForLayout 来创建:

// Scene.java
public static Scene getSceneForLayout(ViewGroup sceneRoot, int layoutId, Context context) {
	//将场景信息以<layoutId, Scene>存储在SparseArray中,通过View的tag来存储
    SparseArray<Scene> scenes = (SparseArray<Scene>) sceneRoot.getTag(
            com.android.internal.R.id.scene_layoutid_cache);
    if (scenes == null) {
        scenes = new SparseArray<Scene>();
        sceneRoot.setTagInternal(com.android.internal.R.id.scene_layoutid_cache, scenes);
    }
	//获取对应layoutId的Scene
    Scene scene = scenes.get(layoutId);
    if (scene != null) {
        return scene;
    } else {
		//第一次调用,会新创建一个Scene实例,将sceneRoot、layoutId和context赋值到实例变量中
        scene = new Scene(sceneRoot, layoutId, context);
        scenes.put(layoutId, scene);
        return scene;
    }
}

可以看到创建场景的过程还是比较简单的,就是实例化出来,然后存储到sceneRoot的tag里面。因此一个sceneRoot可以绑定多个场景,这个从逻辑上也是非常合理的,因为场景容器就是承载场景的视图,也可以理解为就是场景的父布局。

因此,layoutId对应的根视图不能有父布局,否则在场景变换中添加场景就会有问题。

变换场景

从前面的使用中,我们可以知道,TransitionManager负责调度场景、变换场景。变换场景一般有两个方法:

// TransitionManager.java
private static Transition sDefaultTransition = new AutoTransition();

public static void go(Scene scene) {
    changeScene(scene, sDefaultTransition);
}

public static void go(Scene scene, Transition transition) {
    changeScene(scene, transition);
}

如果不指定Transition,就会使用默认的AutoTransition。最终都会调用到 changeScene方法中:

// TransitionManager.java
/**
 * This is where all of the work of a transition/scene-change is
 * orchestrated. This method captures the start values for the given
 * transition, exits the current Scene, enters the new scene, captures
 * the end values for the transition, and finally plays the
 * resulting values-populated transition.
 *
 * @param scene The scene being entered
 * @param transition The transition to play for this scene change
 */
private static void changeScene(Scene scene, Transition transition) {
    final ViewGroup sceneRoot = scene.getSceneRoot();
	//当前是否已经加入到sPendingTransitions中
    if (!sPendingTransitions.contains(sceneRoot)) {
        Scene oldScene = Scene.getCurrentScene(sceneRoot);
        if (transition == null) {
            // Notify old scene that it is being exited
            if (oldScene != null) {
                oldScene.exit();
            }

            scene.enter();
        } else {
			//从go方法过来,transition不为空,将sceneRoot添加到sPendingTransitions中
            sPendingTransitions.add(sceneRoot);

            Transition transitionClone = transition.clone();
            transitionClone.setSceneRoot(sceneRoot);

            if (oldScene != null && oldScene.isCreatedFromLayoutResource()) {
                transitionClone.setCanRemoveViews(true);
            }
			
			//captures the start values for the given transition, exits the current Scene
            sceneChangeSetup(sceneRoot, transitionClone);

			//enters the new scene, captures the end values for the transition
            scene.enter();


			//finally plays the resulting values-populated transition
            sceneChangeRunTransition(sceneRoot, transitionClone);
        }
    }
}

注释中已经将方法执行链路解释比较清晰了。主要是由三个执行子过程,分别是(1)抓捕开始信息、退出当前场景 ;(2)进入到新的场景;(3)抓捕结束信息、执行变换场景动效。

由于这里内容较多且复杂,我们分开来讲:

1)抓捕开始信息、退出当前场景

// TransitionManager.java
private static void sceneChangeSetup(ViewGroup sceneRoot, Transition transition) {

    // Capture current values
    ArrayList<Transition> runningTransitions = getRunningTransitions().get(sceneRoot);

	//暂停当前进行中的变换动效
    if (runningTransitions != null && runningTransitions.size() > 0) {
        for (Transition runningTransition : runningTransitions) {
            runningTransition.pause(sceneRoot);
        }
    }

	//抓捕变换(transition)所需要的视图信息
    if (transition != null) {
        transition.captureValues(sceneRoot, true);
    }

    // Notify previous scene that it is being exited
    Scene previousScene = Scene.getCurrentScene(sceneRoot);
    if (previousScene != null) {
        previousScene.exit();
    }
}

变换的captureValues(ViewGroup, boolean)方法用于抓捕ViewGroup中的变换所需视图信息,其中boolean值为true则为变换前,false则为变换后。

在讲解captureValues方法的具体实现之前,我们有必要了解一下变换是如何存储视图信息的。

// TransitionValues.java
public TransitionValues(@NonNull View view) {
    this.view = view;
}

/**
 * The View with these values
 */
public View view;

/**
 * The set of values tracked by transitions for this scene
 */
@NonNull
public final Map<String, Object> values = new ArrayMap<String, Object>();

/**
 * The Transitions that targeted this view.
 */
@NonNull
final ArrayList<Transition> targetedTransitions = new ArrayList<Transition>();

其中 TransitionValues 就是用于存储视图的信息类。内部绑定了视图,同时通过map存储必要的状态信息(values),targetedTransitions中记录了视图要执行的变换。

// TransitionValuesMaps.java
class TransitionValuesMaps {
    ArrayMap<View, TransitionValues> viewValues =
            new ArrayMap<View, TransitionValues>();
    SparseArray<View> idValues = new SparseArray<View>();
    LongSparseArray<View> itemIdValues = new LongSparseArray<View>();
    ArrayMap<String, View> nameValues = new ArrayMap<String, View>();
}

另外一个类是 TransitionValuesMaps,内部 viewValues 存储了对应视图的TransitionValues,idValues则用map存储了<viewId, view>的键值对,nameValues存储了<transitionName, view>的键值对,用于查找指定transitionName的视图。

因此在做变换动效时,场景中的子视图要使用不同的transtionName,否则为发生异常。

// Transition.java
private TransitionValuesMaps mStartValues = new TransitionValuesMaps();
private TransitionValuesMaps mEndValues = new TransitionValuesMaps();

/**
 * Recursive method that captures values for the given view and the
 * hierarchy underneath it.
 * @param sceneRoot The root of the view hierarchy being captured
 * @param start true if this capture is happening before the scene change,
 * false otherwise
 */
void captureValues(ViewGroup sceneRoot, boolean start) {
    clearValues(start);
    if ((mTargetIds.size() > 0 || mTargets.size() > 0)
            && (mTargetNames == null || mTargetNames.isEmpty())
            && (mTargetTypes == null || mTargetTypes.isEmpty())) {
        for (int i = 0; i < mTargetIds.size(); ++i) {
            int id = mTargetIds.get(i);
            View view = sceneRoot.findViewById(id);
            if (view != null) {
                TransitionValues values = new TransitionValues(view);
                if (start) {
                    captureStartValues(values);
                } else {
                    captureEndValues(values);
                }
                values.targetedTransitions.add(this);
                capturePropagationValues(values);
                if (start) {
                    addViewValues(mStartValues, view, values);
                } else {
                    addViewValues(mEndValues, view, values);
                }
            }
        }
        for (int i = 0; i < mTargets.size(); ++i) {
            View view = mTargets.get(i);
            TransitionValues values = new TransitionValues(view);
            if (start) {
                captureStartValues(values);
            } else {
                captureEndValues(values);
            }
            values.targetedTransitions.add(this);
            capturePropagationValues(values);
            if (start) {
                addViewValues(mStartValues, view, values);
            } else {
                addViewValues(mEndValues, view, values);
            }
        }
    } else {
        captureHierarchy(sceneRoot, start);
    }
	//listView特殊处理
    ...
}

captureValues较为复杂,我们一步一步来看。首先会清除Values,也就是初始或结束视图的所有信息。判断targetId、targetName、targetType三个列表中是否有值,这里指的是我们是否给Transition单独指定过视图。

这个我们在前面也讲到过,Transition可以给指定id视图、指定transitionName视图、指定class类型视图做变换。

由于我们在实例中没有特殊指定,进入到else分支,调用了 captureHierarchy 方法。if和else分支中做的事情是差不多,我们通过分析else分支就能知道它在做什么了。

// Transition.java
private void captureHierarchy(View view, boolean start) {
    if (view == null) {
        return;
    }
    int id = view.getId();
    if (mTargetIdExcludes != null && mTargetIdExcludes.contains(id)) {
        return;
    }
    if (mTargetExcludes != null && mTargetExcludes.contains(view)) {
        return;
    }
    if (mTargetTypeExcludes != null && view != null) {
        int numTypes = mTargetTypeExcludes.size();
        for (int i = 0; i < numTypes; ++i) {
            if (mTargetTypeExcludes.get(i).isInstance(view)) {
                return;
            }
        }
    }
    if (view.getParent() instanceof ViewGroup) {
        TransitionValues values = new TransitionValues(view);
        if (start) {
            captureStartValues(values);
        } else {
            captureEndValues(values);
        }
        values.targetedTransitions.add(this);
        capturePropagationValues(values);
        if (start) {
            addViewValues(mStartValues, view, values);
        } else {
            addViewValues(mEndValues, view, values);
        }
    }
    if (view instanceof ViewGroup) {
        // Don't traverse child hierarchy if there are any child-excludes on this view
        if (mTargetIdChildExcludes != null && mTargetIdChildExcludes.contains(id)) {
            return;
        }
        if (mTargetChildExcludes != null && mTargetChildExcludes.contains(view)) {
            return;
        }
        if (mTargetTypeChildExcludes != null) {
            int numTypes = mTargetTypeChildExcludes.size();
            for (int i = 0; i < numTypes; ++i) {
                if (mTargetTypeChildExcludes.get(i).isInstance(view)) {
                    return;
                }
            }
        }
        ViewGroup parent = (ViewGroup) view;
        for (int i = 0; i < parent.getChildCount(); ++i) {
            captureHierarchy(parent.getChildAt(i), start);
        }
    }
}

这个方法是一个递归方法,会对view树进行递归遍历。

首先是判断了一下是否在Exclude列表当中,如果没有特殊指定,可以先忽略。

然后会根据start为true或者false来分别调用 captureStartValues 和 captureEndValues 方法。这两个方法都是抽象方法,由具体的子类来实现。一般来说方法里面会记录视图的初始和终止态信息,然后借由属性动画来实现具体的单个视图的动效。

我们以Slide 滑动变换来举例说明:

// Slide.java
private void captureValues(TransitionValues transitionValues) {
    View view = transitionValues.view;
    int[] position = new int[2];
    view.getLocationOnScreen(position);
    transitionValues.values.put(PROPNAME_SCREEN_POSITION, position);
}

@Override
public void captureStartValues(TransitionValues transitionValues) {
    super.captureStartValues(transitionValues);
    captureValues(transitionValues);
}

@Override
public void captureEndValues(TransitionValues transitionValues) {
    super.captureEndValues(transitionValues);
    captureValues(transitionValues);
}

内部都是统一调用到了 captureValues 方法,内部记录了视图在屏幕上的位置信息,用于滑动动效的位移。

然后回到 captureHierarchy 方法内部,还有一处比较重要的是:

// Transition.java
static void addViewValues(TransitionValuesMaps transitionValuesMaps,
        View view, TransitionValues transitionValues) {
    transitionValuesMaps.viewValues.put(view, transitionValues);
    int id = view.getId();
    if (id >= 0) {
        if (transitionValuesMaps.idValues.indexOfKey(id) >= 0) {
            // Duplicate IDs cannot match by ID.
            transitionValuesMaps.idValues.put(id, null);
        } else {
            transitionValuesMaps.idValues.put(id, view);
        }
    }
    String name = view.getTransitionName();
    if (name != null) {
        if (transitionValuesMaps.nameValues.containsKey(name)) {
            // Duplicate transitionNames: cannot match by transitionName.
            transitionValuesMaps.nameValues.put(name, null);
        } else {
            transitionValuesMaps.nameValues.put(name, view);
        }
    }
	//ListView特殊处理,暂不需要
    ...
}

addViewValues方法主要是把记录下来的信息存储到transitionValuesMaps的idValues和nameValues中。注意这里就有对id相同或者transitionName相同的特殊处理。

至此,关于sceneRoot视图树的所有信息记录已经分析完了,我们在回到开始分析的方法里:

// TransitionManager.java
private static void sceneChangeSetup(ViewGroup sceneRoot, Transition transition) {
    // Capture current values
    ArrayList<Transition> runningTransitions = getRunningTransitions().get(sceneRoot);

	//暂停当前进行中的变换动效
    if (runningTransitions != null && runningTransitions.size() > 0) {
        for (Transition runningTransition : runningTransitions) {
            runningTransition.pause(sceneRoot);
        }
    }

	//抓捕变换(transition)所需要的视图信息
    if (transition != null) {
        transition.captureValues(sceneRoot, true);
    }

    // Notify previous scene that it is being exited
    Scene previousScene = Scene.getCurrentScene(sceneRoot);
    if (previousScene != null) {
        previousScene.exit();
    }
}

我们已经完成初始信息的记录,方法体最后一段代码就是让当前正在显示的场景退出。

2)进入到新的场景

// Scene.java
public void enter() {
    // Apply layout change, if any
    if (mLayoutId > 0 || mLayout != null) {
        // empty out parent container before adding to it
        getSceneRoot().removeAllViews();

        if (mLayoutId > 0) {
            LayoutInflater.from(mContext).inflate(mLayoutId, mSceneRoot);
        } else {
            mSceneRoot.addView(mLayout);
        }
    }

    // Notify next scene that it is entering. Subclasses may override to configure scene.
    if (mEnterAction != null) {
        mEnterAction.run();
    }

    setCurrentScene(mSceneRoot, this);
}

场景的enter方法用于进入到场景容器中,这里就是将当时创建Scene所传入的layout 布局或者视图,添加到场景容器中(mSceneRoot)。然后将当前场景甜到加SceneRoot的tag中。

3)抓捕结束信息、执行变换动效

// TransitionManager.java
private static void sceneChangeRunTransition(final ViewGroup sceneRoot,
        final Transition transition) {
    if (transition != null && sceneRoot != null) {
        MultiListener listener = new MultiListener(transition, sceneRoot);
        sceneRoot.addOnAttachStateChangeListener(listener);
        sceneRoot.getViewTreeObserver().addOnPreDrawListener(listener);
    }
}

在新的场景进入后,会触发视图的重新绘制。变换执行通过 MultiListener 来真正实现,其中监听了场景容器的attch状态和onPreDraw状态。

// TransitionManager.java
/**
 * This private utility class is used to listen for both OnPreDraw and
 * OnAttachStateChange events. OnPreDraw events are the main ones we care
 * about since that's what triggers the transition to take place.
 * OnAttachStateChange events are also important in case the view is removed
 * from the hierarchy before the OnPreDraw event takes place; it's used to
 * clean up things since the OnPreDraw listener didn't get called in time.
 */
private static class MultiListener implements ViewTreeObserver.OnPreDrawListener,
        View.OnAttachStateChangeListener {

    Transition mTransition;
    ViewGroup mSceneRoot;
    final ViewTreeObserver mViewTreeObserver;

    MultiListener(Transition transition, ViewGroup sceneRoot) {
        mTransition = transition;
        mSceneRoot = sceneRoot;
        mViewTreeObserver = mSceneRoot.getViewTreeObserver();
    }

    private void removeListeners() {
        if (mViewTreeObserver.isAlive()) {
            mViewTreeObserver.removeOnPreDrawListener(this);
        } else {
            mSceneRoot.getViewTreeObserver().removeOnPreDrawListener(this);
        }
        mSceneRoot.removeOnAttachStateChangeListener(this);
    }

    @Override
    public void onViewAttachedToWindow(View v) {
    }

    @Override
    public void onViewDetachedFromWindow(View v) {
        removeListeners();

        sPendingTransitions.remove(mSceneRoot);
        ArrayList<Transition> runningTransitions = getRunningTransitions().get(mSceneRoot);
        if (runningTransitions != null && runningTransitions.size() > 0) {
            for (Transition runningTransition : runningTransitions) {
                runningTransition.resume(mSceneRoot);
            }
        }
        mTransition.clearValues(true);
    }

    @Override
    public boolean onPreDraw() {
        removeListeners();

        // Don't start the transition if it's no longer pending.
        if (!sPendingTransitions.remove(mSceneRoot)) {
            return true;
        }

        // Add to running list, handle end to remove it
        final ArrayMap<ViewGroup, ArrayList<Transition>> runningTransitions =
                getRunningTransitions();
        ArrayList<Transition> currentTransitions = runningTransitions.get(mSceneRoot);
        ArrayList<Transition> previousRunningTransitions = null;
        if (currentTransitions == null) {
            currentTransitions = new ArrayList<Transition>();
            runningTransitions.put(mSceneRoot, currentTransitions);
        } else if (currentTransitions.size() > 0) {
            previousRunningTransitions = new ArrayList<Transition>(currentTransitions);
        }
        currentTransitions.add(mTransition);
        mTransition.addListener(new TransitionListenerAdapter() {
            @Override
            public void onTransitionEnd(Transition transition) {
                ArrayList<Transition> currentTransitions =
                        runningTransitions.get(mSceneRoot);
                currentTransitions.remove(transition);
                transition.removeListener(this);
            }
        });
        mTransition.captureValues(mSceneRoot, false);
        if (previousRunningTransitions != null) {
            for (Transition runningTransition : previousRunningTransitions) {
                runningTransition.resume(mSceneRoot);
            }
        }
        mTransition.playTransition(mSceneRoot);

        return true;
    }
};

创建 MultiListener时,会将变换和场景容器传入。这里我们重点关注一下onPreDraw方法是如何真正生成动效并执行它的。

首先要判断 sPendingTransitions 中包含当前要执行的变换,否则退出。这个在之前已经添加过,所以进入到后续代码。

getRunningTransitions获取当前正在运行的变换动效ArrayList,同时将当前要运行的变换添加到 currentTransitions,设置变换结束的回调,将运行完的变换从currentTransitions中删除。

然后,又碰到我们熟悉的 captureValues方法,用于记录场景容器切换完场景,此时的视图状态信息,也就是是终止态信息(变换后的场景视图)。恢复之前暂停的动画。

最后也就是我们压轴的动效执行过程,通过Transition的playTransition方法来执行当前变换。

// Transition.java
/**
 * Called by TransitionManager to play the transition. This calls
 * createAnimators() to set things up and create all of the animations and then
 * runAnimations() to actually start the animations.
 */
void playTransition(ViewGroup sceneRoot) {
    mStartValuesList = new ArrayList<TransitionValues>();
    mEndValuesList = new ArrayList<TransitionValues>();
    matchStartAndEnd(mStartValues, mEndValues);

    ArrayMap<Animator, AnimationInfo> runningAnimators = getRunningAnimators();
    int numOldAnims = runningAnimators.size();
    WindowId windowId = sceneRoot.getWindowId();
    for (int i = numOldAnims - 1; i >= 0; i--) {
        Animator anim = runningAnimators.keyAt(i);
        if (anim != null) {
            AnimationInfo oldInfo = runningAnimators.get(anim);
            if (oldInfo != null && oldInfo.view != null && oldInfo.windowId == windowId) {
                TransitionValues oldValues = oldInfo.values;
                View oldView = oldInfo.view;
                TransitionValues startValues = getTransitionValues(oldView, true);
                TransitionValues endValues = getMatchedTransitionValues(oldView, true);
                if (startValues == null && endValues == null) {
                    endValues = mEndValues.viewValues.get(oldView);
                }
                boolean cancel = (startValues != null || endValues != null) &&
                        oldInfo.transition.isTransitionRequired(oldValues, endValues);
                if (cancel) {
                    if (anim.isRunning() || anim.isStarted()) {
                        if (DBG) {
                            Log.d(LOG_TAG, "Canceling anim " + anim);
                        }
                        anim.cancel();
                    } else {
                        if (DBG) {
                            Log.d(LOG_TAG, "removing anim from info list: " + anim);
                        }
                        runningAnimators.remove(anim);
                    }
                }
            }
        }
    }

    createAnimators(sceneRoot, mStartValues, mEndValues, mStartValuesList, mEndValuesList);
    runAnimators();
}

刚开始时会对视图做一些匹配,这里比较复杂,暂时先不管它。

getRunningAnimators获取当前正在运行的属性动画,将其取消。之后调用createAnimators方法:

// Transition.java
protected void createAnimators(ViewGroup sceneRoot, TransitionValuesMaps startValues,
        TransitionValuesMaps endValues, ArrayList<TransitionValues> startValuesList,
        ArrayList<TransitionValues> endValuesList) {
    if (DBG) {
        Log.d(LOG_TAG, "createAnimators() for " + this);
    }
    ArrayMap<Animator, AnimationInfo> runningAnimators = getRunningAnimators();
    long minStartDelay = Long.MAX_VALUE;
    int minAnimator = mAnimators.size();
    SparseLongArray startDelays = new SparseLongArray();
    int startValuesListCount = startValuesList.size();
    for (int i = 0; i < startValuesListCount; ++i) {
        TransitionValues start = startValuesList.get(i);
        TransitionValues end = endValuesList.get(i);
        if (start != null && !start.targetedTransitions.contains(this)) {
            start = null;
        }
        if (end != null && !end.targetedTransitions.contains(this)) {
            end = null;
        }
        if (start == null && end == null) {
            continue;
        }
        // Only bother trying to animate with values that differ between start/end
        boolean isChanged = start == null || end == null || isTransitionRequired(start, end);
        if (isChanged) {
            if (DBG) {
                View view = (end != null) ? end.view : start.view;
                Log.d(LOG_TAG, "  differing start/end values for view " + view);
                if (start == null || end == null) {
                    Log.d(LOG_TAG, "    " + ((start == null) ?
                            "start null, end non-null" : "start non-null, end null"));
                } else {
                    for (String key : start.values.keySet()) {
                        Object startValue = start.values.get(key);
                        Object endValue = end.values.get(key);
                        if (startValue != endValue && !startValue.equals(endValue)) {
                            Log.d(LOG_TAG, "    " + key + ": start(" + startValue +
                                    "), end(" + endValue + ")");
                        }
                    }
                }
            }
            // TODO: what to do about targetIds and itemIds?
            Animator animator = createAnimator(sceneRoot, start, end);
            if (animator != null) {
                // Save animation info for future cancellation purposes
                View view = null;
                TransitionValues infoValues = null;
                if (end != null) {
                    view = end.view;
                    String[] properties = getTransitionProperties();
                    if (properties != null && properties.length > 0) {
                        infoValues = new TransitionValues(view);
                        TransitionValues newValues = endValues.viewValues.get(view);
                        if (newValues != null) {
                            for (int j = 0; j < properties.length; ++j) {
                                infoValues.values.put(properties[j],
                                        newValues.values.get(properties[j]));
                            }
                        }
                        int numExistingAnims = runningAnimators.size();
                        for (int j = 0; j < numExistingAnims; ++j) {
                            Animator anim = runningAnimators.keyAt(j);
                            AnimationInfo info = runningAnimators.get(anim);
                            if (info.values != null && info.view == view &&
                                    ((info.name == null && getName() == null) ||
                                            info.name.equals(getName()))) {
                                if (info.values.equals(infoValues)) {
                                    // Favor the old animator
                                    animator = null;
                                    break;
                                }
                            }
                        }
                    }
                } else {
                    view = (start != null) ? start.view : null;
                }
                if (animator != null) {
                    if (mPropagation != null) {
                        long delay = mPropagation
                                .getStartDelay(sceneRoot, this, start, end);
                        startDelays.put(mAnimators.size(), delay);
                        minStartDelay = Math.min(delay, minStartDelay);
                    }
                    AnimationInfo info = new AnimationInfo(view, getName(), this,
                            sceneRoot.getWindowId(), infoValues);
                    runningAnimators.put(animator, info);
                    mAnimators.add(animator);
                }
            }
        }
    }
    if (startDelays.size() != 0) {
        for (int i = 0; i < startDelays.size(); i++) {
            int index = startDelays.keyAt(i);
            Animator animator = mAnimators.get(index);
            long delay = startDelays.valueAt(i) - minStartDelay + animator.getStartDelay();
            animator.setStartDelay(delay);
        }
    }

这个方法对子类创建属性动画做了一些封装,首先判断初始和终止态信息是否不一致,有变化才执行变换,通过抽象方法让子类实现创建属性动画的细节。

属性动画创建完毕后添加到sRunningAnimators和mAnimators中,设置执行延时。

最后一步是通过runAnimators()来执行这些属性动画:

// Transition.java
/**
 * This is called internally once all animations have been set up by the
 * transition hierarchy.
 *
 * @hide
 */
protected void runAnimators() {
    if (DBG) {
        Log.d(LOG_TAG, "runAnimators() on " + this);
    }
    start();
    ArrayMap<Animator, AnimationInfo> runningAnimators = getRunningAnimators();
    // Now start every Animator that was previously created for this transition
    for (Animator anim : mAnimators) {
        if (DBG) {
            Log.d(LOG_TAG, "  anim: " + anim);
        }
        if (runningAnimators.containsKey(anim)) {
            start();
            runAnimator(anim, runningAnimators);
        }
    }
    mAnimators.clear();
    end();
}

遍历mAnimators,执行这些属性动画。

最终,场景的变换通过**【场景内叠加不同变换】【变换内叠加不同属性动画】**来完美实现。

进阶—自定义Transition

经过上述的分析,我们已经了解了Transition框架的原理和执行流程。但同时也发现对于一种特定类型的视图,具体的变换方式是完全可以由开发者来自定义的。

变换框架也提供了这种可拓展的能力,主要是通过自定义Transition来实现。Android SDK已经提供了一些常用的实现:

<u>ce368c9aa222d5e1cd820e3bc9ac68df</u>

具体的用法就自行百度或者翻看源码即可。

自定义Transition时经常要实现的抽象方法或者公共方法通常有三个:

一、public abstract void captureStartValues(TransitionValues transitionValues);

根据自定义的变换场景,来抓捕变换前视图的信息,比如alpha、width、height等等

二、public abstract void captureEndValues(TransitionValues transitionValues);

根据自定义的变换场景,来抓捕变换后视图的信息,比如alpha、width、height等等

三、public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues,TransitionValues endValues);

根据自定义的变换场景和变换前后的视图信息,来创建最终要执行的属性动画

下面我们将讲解两个出现在DiDi Food客户端和Conductor库中的自定义Transition,供抛砖引玉。

自定义字体变换分析

DiDi Food客户端在做首页进店的动效时,涉及到多种元素的共享。我们讲解一个比较简单的子动效:卡片上商家名称从小字号变换到大字号的场景变换过程。

照例,先来看效果视频:

www.bilibili.com/video/BV1F5…

仔细看,注意商家卡片上名称的字号从小到大的一个过程。

// TextSizeTransition.java
@TargetApi(VERSION_CODES.KITKAT)
public class TextSizeTransition extends Transition {

    private static final String PROPNAME_TEXT_SIZE = "sodaglobaldidifood:transition:textsize";
    private static final String[] TRANSITION_PROPERTIES = {PROPNAME_TEXT_SIZE};

    private static final Property<TextView, Float> TEXT_SIZE_PROPERTY = AnimationProperty.TEXT_SIZE;

    public TextSizeTransition() {
    }

    @Override
    public void captureEndValues(TransitionValues transitionValues) {
        captureValues(transitionValues);
    }

    @Override
    public void captureStartValues(TransitionValues transitionValues) {
        captureValues(transitionValues);
    }

    @Override
    public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues,
        TransitionValues endValues) {
        if (startValues == null || endValues == null) {
            return null;
        }

        Float startSize = (Float) startValues.values.get(PROPNAME_TEXT_SIZE);
        Float endSize = (Float) endValues.values.get(PROPNAME_TEXT_SIZE);
        if (startSize == null || endSize == null ||
            startSize.floatValue() == endSize.floatValue()) {
            return null;
        }

        TextView view = (TextView) endValues.view;
        view.setTextSize(TypedValue.COMPLEX_UNIT_PX, startSize);
        return ObjectAnimator.ofFloat(view, TEXT_SIZE_PROPERTY, startSize, endSize);
    }

    @Override
    public String[] getTransitionProperties() {
        return TRANSITION_PROPERTIES;
    }

    private void captureValues(TransitionValues transitionValues) {
        if (transitionValues.view instanceof TextView) {
            TextView textView = (TextView) transitionValues.view;
            transitionValues.values.put(PROPNAME_TEXT_SIZE, textView.getTextSize());
        }
    }
}

在这个自定义字号变换中,我们在变换前后抓捕了视图的TextSize信息,同时 createAnimator 中返回了字号变化的属性动画。

是不是觉得很简单!!基本上视图中定义的属性值比如alpha、scale、translateY等等都可以这样来处理。

Conductor自定义Fab变换分析

Conductor库是非常重视视觉过渡效果的,其中就有一个比较有意思的变换效果。点击FloatingActionButton之后,弹出一个Dialog,其中的场景变换就是利用自定义Transition实现的。

www.bilibili.com/video/BV1nX…

限于篇幅和时间原因,没有办法在这里去解析它。感兴趣的同学可以爬下源码看一看。

参考内容

1、Android过渡动画学习