本篇文章已授权微信公众号 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
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,需要以下步骤
查看原图
实际代码可参考ChangeTextTransition
YcShareElement
demo里用了
GSYVideoPlayer展示视频
Fresco、Glide展示图片
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:硬编码短视频录制,支持分段录制、所见即所得
网友评论
2、上下切换的话github搜"VerticalViewPager",直接换掉Demo里的ViewPager就变成上下切换了