Android高阶转场动画-ShareElement完全攻略

本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

看完本文你能学到什么:

1、ShareElement是什么以及基本用法
2、理解ShareElement是如何运作的
3、掌握ShareElement的进阶用法(Fresco、Glide、RecyclerView&ViewPager图片视频混合的情况下如何实现ShareElement动画)
4、一个封装好可以简单实现以上ShareElement动画的开源库 YcShareElement(https://github.com/yellowcath/YcShareElement)

[TOC]

什么是ShareElement

ShareElement即两个Activity(或Fragment)之间切换时的共享元素,如下图,可以看到,选中的联系人头像和名字直接很自然地过渡到了下一页的位置,这两个就是本次切换动画的ShareElement

ContactsAnim.gif

ShareElement这一套也能实现同一个Activity(Fragment)内部的复杂切换动画,不过因为在Activity内部做动画有太多现成的手段,所以本文不涉及这方面内容

ShareElement应用场景

以我个人的观点,ShareElement最好的应用场景之一就是现在的以图片、视频为主的内容流APP。下面是我司应用了ShareElement的app与某app的用户浏览体验对比


c360.gif

dy.gif

如何实现ShareElement

或许很多人第一次看到类似这种MaterialDesign里炫酷的界面切换效果时,也会有和我一样的疑惑,
这么炫酷的效果是怎么实现的?两个Activity之间怎么能切换的如此自然?
实际上,这样的效果单凭开发者自己确实很难实现,幸运的是,在Api21之后,官方提供了一套现成的工具来帮我们实现这个功能,核心就是以下四个函数:

    Window.setEnterTransition()
    Window.setExitTransition()
    Window.setSharedElementEnterTransition()
    Window.setSharedElementExitTransition()

这里我们先以一个简单的仿官方联系人效果的Demo介绍下实现ShareElement的基本流程

Activity A

public class ContactsActivity extends Activity {
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        /**
         *1、打开FEATURE_CONTENT_TRANSITIONS开关(可选),这个开关默认是打开的
         */
        requestWindowFeature(Window.FEATURE_CONTENT_TRANSITIONS); 
        /**
         *2、设置除ShareElement外其它View的退出方式(左边滑出)
         */
        getWindow().setExitTransition(new Slide(Gravity.LEFT));
        super.onCreate(savedInstanceState);
        ...
    }
    
    @Override
    public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
        ...
        /**
         *3、设置两个Activity的共享元素的TransitionName,
         *两个Activity的共享元素必须设置同样的TransitionName
         */
        ViewCompat.setTransitionName(avatarImg,"avatar:"+item.name);
        ViewCompat.setTransitionName(nameTxt,"name:"+item.name);
    }
    
    private void gotoDetailActivity(Contacts contacts, final View avatarImg, final View nameTxt) {
        Intent intent = new Intent(ContactActivity.this,DetailActivity.class);
        Pair<View,String> pair1 = new Pair<>((View)avatarImg,ViewCompat.getTransitionName(avatarImg));
        Pair<View,String> pair2 = new Pair<>((View)nameTxt,ViewCompat.getTransitionName(nameTxt));
        /**
         *4、生成带有共享元素的Bundle,这样系统才会知道这几个元素需要做动画
         */
        ActivityOptionsCompat activityOptionsCompat = ActivityOptionsCompat.makeSceneTransitionAnimation(ContactActivity.this, pair1, pair2);
        ActivityCompat.startActivity(ContactActivity.this,intent,activityOptionsCompat.toBundle());
    }
}

Activity B

public class DetailActivity extends Activity {

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_detail);

        ImageView avatarImg = findViewById(R.id.avatar);
        TextView nameTxt = findViewById(R.id.name);
        Contacts item = getIntent().getParcelableExtra(ContactsActivity.KEY_CONTACTS);
        /**
         * 1、设置相同的TransitionName
         */
        ViewCompat.setTransitionName(avatarImg,"avatar:"+item.name);
        ViewCompat.setTransitionName(nameTxt,"name:"+item.name);
        /**
         * 2、设置WindowTransition,除指定的ShareElement外,其它所有View都会执行这个Transition动画
         */
        getWindow().setEnterTransition(new Fade());
        getWindow().setExitTransition(new Fade());
        /**
         * 3、设置ShareElementTransition,指定的ShareElement会执行这个Transiton动画
         */
        TransitionSet transitionSet = new TransitionSet();
        transitionSet.addTransition(new ChangeBounds());
        transitionSet.addTransition(new ChangeTransform());
        transitionSet.addTarget(avatarImg);
        transitionSet.addTarget(nameTxt);
        getWindow().setSharedElementEnterTransition(transitionSet);
        getWindow().setSharedElementExitTransition(transitionSet);
    }
}

运行一下看效果

contacts1.gif

可以看到,头像和名字位置是很顺利的过渡了,但是名字的大小和颜色并没有和之前的官方demo一样完美过渡,这是因为官方默认提供的Transition动画只有以下几个:

ChangeBounds:View的大小与位置动画
ChangeTransform:View的缩放与旋转动画
ChangeClipBounds:View的裁剪区域(View.getClipBounds())动画
ChangeScroll:处理View的scrollX与scrollY属性
ChangeImageTransform:处理ImageView的ScaleType属性(这个在实际项目中有网络图片时不好用,后文有解决方案)

可以看到并没有对TextView的字体大小和颜色做处理

俗话说得好,自己动手丰衣足食,我们来自定义一个Transition动画

public class ChangeTextTransition extends Transition {
    @Override
    public void captureStartValues(TransitionValues transitionValues) {}

    @Override
    public void captureEndValues(TransitionValues transitionValues) {}

    @Override
    public Animator createAnimator(ViewGroup sceneRoot, TransitionValues startValues, TransitionValues endValues){
        return super.createAnimator(sceneRoot, startValues, endValues);
    }
}

Transition的设计思路是,每一个Transition类负责整个动画的一部分,在这个例子里,TextView的平移和大小变化已经由ChangeBounds实现了,因此我们自定义的Transition只需要实现字体大小和颜色的动画就行了

可以看到,自定义Transition需要实现三个函数,要达到我们想要的效果,需要:
1、在captureStartValues里获取到TextView在Activity A里的状态(字体和颜色)
2、在captureEndValues里获取到TextView在Activity B里的状态(字体和颜色)
3、在createAnimator里利用获取到的初始和结束状态创建一个Animator
最简单的方法就是在创建ChangeTextTransition的时候传入相应的参数,不过缺点是:
1、进入和退出时需要不同的参数
2、如果有多个TextView都需要做动画怎么办?有多少传多少参数?
3、不够优雅 :)
想要解决以上缺点,就需要了解ShareElement动画的完整流程

ShareElement完整流程

要实现自定义的ShareElement动画,一切的重点都在于Activity对外暴露的回调SharedElementCallback

SharedElementCallback

你可以通过以下两个函数设置这个回调

activity.setExitSharedElementCallback(callback)
activity.setEnterSharedElementCallback(callback)

SharedElementCallback有以下7个回调,最麻烦的是,这几个回调在进入和退出时的调用顺序是不一致的

SharedElementCallback是一个抽象类,所有回调都有默认实现

    /**
    *最先调用,用于动画开始前替换ShareElements,比如在Activity B翻过若干页大图之后,返回Activity A
    *的时候需要缩小回到对应的小图,就需要在这里进行替换
    */
    public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) {}

    /**
    *表示ShareElement已经全部就位,可以开始动画了
    */
    public void onSharedElementsArrived(List<String> sharedElementNames, List<View> sharedElements, OnSharedElementsReadyListener listener) {}

    /**
    *在之前的步骤里(onMapSharedElements)被从ShareElements列表里除掉的View会在此回调,
    *不处理的话默认进行alpha动画消失
    */
    public void onRejectSharedElements(List<View> rejectedSharedElements) {}
    
    /**
    *在这里会把ShareElement里值得记录的信息存到为Parcelable格式,以发送到Activity B
    *默认处理规则是ImageView会特殊记录Bitmap、ScaleType、Matrix,其它View只记录大小和位置
    */
    public Parcelable onCaptureSharedElementSnapshot(View sharedElement, Matrix viewToGlobalMatrix, RectF screenBounds) {}
    
    /**
    *在这里会把Activity A传过来的Parcelable数据,重新生成一个View,这个View的大小和位置会与Activity A里的
    *ShareElement一致,
    */
    public View onCreateSnapshotView(Context context, Parcelable snapshot) {}

    public void onSharedElementStart(List<String> sharedElementNames, List<View> sharedElements, List<View> sharedElementSnapshots) {}

    public void onSharedElementEnd(List<String> sharedElementNames, List<View> sharedElements, List<View> sharedElementSnapshots) {}

下图展示了从Activity A切换到Activity B,SharedElementCallback被调用的时序

ShareElement.png

查看原图

图里我标了几个值得注意的点:

1、moveSharedElementsToOverlay()

    protected void moveSharedElementsToOverlay() {
        ...
        ViewGroup decor = getDecor();
        if (decor != null) {
            ...
            for (int i = 0; i < numSharedElements; i++) {
                View view = mSharedElements.get(i);
                if (view.isAttachedToWindow()) {
                    ...
                    GhostView.addGhost(view, decor, tempMatrix);
                   ...
                }
            }
        }
    }

ViewOverlay在Android4.3加入,其父类是ViewGroup,如果想在一个View最上层展示一些东西,可以调用View.getOverlay(),然后调用ViewOverlay.add(drawable)或者ViewOverlay.getOverlayView().addView()函数添加到ViewOverlay.

GhostView可以在不改变一个View的Parent的情况下,把View渲染到另一个ViewGroup里面去.

moveSharedElementsToOverlay()函数实质就是把ShareElementView渲染到整个Activity的最上层(DecorView的ViewOverlay),
这样在做动画时ShareElementView就不会被任何别的东西遮挡住.

2、setSharedElementState()

这里需要提一点,在这个Demo里,整个ShareElement动画过程中,做动画的都只有Activity B里的ShareElement,Activity A里的ShareElement唯一的作用就是提供位置大小等参数,然后这些参数在setSharedElementState()函数里被设置到Activity B里对应的View上.

 private void setSharedElementState(View view, String name, Bundle transitionArgs,
            Matrix tempMatrix, RectF tempRect, int[] decorLoc) {
        ...
        if (view instanceof ImageView) {
            ...
            imageView.setScaleType(scaleType);
            if (scaleType == ImageView.ScaleType.MATRIX) {
                float[] matrixValues = sharedElementBundle.getFloatArray(KEY_IMAGE_MATRIX);
                tempMatrix.setValues(matrixValues);
                imageView.setImageMatrix(tempMatrix);
            }
        }
        ....
        view.setLeft(0);
        view.setTop(0);
        view.setRight(Math.round(width));
        view.setBottom(Math.round(height));
        ...
        view.measure(widthSpec, heightSpec);
        view.layout(x, y, x + width, y + height);
    }

可以看见,如果不是ImageView,系统只处理了大小位置的信息,这也是我们前面的动画里为什么名字的过渡效果那么不自然,因为系统压根就没管字体大小和颜色之类的东西.
(如果是进入动画)在设置好信息之后,会先调用SharedElementCallback.onSharedElementStart,然后就是Transition.captureStartValues()

3、setOriginalSharedElementState()

    protected static void setOriginalSharedElementState(ArrayList<View> sharedElements,
            ArrayList<SharedElementOriginalState> originalState) {
        for (int i = 0; i < originalState.size(); i++) {
            View view = sharedElements.get(i);
            SharedElementOriginalState state = originalState.get(i);
            if (view instanceof ImageView && state.mScaleType != null) {
                ImageView imageView = (ImageView) view;
                imageView.setScaleType(state.mScaleType);
                if (state.mScaleType == ImageView.ScaleType.MATRIX) {
                  imageView.setImageMatrix(state.mMatrix);
                }
            }
            view.setElevation(state.mElevation);
            view.setTranslationZ(state.mTranslationZ);
            int widthSpec = View.MeasureSpec.makeMeasureSpec(state.mMeasuredWidth,
                    View.MeasureSpec.EXACTLY);
            int heightSpec = View.MeasureSpec.makeMeasureSpec(state.mMeasuredHeight,
                    View.MeasureSpec.EXACTLY);
            view.measure(widthSpec, heightSpec);
            view.layout(state.mLeft, state.mTop, state.mRight, state.mBottom);
        }
    }

在Transition.captureStartValues()之后,接着setOriginalSharedElementState()函数会恢复view在Activity B里的状态,
再调用Transition.captureEndValues().

这时候动画的起始和结束状态的已经获得了,TransitionManager就会在onPreDraw()的回调里执行Transiton.playTransition(),
这里面会调用Transition.createAnimator()函数,然后执行这个Animator.这时候ShareElement动画就真正开始了.

返回流程

返回流程这里就不详细分析了,直接给出各个回调的调用顺序

  ActivityB.onMapSharedElements()
->ActivityA.onMapSharedElements()
->ActivityA.onCaptureSharedElementSnapshot()
->ActivityB.onCreateSnapshotView()
->ActivityB.onSharedElementEnd()    
->ActivityB.onSharedElementStart()   //你没有看错,就是先End再Start
->ActivityB.onSharedElementsArrived()
->ActivityA.onSharedElementsArrived()
->ActivityA.onRejectSharedElements()
->ActivityA.onCreateSnapshotView()
->ActivityA.onSharedElementStart()
->ActivityA.onSharedElementEnd()

自定义Transition

由上面的分析可以得出,要实现TextView的Transition,需要以下步骤

EnterTransition.png

查看原图

实际代码可参考ChangeTextTransition

YcShareElement

demo里用了
GSYVideoPlayer展示视频
FrescoGlide展示图片

YcShareElement提供了两个demo,一个是上面的联系人demo,另一个实现了图片、视频混合的列表页与详情页之间的ShareElement动画,如下图

YcShareElementDemo

这里面的关键点如下:
1、Glide图片的ShareElement动画
ImageView在动画过程中要经历默认背景色->小缩略图->大图三个阶段,如何在这三个阶段里做到无缝切换
参考:ChangeOnlineImageTransition
2、Fresco图片的ShareElement动画
Fresco提供了内置的DraweeTransition,但是如果设置了缩略图,图片就会变形,并且必须在构造函数里提供动画起始的ScaleType信息,简单的情况很好用,在复杂的情况下不太友好
参考:AdvancedDraweeTransition
3、从列表的Webp动图到详情页的视频ShareElement动画
这个在实现了以上两点之后其实就很简单了,实际上就是视频的封面图做动画

普通页面使用步骤

1、打开WindowContentTransition开关

YcShareElement.enableContentTransition(getApplication());  

由于这个开关默认是打开的,因此这一句是可选的,担心遇到奇葩手机关掉这个开关的可以调用

2、生成Bundle,然后startActivity

    private void gotoDetailActivity(){
        Intent intent = new Intent(this, DetailActivity.class);
        Bundle bundle = YcShareElement.buildOptionsBundle(ContactActivity.this, new IShareElements() {
            @Override
            public ShareElementInfo[] getShareElements() {
                return new ShareElementInfo[]{new ShareElementInfo(mAvatarImg),
                        new ShareElementInfo(mNameTxt, new TextViewStateSaver())};
            }
        });
        ActivityCompat.startActivity(ContactActivity.this, intent, bundle);
    }

3、新的页面里设置并启动Transition

public class DetailActivity extends Activity {
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        ...
        YcShareElement.setEnterTransition(this, new IShareElements() {
            @Override
            public ShareElementInfo[] getShareElements() {
                return new ShareElementInfo[]{new ShareElementInfo(avatarImg),
                        new ShareElementInfo(nameTxt, new TextViewStateSaver())};
            }
        });
        YcShareElement.startTransition(this);
    }
}

YcShareElement.setEnterTransition()默认会暂停Activity的Transtion动画,直到调用YcShareElement.startTransition(),
在这种不需要等待ShareElement加载的简单页面,可以将第三个参数传false,就不会暂停ActivityB的Transition动画了,如下

    protected void onCreate(@Nullable Bundle savedInstanceState) {
        ...
        YcShareElement.setEnterTransition(this, new IShareElements() {
            @Override
            public ShareElementInfo[] getShareElements() {
                return new ShareElementInfo[]{new ShareElementInfo(avatarImg),
                        new ShareElementInfo(nameTxt, new TextViewStateSaver())};
            }
        },false);
    }

效果如下:


contacts2.gif

图片&视频页面使用步骤

1、打开WindowContentTransition开关

    YcShareElement.enableContentTransition(getApplication());  

2、生成Bundle,然后startActivity

    Bundle options = YcShareElement.buildOptionsBundle(getActivity(), this);
    startActivityForResult(intent, REQUEST_CONTENT, options);

3、Activity B设置Transtion动画

    protected void onCreate(@Nullable Bundle savedInstanceState) {
        YcShareElement.setEnterTransition(this, this);
        ...
    }

4、Activity B的ViewPager加载好之后启动Transition

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        ...加载数据...
        YcShareElement.postStartTransition(getActivity());
    }

这时候进入动画就执行完毕了,接下来要处理滑动若干页之后返回列表页的情况

5、Activity B实现finishAfterTransition()函数

    @Override
    public void finishAfterTransition() {
        YcShareElement.finishAfterTransition(this, this);
        super.finishAfterTransition();
    }

6、Activity A实现onActivityReenter()函数

    @Override
    public void onActivityReenter(int resultCode, Intent data) {
        super.onActivityReenter(resultCode, data);
        YcShareElement.onActivityReenter(this, resultCode, data, new IShareElementSelector() {
            @Override
            public void selectShareElements(List<ShareElementInfo> list) {
                //将列表页滑动到变更后的ShareElement的位置
                mFragment.selectShareElement(list.get(0));
            }
        });
    }

如何扩展支持自定义View的Transition动画

这里以Fresco为例介绍如何进行扩展

1、确定所需参数

首先确定SimpleDraweeView做Transtion动画需要的参数,即ActualImageScaleType

2、继承ViewStateSaver,获取所需参数

public class FrescoViewStateSaver extends ViewStateSaver {

    @Override
    protected void captureViewInfo(View view, Bundle bundle) {
        if (view instanceof GenericDraweeView) {
            int actualScaleTypeInt = scaleTypeToInt(((GenericDraweeView)view).getHierarchy().getActualImageScaleType())
            bundle.putInt("scaleType",actualScaleTypeInt);
        }
    }
    
    public ScalingUtils.ScaleType getScaleType(Bundle bundle) {
        int scaleType = bundle.getInt("scaleType", 0);
        return intToScaleType(scaleType);
    }
}

3、自定义Transition

public class AdvancedDraweeTransition extends Transition {
    private ScalingUtils.ScaleType mFromScale;
    private ScalingUtils.ScaleType mToScale;

    public AdvancedDraweeTransition() {
        addTarget(GenericDraweeView.class);
    }

    @Override
    public void captureStartValues(TransitionValues transitionValues) {
        ...
        ShareElementInfo shareElementInfo = ShareElementInfo.getFromView(transitionValues.view);
        mFromScale = ((FrescoViewStateSaver) shareElementInfo.getViewStateSaver()).getScaleType(viewInfo);
        ...
    }

    @Override
    public void captureEndValues(TransitionValues transitionValues) {
        ...
        ShareElementInfo shareElementInfo = ShareElementInfo.getFromView(transitionValues.view);
        mToScale = ((FrescoViewStateSaver) shareElementInfo.getViewStateSaver()).getScaleType(viewInfo);
        ...
    }

    @Override
    public Animator createAnimator(
            ViewGroup sceneRoot,
            TransitionValues startValues,
            TransitionValues endValues) {
        ..
        ValueAnimator animator = ValueAnimator.ofFloat(0, 1);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float fraction = (float) animation.getAnimatedValue();
                scaleType.setValue(fraction);
                if (draweeView.getHierarchy().getActualImageScaleType() != scaleType) {
                    draweeView.getHierarchy().setActualImageScaleType(scaleType);
                }
            }
        });
        ...
        return animator;
    }
}

4、使用自定义的Transition

public class FrescoShareElementTransitionfactory extends DefaultShareElementTransitionFactory {
    @Override
    protected TransitionSet buildShareElementsTransition(List<View> shareViewList) {
        TransitionSet transitionSet =  super.buildShareElementsTransition(shareViewList);
        transitionSet.addTransition(new AdvancedDraweeTransition());
        return transitionSet;
    }
}
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        ...
        YcShareElement.setEnterTransitions(this, this,true,new FrescoShareElementTransitionfactory());
        ...
    }

广告时间

在文末安利一下我的另外几个开源库,欢迎大家来提issue、star、fork

PhotoMovie:高仿抖音照片电影功能
VideoProcessor:用硬编码实现视频的快慢放、倒流及混音功能
SVideoRecorder:硬编码短视频录制,支持分段录制、所见即所得

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 144,106评论 18 619
  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 9,152评论 2 49
  • 【Android 动画】 动画分类补间动画(Tween动画)帧动画(Frame 动画)属性动画(Property ...
    Rtia阅读 3,238评论 1 36
  • 经常都有这样子感觉:被突如其来的危机感,给恐惧了!到了某个年龄阶层,还一无所获,的确很悲哀。或许来自于对比的压力,...
    念叨唠阅读 97评论 0 0
  • 早起的鸟儿 今天早起了一会,趁先生和孩子还未起床,打上豆浆,自己来到卫生间给刮痧神器插上电,开始一早的刮痧时间,抹...
    艾米黎妈阅读 58评论 1 0