InstaMaterial:正確處理RecyclerView動畫
我們生活在一個app不僅要能用還要流暢和好看的年代。不同與幾年前,我們對ListView adapter唯一要做的事情就是調用notifyDataSetChanged(),屏幕一閃,顯示新的數據,完事。
今天,在RenderThread的時代,MaterialDesign動畫以及過渡效果的app應該完全展示出所發生的事情。用戶應該看到什么時候它的集合發生了改變或者什么時候新的元素出現(或者被移除)。
幾周前,我們看到了(現場或者線上)一個偉大的安卓大會 - Android Dev Summit。在這兩天的深入技術會議中我們可以看到安卓工程師團隊推出了的新東西 - Android Studio 2.0 ,新的Gradle 插件, Instant run 功能,新的官方模擬器等等。
順便說一句,如果你錯過了,我強烈建議去觀看整個 播放列表 - 這里面有很多關于安卓開發,工具以及解決方案的會議。
其中的一個視頻- RecyclerView Animations and Behind the Scenes 正是寫本文的原因。 Chet Haase 和 Yigit Boyar 過了一遍RecyclerView的item動畫并演示了如何正確的做這件事。對于要學習如何讓RecyclerView更有吸引力更好看來說,這個視頻是個很好的開始。
https://youtu.be/imsr8NrIAMs?list=PLWz5rJ2EKKc_Tt7q77qwyKRgytF1RzRx8
InstaMaterial 遇到 RecyclerView使用指南
今天我們將從特定的角度去看看RecyclerView動畫(很快我將試試正式的去深入探討整個RecyclerView)。
我們想要整理的InstaMaterial源碼在 這個 commit 中(最新的前幾個版本已經根據以下的描述進行了更新。)
還有一點同樣重要 - 從一個用戶的角度來說,這個版本沒有任何改變。但是從代碼的角度來說,我們將更明智(至少更干凈)。
期望的效果
我們想要重建的代碼負責兩個相似的操作:
-
like操作:點擊item主圖片
-
like歡操作:點擊喜歡按鈕
這些動畫應該在RecyclerView中被觸發。
-
出場動畫-當對象首次添加的時候, feed item從底部滑出。
-
大like動畫 - 當用戶點擊主圖片的時候,圓形背景的心形播放動畫。
-
like按鈕動畫 - 用戶點擊like按鈕或者點擊主圖片(這樣就是兩個動畫被播放)心形旋轉并被填充。
這里是上面描述的動畫(從最近的app錄制過來):
Appearance animation
大的like動畫
like按鈕動畫
代碼
以前我們的動畫直接在 RecyclerView.Adapter的子類 FeedAdapter 里實現的,一切運行正常,那這個方法到底有什么問題呢?
-
RecyclerView.Adapter 并不是為 動畫而 設計的。根據 文檔 :
Adapters提供了app專用數據到視圖的綁定。 adapter已經有足夠多的綁定代碼,如果再加上動畫代碼將加倍。
-
在 adapter中處理動畫我們需要考慮如何結束它們,恰當的處理view的回收,確保它們在正確的時間準時播放以及更多的事情。所有的事情都靠我們自己。
-
雖然單個item動畫好處理,但是對象間的互動(移動/交換item,當新的對象顯示或者消失時更新item的位置)則是更復雜的事情。
-
RecyclerView的發明者為我們提供了官方的解決方案: RecyclerView.ItemAnimator:
這個類定義了當 adapter變化時,發生在 item上的動畫。
它處理了上面提到的所有情況。因此我們可以更多的去考慮動畫的質量,而不是它們在滾動周期中該如何正確
處理
的邏輯。
讓我們再次看看 FeedAdapter 。
這幾行的代碼是不應該在這里的:
private static final DecelerateInterpolator DECCELERATE_INTERPOLATOR = new DecelerateInterpolator(); private static final AccelerateInterpolator ACCELERATE_INTERPOLATOR = new AccelerateInterpolator(); private static final OvershootInterpolator OVERSHOOT_INTERPOLATOR = new OvershootInterpolator(4); private static final int ANIMATED_ITEMS_COUNT = 2;
interpolators.java hosted with ? by GitHub
private boolean showLoadingView = false;
showLoadingView.java hosted with ? by GitHub
我們需要控制什么時候item動畫什么時候不動畫(item應該在第一次顯示的時候動畫,而不是在activity恢復的時候)。
private final Map<RecyclerView.ViewHolder, AnimatorSet> likeAnimations = new HashMap<>();
likeAnimations.java hosted with ? by GitHub
我們應該把動畫保存在某個位置,以防我們需要在回收的時候檢查它們是否還在運行或者等待被取消。
private void runEnterAnimation(View view, int position) {
if (!animateItems || position >= ANIMATED_ITEMS_COUNT - 1) {
return;
}
if (position > lastAnimatedPosition) {
lastAnimatedPosition = position;
view.setTranslationY(Utils.getScreenHeight(context));
view.animate()
.translationY(0)
.setInterpolator(new DecelerateInterpolator(3.f))
.setDuration(700)
.start();
}
}
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
runEnterAnimation(viewHolder.itemView, position);
//...
} runEnterAnimation.java hosted with ? by GitHub
這里,我們在每次view被綁定的時候運行runEnterAnimation,并檢查當前是否是應該這么做(item動畫只能有一次)。鑒于我們描述的場景,方法的命名可能有些迷惑。
@Override
public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
//...
bindLoadingFeedItem(holder);
}
private void bindDefaultFeedItem(int position, CellFeedViewHolder holder) {
//...
updateLikesCounter(holder, false);
updateHeartButton(holder, false);
holder.btnComments.setTag(position);
holder.btnMore.setTag(position);
holder.ivFeedCenter.setTag(holder);
holder.btnLike.setTag(holder);
if (likeAnimations.containsKey(holder)) {
likeAnimations.get(holder).cancel();
}
resetLikeAnimationState(holder);
} onBindViewHolder.java hosted with ? by GitHub
在onBindViewHolder()的某個時刻,如果動畫已經運行,那么我們取消它。這是因為view可能被回收而我們不知道它們是否已經完成。
updateLikesCounter() 和 updateHeartButton()方法負責兩種情況下(動畫與靜態)內容的設置。
我們的代碼也有一個問題。
我們把position傳遞給按鈕:
holder.btnComments.setTag(position); holder.btnMore.setTag(position);
holder.java hosted with ? by GitHub
讓它在后面的onClick()方法中獲得:
@Override
public void onClick(View view) {
//...
if (viewId == R.id.btnComments) {
//...
} else if (viewId == R.id.btnMore) {
if (onFeedItemClickListener != null) {
onFeedItemClickListener.onMoreClick(view, (Integer) view.getTag());
}
}
//...
} onClick.java hosted with ? by GitHub
這個position索引并非總是準確的。尤其是這個position用于這兩個情形時:放置上下文菜單到屏幕的正確位置和把adapter的item傳遞給它的時候(好吧,理論上講是這種情況)。
因為RecyclerView可以通過異步的方式更新數據(item視圖可以不用更新數據就被移除 - 比如notifyItemMoved()),所以有可能我們的position指向的是錯誤的數據。
這非常類似于 Yigit Boyar 所討論的:
我們不能假設item position是final的(這張幻燈片中的代碼就會導致問題)。
所以我們應該轉使用RecyclerView.ViewHolder的兩個方法:
新的實現
讓我們從頭再來。我們的Feed將由這些部分組成:
-
FeedItemAnimator which extends DefaultItemAnimator (which extends RecyclerView.ItemAnimator). It will give us base for default animations performed by RecyclerView (mainly fade in/out) which we can extend in places which are important for us. FeedItemAnimator繼承于 DefaultItemAnimator(而它繼承于 RecyclerView.ItemAnimator )。它讓我們
-
LinearLayoutManager - 跟前面一樣,讓 feed看起來像一個標準的列表。
-
FeedAdapter - 綁定數據 (并且只做這件事).
FeedItemAnimator
FeedItemAnimator 的完整代碼。
同時這里的FeedItemAnimator中我們有一個更有趣的代碼:
@Override
public boolean canReuseUpdatedViewHolder(RecyclerView.ViewHolder viewHolder) {
return true;
} canReuseUpdatedViewHolder.java hosted with ? by GitHub
注:這個方法是Android Support Library 23.1中加入的 ,參見: Android Support Library 23.1的變化 一文。
When we’re animating RecyclerView items we have a chance to ask RecyclerView to keep the previous ViewHolder of the item as-is and provide a new ViewHolder which will animate changes from the previous one (only new ViewHolder will be visible on our RecyclerView). But when we are writing a custom item animator for our layout we should use same ViewHolder and animate the content changes manually. This is why our method returns true in this case.
在我們播放RecyclerView item動畫的時候,我們有一次讓RecyclerView保持前一個item 的ViewHolder
@NonNull
@Override
public ItemHolderInfo recordPreLayoutInformation(@NonNull RecyclerView.State state,
@NonNull RecyclerView.ViewHolder viewHolder,
int changeFlags, @NonNull List<Object> payloads) {
if (changeFlags == FLAG_CHANGED) {
for (Object payload : payloads) {
if (payload instanceof String) {
return new FeedItemHolderInfo((String) payload);
}
}
}
return super.recordPreLayoutInformation(state, viewHolder, changeFlags, payloads);
} recordPreLayoutInformation.java hosted with ? by GitHub
當我們調用notifyItemChanged() 方法時,我們可以傳入額外的參數幫助我們決定該執行什么什么動畫。
FeedAdapter的例子:
-
notifyItemChanged(adapterPosition, ACTION_LIKE_IMAGE_CLICKED);
-
notifyItemChanged(adapterPosition, ACTION_LIKE_BUTTON_CLICKED);
recordPreLayoutInformation() 方法用于在數據改變之前緩存數據。RecyclerView調用onBindViewHolder()(adapter中)然后ItemAnimator調用recordPostLayoutInformation() 緩存數據。
正因為這些操作我們才能得到item改變前后的狀態。
最后是調用animateChange()方法,并傳入前后ItemHolderInfo對象。如下:
@Override
public boolean animateChange(@NonNull RecyclerView.ViewHolder oldHolder,
@NonNull RecyclerView.ViewHolder newHolder,
@NonNull ItemHolderInfo preInfo,
@NonNull ItemHolderInfo postInfo) {
cancelCurrentAnimationIfExists(newHolder);
if (preInfo instanceof FeedItemHolderInfo) {
FeedItemHolderInfo feedItemHolderInfo = (FeedItemHolderInfo) preInfo;
FeedAdapter.CellFeedViewHolder holder = (FeedAdapter.CellFeedViewHolder) newHolder;
animateHeartButton(holder);
updateLikesCounter(holder, holder.getFeedItem().likesCount);
if (FeedAdapter.ACTION_LIKE_IMAGE_CLICKED.equals(feedItemHolderInfo.updateAction)) {
animatePhotoLike(holder);
}
}
return false;
} animateChange.java hosted with ? by GitHub
我們已經清楚的看到心形按鈕動畫總是被觸發,而大圖片動畫只在用戶點擊feed圖片的時候被觸發。這正是我們想要的效果。
第二件事-入場動畫。它應該在我們第一次看見列表的時候被觸發。如何處理呢?如下:
@Override
public boolean animateAdd(RecyclerView.ViewHolder viewHolder) {
if (viewHolder.getItemViewType() == FeedAdapter.VIEW_TYPE_DEFAULT) {
if (viewHolder.getLayoutPosition() > lastAddAnimatedItem) {
lastAddAnimatedItem++;
runEnterAnimation((FeedAdapter.CellFeedViewHolder) viewHolder);
return false;
}
}
dispatchAddFinished(viewHolder);
return false;
} animateAdd.java hosted with ? by GitHub
當FeedAdapter觸發 notifyItemRangeInserted()的時候, 這個 RecyclerView.ItemAnimator的方法將被調用。另一個方法就是調用notifyItemInserted()。
還有什么?
@Override
public void endAnimation(RecyclerView.ViewHolder item) {
super.endAnimation(item);
cancelCurrentAnimationIfExists(item);
}
@Override
public void endAnimations() {
super.endAnimations();
for (AnimatorSet animatorSet : likeAnimationsMap.values()) {
animatorSet.cancel();
}
} endAnimation.java hosted with ? by GitHub
實現這兩個方法是很有必要的。這樣當item視圖從屏幕上出現的時候,RecyclerView能夠停止item視圖上的動畫(同時也將準備好回收)。
另外這兩個方法也值得一提:
-
dispatchAddFinished() - should be called when animation from animateAdd() is finished (this will inform RecyclerView that view is ready for recycle).
-
dispatchAnimationFinished() - as above, for animateChange().
今天就是這么多。我們更新過的FeedAdapter比之前少了200行代碼,同時只負責數據和視圖的綁定。這里是它完整的 代碼 。
源碼
最新版本的InstaMaterial源代碼可在Github repository 上得到。