Android过渡(Transition)动画解析之源码篇

0.587字数 1944阅读 3586
transitions1.png

上文我们分析了过渡动画的基本使用方法,你是不是已经对使用过渡动画成竹在胸了呢?本文我们来跟一下Transition动画在SDK中的源码实现,如果没有看过基础篇,请戳这里:[基础篇][1]。ok,下面我们接着这个话题来聊聊系统是如何来处理过渡动画的。我们从一行最简单的代码开始分析:

Scene scene2 = Scene.getSceneForLayout(rootView, R.layout.scene2, context);
TransitionManager.go(Scene scene, Transition transition)

这里我们先通过布局定义了一个场景,然后通过TransitionManager进入场景,我们首先来看

Scene.getSceneForLayout:

  public static Scene getSceneForLayout(ViewGroup sceneRoot, int layoutId, Context context) {
    //缓存layout,避免同一个sence layout被多次的inflate        
    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);
    }
    Scene scene = scenes.get(layoutId);
    if (scene != null) {
        return scene;
    } else {
        scene = new Scene(sceneRoot, layoutId, context);
        scenes.put(layoutId, scene);
        return scene;
    }
}

产生一个Scene的代码比较简单,然后我们来看TransitionManager.go()go直接调用changeScene(Scene scene, Transition transition):

private static void changeScene(Scene scene, Transition transition) {
    //1. 准备工作,保存当前视图树的状态
   sceneChangeSetup(sceneRoot, transitionClone);
    //2. 将当前scene inflate到rootview
   scene.enter();
    //3. 开启动画设置
   sceneChangeRunTransition(sceneRoot, transitionClone);
    }

这里我们可以看到启动一个Transition动画需要三步,下面一步步来分析。

1. 保存当前视图树的状态

我们在自定义Transiton动画时,曾经需要复写两个方法,captureStartValuescaptureEndValues来获取视图树的状态,那么这些方法具体是在那里被调用的呢?

首先我们来看一下sceneChangeSetup:

private static void sceneChangeSetup(ViewGroup sceneRoot, Transition transition) {
      //正在执行的动画,需要暂停
    ArrayList<Transition> runningTransitions = getRunningTransitions().get(sceneRoot);

    if (runningTransitions != null && runningTransitions.size() > 0) {
        for (Transition runningTransition : runningTransitions) {
            runningTransition.pause(sceneRoot);
        }
    }
      //这里赋值为true,表示start   不是一个很好的设计。。。
    if (transition != null) {
        transition.captureValues(sceneRoot, true);
    }
     // 如果当前有scene,执行回调
    Scene previousScene = Scene.getCurrentScene(sceneRoot);
    if (previousScene != null) {
        previousScene.exit();
    }
}

sceneChangeSetup先暂停了这个rootView上的所有Transition动画,然后调用captureValues开始记录视图树当前的状态,然后获取rootView当前的Scene,如果之前在这个rootViwe执行过Transition动画,那么这里就是上一次Transition动画之后最后的Scene

Transition.captureValues代码如下:

void captureValues(ViewGroup sceneRoot, boolean start) {
    if ((mTargetIds.size() > 0 || mTargets.size() > 0)
            && (mTargetNames == null || mTargetNames.isEmpty())
            && (mTargetTypes == null || mTargetTypes.isEmpty())) {
            // ... 我们主动设置了Transition的Target来对特殊的View进行动画
    } else {
        //否则直接递归调用保存当前视图树中所有view的状态
        captureHierarchy(sceneRoot, start);
    }
  }

Transition.captureHierarchy:

private void captureHierarchy(View view, boolean start) {
        //...去掉被我们exclude的View
     if (view.getParent() instanceof ViewGroup) {
        TransitionValues values = new TransitionValues();
        values.view = 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) {
        //对所有view的子view调用captureHierarchy
    }
}

注意一下capturePropagationValues(values)这个方法,它的作用是,对于视图树中的View来说,如果我不希望所有的View的动画都在同一时间进行,比如默认的Explode,靠近屏幕中心部位的View和在屏幕边缘的View开始动画的时间不一致,进入时,靠近中心部分的View应该先开始动画,边缘的View动画开始的时间较晚,我们可以通过Transition.setPropagation(TransitionPropagation)来为不同的View设置不同的动画开启时间。

好了,执行至此,视图树的状态保存完毕。

2. RootView视图树操作

第二步,调用了scene.enter(),我们跟一下代码:

public void enter() {
    if (mLayoutId > 0 || mLayout != null) {
         //先把rootView中的所有Viewremove
        getSceneRoot().removeAllViews();

        if (mLayoutId > 0) {
                //将当前Scene加载到rootview中
            LayoutInflater.from(mContext).inflate(mLayoutId, mSceneRoot);
        } else {
            mSceneRoot.addView(mLayout);
        }
    }
    setCurrentScene(mSceneRoot, this);
}

这里首先把rootView中的所有子View的删除,然后把当前Sence的视图加载到rootView,然后更新一下当前Sence(rootView下次Transtion动画时的oldSence)。

3. 开启动画

由上面分析可知,在动画开始前,实际上我们的rootView已经完成了视图树场景的替换。下面我们看第三步,开启动画:
TransitionManager.sceneChangeRunTransition

MultiListener listener = new MultiListener(transition, sceneRoot);
//View从Window中dettach时,去掉动画
sceneRoot.addOnAttachStateChangeListener(listener);
//下次视图树重绘时,启动动画
sceneRoot.getViewTreeObserver().addOnPreDrawListener(listener);

搂一眼MultiListener类,在onPreDraw中加入了如下代码:

//false,会去调用mTransition的captureEndValues获取当前的视图树的状态
mTransition.captureValues(mSceneRoot, false);
//执行动画
mTransition.playTransition(mSceneRoot);

playTransition()逻辑:

void playTransition(ViewGroup sceneRoot) {
    //检查视图树中每个View存储TransitionValues是否合法
    matchStartAndEnd(mStartValues, mEndValues);
    //设置动画
    createAnimators(sceneRoot, mStartValues, mEndValues, mStartValuesList, mEndValuesList);
    //启动动画
    runAnimators();
}

3.1 匹配视图树中View对对应关系

写到这里,我们可以看到前面所有地方在使用过渡动画过程中,两个视图树中的View要建立对应关系需要指定相同的ID,为什么有这样子的限制呢?原因是在开启动画之后我们第一步会去检查视图树中的状态是否能够匹配,只有匹配了的View我们保存了才会被Transition框架使用。Transition的匹配有如下匹配规则:

private static final int[] DEFAULT_MATCH_ORDER = {
    MATCH_NAME,
    MATCH_INSTANCE,
    MATCH_ID,
    MATCH_ITEM_ID,
};

我们可以看到,开始场景和结束场景在进行数据匹配的时候并仅仅只有根据ID,其实一共有四种匹配条件,从上往下优先级降低。

  • MATCH_NAME 如果我们为View指定了android:transitionName,那么会直接匹配两个android:transitionName相同的View会进行绑定。
  • MATCH_INSTANCE 开始场景和结束场景中View直接有相同的引用
  • MATCH_ID 匹配ID
  • MATCH_ITEM_ID如果是ListViewItem,匹配Item Id

当然,我看到这里的时候,问了自己两个问题:1、如果我们要做一个淡出的效果,在start 场景是有一个View的,然后我直接remove掉了这个View,那么在captureEndValues时,实际上此时视图树里面已经没有这个View了,那么start里面存储的信息不就没有匹配了吗?
2、 我在动画开始之前就已经将start场景所有的view直接remove了,然后我又需要这个View来做一个动画,这样怎么破?
对于第一个问题,所有通过以上match规则没有匹配上的状态(TransitionValues)并不会被直接丢弃,而是通过一个null对象进行匹配,看这里:

private void addUnmatched(ArrayMap<View, TransitionValues> unmatchedStart,
        ArrayMap<View, TransitionValues> unmatchedEnd) {
    // Views that only exist in the start Scene
    for (int i = 0; i < unmatchedStart.size(); i++) {
        final TransitionValues start = unmatchedStart.valueAt(i);
        if (isValidTarget(start.view)) {
            mStartValuesList.add(start);
            mEndValuesList.add(null);
        }
    }

    // Views that only exist in the end Scene
    for (int i = 0; i < unmatchedEnd.size(); i++) {
        final TransitionValues end = unmatchedEnd.valueAt(i);
        if (isValidTarget(end.view)) {
            mEndValuesList.add(end);
            mStartValuesList.add(null);
        }
    }
}

这样,我们就可以在TransitioncreateAnimator方法中判断一下了,如果start不为空,而end为空,表示这个View已经被删除了,那么我们可以自己觉得相应的动画,这里解决了问题1,但是对于问题2呢?这也需要createAnimator去处理,文末会具体列举一个系统内置动画的例子来解释,这里暂且不表。

3.2 构建动画

protected void createAnimators(ViewGroup sceneRoot, TransitionValuesMaps startValues,
        TransitionValuesMaps endValues, ArrayList<TransitionValues> startValuesList,
        ArrayList<TransitionValues> endValuesList) {
  
        //...
    for (int i = 0; i < startValuesListCount; ++i) {
        TransitionValues start = startValuesList.get(i);
        TransitionValues end = endValuesList.get(i);
        //... 比较两个状态是否变化
        boolean isChanged = start == null || end == null || isTransitionRequired(start, end);
        if (isChanged) {
            //... 
            Animator animator = createAnimator(sceneRoot, start, end);
            // 是否需要增加延迟
            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 (minStartDelay != 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);
        }
    }
}

注意,这里我们将之前设置的TransitionPropagation计算了一下delay,然后为相应View的动画添加了一定的延迟。

最后我们直接把animator.start()开启动画。

3.3 ViewOverlay

这个小节已经不属于过渡动画框架的范畴了,因为具体动画怎么实现是我们自己的事情,比如基础篇中我们定义了一个改变背景的动画,当然这比较简单,3.1中的问题2才是困扰我的,所以我们来看一看系统内置动画是如何处理的:
场景很简单:

StartScene中含有View1EndScene中已经没有View1了,然后我们设置动画为Fade(淡入淡出),那么应该执行View1一个淡出的效果。但是我们前面分析可知,在动画还没有开始的时候View1已经被我们从rootView中直接remove掉了,新的视图树中已然没有了View1

所以问题就是我们需要在一个视图树执行一个View的动画,然后这个View并没有在视图树中,OMG,这怎么可能?

幸好,系统在API 18提供了一个神奇的类,ViewOverlay,看名字就知道,是在一个View上面添加一个浮层,我们可以通过View.getOverlay()来获取当前View的透明层,我们可以往透明层中添加View&Drawable,然后对这个View进行动画即可。我们看一个例子,再次安利一下本文所有的Demo都可以从[demo-github][3]这里下载到。

transition_demo6.gif

这个布局的层级如下:

<FrameLayout>   //绿色容器
    <FrameLayout> //灰色容器
        <Button>  //蓝色Button
        </Button>
    </FrameLayout>
</FrameLayout>

这大家可以看到蓝色的Button在做移动动画时,居然已经超出了父布局[灰色]的位置,这里其实就是把Button放到了整个布局(绿色)的ViewOverlay中。

ok,到这里困扰我的最后一个问题也有了结果,我们就可以把一个不属于当前视图树的View在当前视图中做一个动画了,我们看看Fade中是怎么做的吧!这部分代码在Fade父类VisibilityonDisappear中。

public Animator onDisappear(ViewGroup sceneRoot,
        TransitionValues startValues, int startVisibility,
        TransitionValues endValues, int endVisibility) {

    //end场景中View已经被删除..
    if (endView == null || endView.getParent() == null) {
          //父View被删除
        if (endView != null) {
            overlayView = endView;
        } else if (startView != null) {
            //因为直接使用ViewOverlay.addView会把原来的View从父布局中remove,所以这里需要进行一些判断看是否安全
            if (startView.getParent() == null) {
                overlayView = startView;
            } else if (startView.getParent() instanceof View) {
               //如果不能直接Add,那么这里直接去把之前的View的Bitmap
                overlayView = TransitionUtils.copyViewImage(sceneRoot, startView,startParent);                 
            }
        }
    } else {
        // visibility change
        if (endVisibility == View.INVISIBLE) {
            viewToKeep = endView;
        } else {
            // Becoming GONE
            if (startView == endView) {
                viewToKeep = endView;
            } else {
                overlayView = startView;
            }
        }
    }

    if (overlayView != null) {
        // TODO: Need to do this for general case of adding to overlay
        int[] screenLoc = (int[]) startValues.values.get(PROPNAME_SCREEN_LOCATION);
        int screenX = screenLoc[0];
        int screenY = screenLoc[1];
        int[] loc = new int[2];
        sceneRoot.getLocationOnScreen(loc);
        overlayView.offsetLeftAndRight((screenX - loc[0]) - overlayView.getLeft());
        overlayView.offsetTopAndBottom((screenY - loc[1]) - overlayView.getTop());
        sceneRoot.getOverlay().add(overlayView);
        Animator animator = onDisappear(sceneRoot, overlayView, startValues, endValues);
        if (animator == null) {
            sceneRoot.getOverlay().remove(overlayView);
        } else {
            final View finalOverlayView = overlayView;
            addListener(new TransitionListenerAdapter() {
                @Override
                public void onTransitionEnd(Transition transition) {
                    finalSceneRoot.getOverlay().remove(finalOverlayView);
                }
            });
        }
        return animator;
    }
    //前后两个view都在一个视图树上,ok,直接动画就好!
    if (viewToKeep != null) {
    }
    return null;
}

至此,过渡动画你已经完全了解到了它的工作机制,这个系列一个是三篇文章,第三篇是 Android & Fragment间的切换动画,敬请期待。
[1]:http://www.jianshu.com/p/b72718bade45
[2]:http://lafosca.cat/viewoverlay-in-android/
[3]:https://github.com/chuyun923/Android-Transitions

推荐阅读更多精彩内容