五行代碼實現 炫動滑動 卡片層疊布局,仿探探、人人影視訂閱界面 簡單&優雅:LayoutManager+ItemTo...
概述
前幾天看有人實現了仿人人美劇的訂閱界面,不過在細節之處以及實現方式我個人認為都不是最佳的姿勢。
于是我也動手擼了一個,還順帶擼了個探探的界面,先看GIF:
探探皇帝翻牌子即視感
人人美劇訂閱界面
這里吐個槽,探探這種設計真的像皇帝翻牌子的感覺,不喜歡左滑,喜歡右滑。
人人影視版特點(需求):
- 動畫:最多可見的這四層,在頂層卡片滑動時, 每一層都會位移&放大動畫,有種補充到頂層的感覺 。
- 動畫:松手時,如果未被判定為刪除,則會有頂層以下每一層卡片 收縮回原位 的動畫。
- 無限循環:模仿人人影視,頂層卡片被刪除后,補充到最底層。
除上述動畫特點,探探版特點(需求):
- Roate的變化:左右滑動時,頂層卡片會慢慢 旋轉 ,到閾值max大概十五度。
- Alpha的變化:左滑時頂層卡片的 刪除按鈕會慢慢顯現 ,右滑時 愛心按鈕會慢慢顯現 。
- 顯然,松手時,以上動畫也需要復位。
我們的效果,基本上和原版一致了,寫起來怎么樣呢?
我不是標題黨,如標題所說:
- 簡單:思路 簡單清晰易理解
- 優雅: 性能 沒有任何隱患, LayoutManager 只會加載顯示屏幕上 可見的數量的View 。
- 快速:利用 ItemTouchHelper 處理拖拽&滑動刪除邏輯,核心代碼不超過50行。且經過封裝,四行代碼就可以用。
伸手黨福利:
如果懶得看這么多文字只想用,直接移步gayhub,gradle導入相關文件or復制。然后如下,搞定。
mRv.setLayoutManager(new OverLayCardLayoutManager());
CardConfig.initConfig(this);
ItemTouchHelper.Callback callback = new RenRenCallback(mRv, mAdapter, mDatas);
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);
itemTouchHelper.attachToRecyclerView(mRv);
而且我將一些參
數都以變量形式計算,這樣就做到了可配置,假如老板讓你一開始多顯示幾層卡片,例如6層,你只需要修改一個參數即可,效果如圖:
6層View
正確的姿勢
正確的姿勢就是:
- 利用 LayoutManager 實現卡片層疊布局,值得注意的是,只layout出界面上可能會看見的那些View。
- 搭配 ItemTouchHelper ,它本身實現了拖拽&滑動刪除邏輯,我們只需要在 onChildDraw() 中繪制動畫和 onSwiped() 中處理數據集(循環or刪除)。
所以本文也算是填了 LayoutManger系列 的坑,實現了一個酷炫效果的布局。
Let's Go!
LayoutManager的實現卡片層疊
其實本例中的 LayoutManager 十分簡單,因為 ItemTouchHelper 的存在, LayoutManager 根本不需要處理它的滑動事件,而 LayoutManager 中最難寫的就是在滑動時的 View 回收和復用,以及 layout 新 View 的處理。
唯一注意事項
但是即便如此,還是有一個唯一的注意事項。我們只 layout 出界面上 可能會看見 的那些 View 即可。
因為考慮到動畫,所以是 可能會看見 。
我們看人人美劇的界面:
底部細節
初始化時,界面上可見三個 View ,我們分別起名: TopView,Top-1View,Top-2View 。其中 TopView 完全可見, Top-1View,Top-2View 只有下邊緣可見。
如文首GIF,滑動 TopView 時, Top-1View,Top-2View 開始慢慢放大,并且向上位移,直至填充至它們各自上層的View。這時候露出了 Top-3View 。
所以我們在書寫 LayoutManager 的 onLayoutChildren() 方法時,只要 layout 出當前數據集 最后四個View 即可。
前文提到的參數配置如下:
包括一些配置
- 界面最多顯示幾個View
-
每一級View之間的Scale差異、translationY等等
public class CardConfig { //屏幕上最多同時顯示幾個Item public static int MAX_SHOW_COUNT; //每一級Scale相差0.05f,translationY相差7dp左右 public static float SCALE_GAP; public static int TRANS_Y_GAP;
public static void initConfig(Context context) { MAX_SHOW_COUNT = 6; SCALE_GAP = 0.05f; TRANS_Y_GAP = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15, context.getResources().getDisplayMetrics()); } }</code></pre> </li> </ul>
LayoutManager全部代碼如下,布滿注釋,如果看不懂,建議閱讀前置文章 LayoutManger系列 :
public class OverLayCardLayoutManager extends RecyclerView.LayoutManager { @Override public RecyclerView.LayoutParams generateDefaultLayoutParams() { return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); } @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { detachAndScrapAttachedViews(recycler); int itemCount = getItemCount(); if (itemCount >= MAX_SHOW_COUNT) { //從可見的最底層View開始layout,依次層疊上去 for (int position = itemCount - MAX_SHOW_COUNT; position < itemCount; position++) { View view = recycler.getViewForPosition(position); addView(view); measureChildWithMargins(view, 0, 0); int widthSpace = getWidth() - getDecoratedMeasuredWidth(view); int heightSpace = getHeight() - getDecoratedMeasuredHeight(view); //我們在布局時,將childView居中處理,這里也可以改為只水平居中 layoutDecoratedWithMargins(view, widthSpace / 2, heightSpace / 2, widthSpace / 2 + getDecoratedMeasuredWidth(view), heightSpace / 2 + getDecoratedMeasuredHeight(view)); /**
* TopView的Scale 為1,translationY 0 * 每一級Scale相差0.05f,translationY相差7dp左右 * * 觀察人人影視的UI,拖動時,topView被拖動,Scale不變,一直為1. * top-1View 的Scale慢慢變化至1,translation也慢慢恢復0 * top-2View的Scale慢慢變化至 top-1View的Scale,translation 也慢慢變化只top-1View的translation * top-3View的Scale要變化,translation巋然不動 */ //第幾層,舉例子,count =7, 最后一個TopView(6)是第0層, int level = itemCount - position - 1; //除了頂層不需要縮小和位移 if (level > 0 /*&& level < mShowCount - 1*/) { //每一層都需要X方向的縮小 view.setScaleX(1 - SCALE_GAP * level); //前N層,依次向下位移和Y方向的縮小 if (level < MAX_SHOW_COUNT - 1) { view.setTranslationY(TRANS_Y_GAP * level); view.setScaleY(1 - SCALE_GAP * level); } else {//第N層在 向下位移和Y方向的縮小的成都與 N-1層保持一致 view.setTranslationY(TRANS_Y_GAP * (level - 1)); view.setScaleY(1 - SCALE_GAP * (level - 1)); } } } } }
}</code></pre>
擼到這里,我們的靜態界面已經成型,下面讓我們動起來:
靜態界面
ItemTouchHelper實現炫動滑動:
ItemTouchHelper 的基礎知識,建議大家自行學習,網上文章很多,我簡單介紹一下,
This is a utility class to add swipe to dismiss and drag & drop support to RecyclerView.
It works with a RecyclerView and a Callback class, which configures what type of interactions
are enabled and also receives events when user performs these actions.
Depending on which functionality you support, you should override
<p>{@link Callback#onMove(RecyclerView, ViewHolder, ViewHolder)} and / or</p> <p>{@link Callback#onSwiped(ViewHolder, int)}.</p>翻譯 + 總結:
這貨是一個工具類,為RecyclerView擴展滑動消失(刪除)和drag & drop效果的。
它需要和RecyclerView、Callback 一起工作。Callback 類里定義了 允許哪些交互,并且會接收到對應的交互事件
根據你需要哪種功能(滑動消失(刪除)和drag & drop),你需要重寫
Callback#onMove(RecyclerView, ViewHolder, ViewHolder)-----drag & drop
Callback#onSwiped(ViewHolder, int) 方法。 -----滑動消失(刪除)
總結一下入門級用法如下,三個步驟:
-
定義一個Callback: ItemTouchHelper.Callback callback = new ItemTouchHelper.SimpleCallback(int,int) ,這兩個int分別代表要 監聽哪幾個方向上的拖拽、滑動事件 。 常用: ItemTouchHelper.DOWN | ItemTouchHelper.UP | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
-
將Callback傳給ItemTouchHelper: ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);
-
關聯ItemTouchHelper和RecyclerView: itemTouchHelper.attachToRecyclerView(mRv)
這三個步驟做完后,ItemTouchHelper就會自動幫我們完成 滑動消失(刪除)和drag & drop 的功能。
滑動刪除
我們本例中,需要的是 滑動消失(刪除) ,所以我們的 Callback 不需要關注 onMove() 方法。
且我們需要上下左右滑動都可以刪除的效果。
則如下構造Callback,傳入上下左右:
ItemTouchHelper.Callback callback = new ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.DOWN | ItemTouchHelper.UP | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT)
onSwiped() 方法,是滑動刪除動作 已經發生后回調 的,即,我們先滑動卡片,然后松手,此時 ItemTouchHelper 判斷我們的手勢是刪除手勢,會 自動對這個卡片執行丟出屏幕外的動畫 ,同時回調 onSwiped() 方法。
所以我們需要在其中如下寫:
@Override public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { //★實現循環的要點 SwipeCardBean remove = mDatas.remove(viewHolder.getLayoutPosition()); mDatas.add(0, remove); mAdapter.notifyDataSetChanged(); }
在這里我們完成了循環的操作:
- 利用當前被刪除的 View 的 ViewHolder 拿到 Position
- 刪除數據集中對應 Position 的數據源
- 同時將該數據源插入數據集中的首位。
- 調用 notifyDataSetChanged() ,通知列表刷新
如此我們便完成了, 循環列表的需求 。
這里提一下為什么我們要調用 notifyDataSetChanged() 。
看官方文檔:
ItemTouchHelper moves the items' translateX/Y properties to reposition them
即ItemTouchHelper實現的滑動刪除,其實只是 隱藏了這個滑動的View 。并不是真的刪除了。
在 LayoutManager實現流式布局 一文第五節中,我們已經提到, notifyDataSetChanged() 會回調 onLayoutChildren() 這個函數,而在這個函數中,我們會 重新布局 ,即真正的移除(不再layout)滑動掉的View,同時 會補充進新的最底層的View 。
嗯,JavaBean也看一眼吧,沒亮點:
public class SwipeCardBean { private int postition;//位置 private String url; private String name; }
我們寫到這里已經完成了滑動刪除的功能,其實我們什么都沒有寫是吧,復雜的判斷都由ItemTouchHelper幫我們處理掉了,例如速度、滑動距離是否到達刪除閾值,刪除成功移除的動畫、取消刪除復位的動畫等等。
所以我說利用ItemTouchHelper才是正確的姿勢,因為很簡單&快速。
下面我們來實現滑動時的動畫。
滑動時動畫
我們需要重寫 Callback 的 onChildDraw() 方法,這個方法參數較多:
* @param c The canvas which RecyclerView is drawing its children
* @param recyclerView The RecyclerView to which ItemTouchHelper is attached to * @param viewHolder The ViewHolder which is being interacted by the User or it was interacted and simply animating to its original position * @param dX The amount of horizontal displacement caused by user's action * @param dY The amount of vertical displacement caused by user's action * @param actionState 是拖拽還是滑動事件 The type of interaction on the View. Is either {@link #ACTION_STATE_DRAG} or {@link #ACTION_STATE_SWIPE}. * @param isCurrentlyActive 事件是用戶產生還是動畫產生的 True if this view is currently being controlled by the user or false it is simply animating back to its original state.</code></pre>
對我們比較有用的有 dX dX ,可以 判斷滑動方向,以及計算滑動的比例,從而控制縮放、位移動畫的程度 。
本文如下編寫,對View的縮放、位移,其實是對LayoutManager里的操作的 逆操作 ,值得注意的是最后一層,即 top-3View 在Y軸上是保持不變的:
@Override public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive); //先根據滑動的dxdy 算出現在動畫的比例系數fraction double swipValue = Math.sqrt(dX * dX + dY * dY); double fraction = swipValue / getThreshold(viewHolder); //邊界修正 最大為1 if (fraction > 1) { fraction = 1; } //對每個ChildView進行縮放 位移 int childCount = recyclerView.getChildCount(); for (int i = 0; i < childCount; i++) { View child = recyclerView.getChildAt(i); //第幾層,舉例子,count =7, 最后一個TopView(6)是第0層, int level = childCount - i - 1; if (level > 0) { child.setScaleX((float) (1 - SCALE_GAP * level + fraction * SCALE_GAP)); if (level < MAX_SHOW_COUNT - 1) { child.setScaleY((float) (1 - SCALE_GAP * level + fraction * SCALE_GAP)); child.setTranslationY((float) (TRANS_Y_GAP * level - fraction * TRANS_Y_GAP)); } } } }
getThreshold(viewHolder) 函數,返回 是否可以被回收掉的閾值 ,關于它為什么這么寫,我是從源碼里找到的,本末會講解:
//水平方向是否可以被回收掉的閾值 public float getThreshold(RecyclerView.ViewHolder viewHolder) { return mRv.getWidth() * getSwipeThreshold(viewHolder); }
探探效果的實現
一開始文章擼到這里應該結束了,群里出來一個馬小跳,告訴我探探和這略有不同,希望我一并實現。
嗯,好吧。表示沒聽說過探探,那我先去下載一個看看吧。
loading-install-open........
哎喲呵,十分鐘過去了,我還在滑動看美女 忘記了要干什么,被女票看到胖揍了我一頓。
好的,我捂著臉繼續分析。
探探和人人影視有 兩點不同 :
- Roate的變化:左右滑動時,頂層卡片會慢慢 旋轉 ,到閾值max大概十五度。
- Alpha的變化:左滑時頂層卡片的 刪除按鈕會慢慢顯現 ,右滑時 愛心按鈕會慢慢顯現 。
感覺也是炒雞簡單,來吧。五分鐘擼完吃外賣。修改點:
- 在layout布局添加『 X 』&『 愛心 』。
- 在 onChildDraw() 里,按比例修改TopView的Rotate & Alpha
監聽方向
還有一點小不同,上滑下滑不再能刪除,所以我們構造時只傳入左右即可:
ItemTouchHelper.Callback callback = new ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT)
布局添加兩個按鈕
略
onChildDraw()
在上文人人影視的基礎上擴展,上文的效果,對 TopView 是不做任何操作的。這里只需要再對 TopView 做額外操作即可:
@Override public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) { ... for (int i = 0; i < childCount; i++) { View child = recyclerView.getChildAt(i); //第幾層,舉例子,count =7, 最后一個TopView(6)是第0層, int level = childCount - i - 1; if (level > 0) { ... } else { //探探只是第一層加了rotate & alpha的操作 //不過他區分左右 float xFraction = dX / getThreshold(viewHolder); //邊界修正 最大為1 if (xFraction > 1) { xFraction = 1; } else if (xFraction < -1) { xFraction = -1; } //rotate child.setRotation(xFraction * MAX_ROTATION); //自己感受一下吧 Alpha if (viewHolder instanceof ViewHolder) { ViewHolder holder = (ViewHolder) viewHolder; if (dX > 0) { //露出左邊,比心 holder.setAlpha(R.id.iv_love, xFraction); } else { //露出右邊,滾犢子 holder.setAlpha(R.id.iv_del, -xFraction); } } } } }
實現完后,我以為結束了,結果比我們想象的還要復雜一丟丟。因為此時刪除后, notifyDataSetChanged() 刷新界面,而 TopView 還是傾斜的,愛心、刪除圖標也是出現的。這顯然與預期不符。所以我們需要在 onSwiped() 里將其復位:
@Override public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) { ... //探探只是第一層加了rotate & alpha的操作 //對rotate進行復位 viewHolder.itemView.setRotation(0); //自己感受一下吧 Alpha if (viewHolder instanceof ViewHolder) { ViewHolder holder = (ViewHolder) viewHolder; holder.setAlpha(R.id.iv_love, 0); holder.setAlpha(R.id.iv_del, 0); } }
Ok,大功告成。效果和文首一樣,盡情去跟產品UI嘚瑟吧。
閾值的尋找之路
閾值的尋找,花費了我一些時間,因為我想做到 和系統的行為保持一致 。
即,當刪除、喜歡圖標全顯,當 Top-1View 顯示完畢時,松手 TopView 會回收。
這就決定了我們的縮放、位移的閾值不能隨便定,所以我們必須 去源代碼里找答案 。
//水平方向是否可以被回收掉的閾值 public float getThreshold(RecyclerView.ViewHolder viewHolder) { return mRv.getWidth() * getSwipeThreshold(viewHolder); }
因為滑動刪除操作是touch事件導致的,且應該是ACTION_UP時,觸發的,
所以在 ItemTouchHelper 源碼里,搜索onTouch字樣:
定位到: mOnItemTouchListener ,->
繼續定位其中的 onTouchEvent() ,->
case MotionEvent.ACTION_UP: ,->
void select(ViewHolder selected, int actionState) ->
在這里我注意到有一句代碼: animationType = ANIMATION_TYPE_SWIPE_SUCCESS;
這說明刪除成功,它的觸發條件是: if (swipeDir > 0) ->
swipeDir 的值: final int swipeDir = prevActionState == ACTION_STATE_DRAG ? 0 : swipeIfNecessary(prevSelected); ->
int swipeIfNecessary(ViewHolder viewHolder) ->
if ((swipeDir = checkHorizontalSwipe(viewHolder, flags)) > 0) { return swipeDir; }
如此返回1的話,則-> checkHorizontalSwipe(viewHolder, flags) ->
在其中終于找到源碼里閾值的獲取之處:
final float threshold = mRecyclerView.getWidth() * mCallback .getSwipeThreshold(viewHolder);
于是我就直接復制出來。
總結
本文利用 LayoutManager 加載顯示屏幕上 可見的數量的View ,搭配 ItemTouchHelper 處理拖拽&滑動刪除邏輯,核心代碼不超過50行。且經過封裝,四行代碼就可以用。
記住 LayoutManager ,我們寫,只layout出界面上可能會看見的那些View即可。
關于 ItemTouchHelper ,它本身實現了拖拽&滑動刪除邏輯,我們只需要在 onChildDraw() 中繪制動畫和 onSwiped() 中處理數據集(循環or刪除)即可。
以后老板讓你做這種效果,你只需要:
mRv.setLayoutManager(new OverLayCardLayoutManager()); CardConfig.initConfig(this); ItemTouchHelper.Callback callback = new RenRenCallback(mRv, mAdapter, mDatas); ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback); itemTouchHelper.attachToRecyclerView(mRv);
如果需要定制特殊的參數,例如顯示6層:
CardConfig.MAX_SHOW_COUNT = 6;
-