Android高階轉場動畫-ShareElement完全攻略
看完本文你能學到什麼:
1、ShareElement是什麼以及基本用法
2、理解ShareElement是如何運作的
3、掌握ShareElement的進階用法(Fresco、Glide、RecyclerView&ViewPager圖片視訊混合的情況下如何實現ShareElement動畫)
4、一個封裝好可以簡單實現以上ShareElement動畫的開源庫 ofollow,noindex">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 展示視訊
YcShareElement提供了兩個demo,一個是上面的聯絡人demo,另一個實現了圖片、視訊混合的列表頁與詳情頁之間的ShareElement動畫,如下圖

YcShareElementDemo
這裡面的關鍵點如下:
1、Glide圖片的ShareElement動畫
ImageView在動畫過程中要經歷預設背景色->小縮圖->大圖三個階段,如何在這三個階段裡做到無縫切換
參考: ChangeOnlineImageTransition
2、Fresco圖片的ShareElement動畫
Fresco提供了內建的DraweeTransition,但是如果設定了縮圖,圖片就會變形,並且必須在建構函式裡提供動畫起始的ScaleType資訊,簡單的情況很好用,在複雜的情況下不太友好
參考: pinguo/shareelementdemo/advanced/list/AdvancedDraweeTransition.java" target="_blank" rel="nofollow,noindex">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加入到YcShareElement
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.setShareElementTransitionFactory(new FrescoShareElementTransitionfactory()); ... }
廣告時間
在文末安利一下我的另外幾個開源庫,歡迎大家來提issue、star、fork
PhotoMovie :高仿抖音照片電影功能
VideoProcessor :用硬編碼實現視訊的快慢放、倒流及混音功能
SVideoRecorder :硬編碼短視訊錄製,支援分段錄製、所見即所得