本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家發(fā)布
看完本文你能學到什么:
1、ShareElement是什么以及基本用法
2、理解ShareElement是如何運作的
3、掌握ShareElement的進階用法(Fresco、Glide、RecyclerView&ViewPager圖片視頻混合的情況下如何實現(xiàn)ShareElement動畫)
4、一個封裝好可以簡單實現(xiàn)以上ShareElement動畫的開源庫 YcShareElement(https://github.com/yellowcath/YcShareElement)
[TOC]
什么是ShareElement
ShareElement即兩個Activity(或Fragment)之間切換時的共享元素,如下圖,可以看到,選中的聯(lián)系人頭像和名字直接很自然地過渡到了下一頁的位置,這兩個就是本次切換動畫的ShareElement

ShareElement這一套也能實現(xiàn)同一個Activity(Fragment)內部的復雜切換動畫,不過因為在Activity內部做動畫有太多現(xiàn)成的手段,所以本文不涉及這方面內容
ShareElement應用場景
以我個人的觀點,ShareElement最好的應用場景之一就是現(xiàn)在的以圖片、視頻為主的內容流APP。下面是我司應用了ShareElement的app與某app的用戶瀏覽體驗對比


如何實現(xiàn)ShareElement
或許很多人第一次看到類似這種MaterialDesign里炫酷的界面切換效果時,也會有和我一樣的疑惑,
這么炫酷的效果是怎么實現(xiàn)的?兩個Activity之間怎么能切換的如此自然?
實際上,這樣的效果單憑開發(fā)者自己確實很難實現(xiàn),幸運的是,在Api21之后,官方提供了一套現(xiàn)成的工具來幫我們實現(xiàn)這個功能,核心就是以下四個函數(shù):
Window.setEnterTransition()
Window.setExitTransition()
Window.setSharedElementEnterTransition()
Window.setSharedElementExitTransition()
這里我們先以一個簡單的仿官方聯(lián)系人效果的Demo介紹下實現(xiàn)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,這樣系統(tǒng)才會知道這幾個元素需要做動畫
*/
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都會執(zhí)行這個Transition動畫
*/
getWindow().setEnterTransition(new Fade());
getWindow().setExitTransition(new Fade());
/**
* 3、設置ShareElementTransition,指定的ShareElement會執(zhí)行這個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);
}
}
運行一下看效果

可以看到,頭像和名字位置是很順利的過渡了,但是名字的大小和顏色并沒有和之前的官方demo一樣完美過渡,這是因為官方默認提供的Transition動畫只有以下幾個:
ChangeBounds:View的大小與位置動畫
ChangeTransform:View的縮放與旋轉動畫
ChangeClipBounds:View的裁剪區(qū)域(View.getClipBounds())動畫
ChangeScroll:處理View的scrollX與scrollY屬性
ChangeImageTransform:處理ImageView的ScaleType屬性(這個在實際項目中有網(wǎng)絡圖片時不好用,后文有解決方案)
可以看到并沒有對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的平移和大小變化已經(jīng)由ChangeBounds實現(xiàn)了,因此我們自定義的Transition只需要實現(xiàn)字體大小和顏色的動畫就行了
可以看到,自定義Transition需要實現(xiàn)三個函數(shù),要達到我們想要的效果,需要:
1、在captureStartValues里獲取到TextView在Activity A里的狀態(tài)(字體和顏色)
2、在captureEndValues里獲取到TextView在Activity B里的狀態(tài)(字體和顏色)
3、在createAnimator里利用獲取到的初始和結束狀態(tài)創(chuàng)建一個Animator
最簡單的方法就是在創(chuàng)建ChangeTextTransition的時候傳入相應的參數(shù),不過缺點是:
1、進入和退出時需要不同的參數(shù)
2、如果有多個TextView都需要做動畫怎么辦?有多少傳多少參數(shù)?
3、不夠優(yōu)雅 :)
想要解決以上缺點,就需要了解ShareElement動畫的完整流程
ShareElement完整流程
要實現(xiàn)自定義的ShareElement動畫,一切的重點都在于Activity對外暴露的回調SharedElementCallback
SharedElementCallback
你可以通過以下兩個函數(shù)設置這個回調
activity.setExitSharedElementCallback(callback)
activity.setEnterSharedElementCallback(callback)
SharedElementCallback有以下7個回調,最麻煩的是,這幾個回調在進入和退出時的調用順序是不一致的
SharedElementCallback是一個抽象類,所有回調都有默認實現(xiàn)
/**
*最先調用,用于動畫開始前替換ShareElements,比如在Activity B翻過若干頁大圖之后,返回Activity A
*的時候需要縮小回到對應的小圖,就需要在這里進行替換
*/
public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) {}
/**
*表示ShareElement已經(jīng)全部就位,可以開始動畫了
*/
public void onSharedElementsArrived(List<String> sharedElementNames, List<View> sharedElements, OnSharedElementsReadyListener listener) {}
/**
*在之前的步驟里(onMapSharedElements)被從ShareElements列表里除掉的View會在此回調,
*不處理的話默認進行alpha動畫消失
*/
public void onRejectSharedElements(List<View> rejectedSharedElements) {}
/**
*在這里會把ShareElement里值得記錄的信息存到為Parcelable格式,以發(fā)送到Activity B
*默認處理規(guī)則是ImageView會特殊記錄Bitmap、ScaleType、Matrix,其它View只記錄大小和位置
*/
public Parcelable onCaptureSharedElementSnapshot(View sharedElement, Matrix viewToGlobalMatrix, RectF screenBounds) {}
/**
*在這里會把Activity A傳過來的Parcelable數(shù)據(jù),重新生成一個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被調用的時序

查看原圖
圖里我標了幾個值得注意的點:
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()函數(shù)添加到ViewOverlay.
GhostView可以在不改變一個View的Parent的情況下,把View渲染到另一個ViewGroup里面去.
moveSharedElementsToOverlay()函數(shù)實質就是把ShareElementView渲染到整個Activity的最上層(DecorView的ViewOverlay),
這樣在做動畫時ShareElementView就不會被任何別的東西遮擋住.
2、setSharedElementState()
這里需要提一點,在這個Demo里,整個ShareElement動畫過程中,做動畫的都只有Activity B里的ShareElement,Activity A里的ShareElement唯一的作用就是提供位置大小等參數(shù),然后這些參數(shù)在setSharedElementState()函數(shù)里被設置到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,系統(tǒng)只處理了大小位置的信息,這也是我們前面的動畫里為什么名字的過渡效果那么不自然,因為系統(tǒng)壓根就沒管字體大小和顏色之類的東西.
(如果是進入動畫)在設置好信息之后,會先調用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()函數(shù)會恢復view在Activity B里的狀態(tài),
再調用Transition.captureEndValues().
這時候動畫的起始和結束狀態(tài)的已經(jīng)獲得了,TransitionManager就會在onPreDraw()的回調里執(zhí)行Transiton.playTransition(),
這里面會調用Transition.createAnimator()函數(shù),然后執(zhí)行這個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
由上面的分析可以得出,要實現(xiàn)TextView的Transition,需要以下步驟

查看原圖
實際代碼可參考ChangeTextTransition
YcShareElement
demo里用了
GSYVideoPlayer展示視頻
Fresco、Glide展示圖片
YcShareElement提供了兩個demo,一個是上面的聯(lián)系人demo,另一個實現(xiàn)了圖片、視頻混合的列表頁與詳情頁之間的ShareElement動畫,如下圖

這里面的關鍵點如下:
1、Glide圖片的ShareElement動畫
ImageView在動畫過程中要經(jīng)歷默認背景色->小縮略圖->大圖三個階段,如何在這三個階段里做到無縫切換
參考:ChangeOnlineImageTransition
2、Fresco圖片的ShareElement動畫
Fresco提供了內置的DraweeTransition,但是如果設置了縮略圖,圖片就會變形,并且必須在構造函數(shù)里提供動畫起始的ScaleType信息,簡單的情況很好用,在復雜的情況下不太友好
參考:AdvancedDraweeTransition
3、從列表的Webp動圖到詳情頁的視頻ShareElement動畫
這個在實現(xiàn)了以上兩點之后其實就很簡單了,實際上就是視頻的封面圖做動畫
普通頁面使用步驟
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加載的簡單頁面,可以將第三個參數(shù)傳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);
}
效果如下:

圖片&視頻頁面使用步驟
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) {
...加載數(shù)據(jù)...
YcShareElement.postStartTransition(getActivity());
}
這時候進入動畫就執(zhí)行完畢了,接下來要處理滑動若干頁之后返回列表頁的情況
5、Activity B實現(xiàn)finishAfterTransition()函數(shù)
@Override
public void finishAfterTransition() {
YcShareElement.finishAfterTransition(this, this);
super.finishAfterTransition();
}
6、Activity A實現(xiàn)onActivityReenter()函數(shù)
@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、確定所需參數(shù)
首先確定SimpleDraweeView做Transtion動畫需要的參數(shù),即ActualImageScaleType
2、繼承ViewStateSaver,獲取所需參數(shù)
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:用硬編碼實現(xiàn)視頻的快慢放、倒流及混音功能
SVideoRecorder:硬編碼短視頻錄制,支持分段錄制、所見即所得