美文网首页Android开发经验谈Android开发Android开发
Android高阶转场动画-ShareElement完全攻略

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

作者: yellowcath | 来源:发表于2018-10-13 13:57 被阅读26次

    本篇文章已授权微信公众号 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:硬编码短视频录制,支持分段录制、所见即所得

    相关文章

      网友评论

      • 一瞬间的浮华:你的demo中glide加载的图片点击会闪一下,还有你这是左右滑动切换图片视频,能上下切换吗,库里边集成了吗,还是得自己自定义?
        yellowcath:1、闪的问题我这里模拟器和真机都没有,请问是指网络图片加载的那一下闪么?是的话可以用Glide的加载动画解决
        2、上下切换的话github搜"VerticalViewPager",直接换掉Demo里的ViewPager就变成上下切换了

      本文标题:Android高阶转场动画-ShareElement完全攻略

      本文链接:https://www.haomeiwen.com/subject/dusoaftx.html