文章目錄
前言
RecyclerView系列總結:
《AndroidX RecyclerView總結-測量佈局》
《AndroidX RecyclerView總結-Recycler》
《AndroidX RecyclerView總結-滑動處理》
《AndroidX RecyclerView總結-ItemTouchHelper》
RecyclerView除了可以展示線性、網格、瀑布流等常規列表佈局,還支持自定義個性化的佈局。這裏實現卡片式滑動佈局,效果如圖:
功能拆解
最終實現效果是一個層疊卡片式佈局,支持滑動拖拽移除,並且將移除的item再添加回數據集以便循環演示。點擊對應按鈕觸發對應方向的自動滑出動畫。當往左滑出的時候彈出"不喜歡"吐司,往右邊滑出彈出"喜歡"吐司。
- 首先實現卡片佈局,很容易想到可以通過自定義LayoutManager來完成,重寫onLayoutChildren中實現個性化佈局邏輯。
- 手勢滑動移除,可以藉助ItemTouchHelper實現。
- 卡片飛出的動畫效果,通過自定義SimpleItemAnimator,設置ItemAnimator。
代碼實現
準備工作
添加依賴
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'com.google.android.material:material:1.1.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.appcompat:appcompat:1.1.0'
MOCK數據
使用本地圖片資源。
List<CardBean> data = new ArrayList<>();
data.add(new CardBean(R.mipmap.tu15));
data.add(new CardBean(R.mipmap.tu16));
data.add(new CardBean(R.mipmap.tu17));
data.add(new CardBean(R.mipmap.xiaotu_50));
data.add(new CardBean(R.mipmap.xiaotu_51));
data.add(new CardBean(R.mipmap.xiaotu_122));
data.add(new CardBean(R.mipmap.xiaotu_131));
data.add(new CardBean(R.mipmap.xiaotu_134));
CardRecycleAdapter adapter = new CardRecycleAdapter(this, data);
cardLayout.setAdapter(adapter);
public class CardBean {
// 圖片資源ID
public int cover;
public CardBean(int cover) {
this.cover = cover;
}
}
創建適配器
public class CardRecycleAdapter extends BaseRecyclerAdapter<CardBean> {
public CardRecycleAdapter(Context context, List<CardBean> data) {
super(context, data);
putItemLayoutId(VIEW_TYPE_DEFAULT, R.layout.item_card);
}
@Override
public void onBind(final ViewHolder holder, final CardBean item, final int position) {
// 設置圖片
holder.setImageResource(R.id.ivCover, item.cover);
holder.getView(R.id.btnDelete).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// ···
}
});
holder.getView(R.id.btnLike).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
// ···
}
});
}
}
卡片佈局實現
思路分析
仔細觀察最終效果圖,注意到每層Item View的重疊排列。從頂層往下,View會逐漸往下偏移露出底部一部分視圖,並且會適當縮小一定比例,從視覺上看起來位於後方。當數據過多時,不會全部擺放到RecyclerView中,限制最多展示View個數。
初始配置
定義一個配置類:
public static class CardConfig {
public static int CARD_SHOW_COUNT; //最多同時顯示個數
public static float SCALE_GAP; //縮放比例
public static int TRANS_Y_GAP; //偏移量
public static void init(Context context) {
CARD_SHOW_COUNT = 4;
SCALE_GAP = 0.05f;
TRANS_Y_GAP = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 15, context.getResources().getDisplayMetrics());
}
}
- CARD_SHOW_COUNT:就是在RecyclerView中擺放View的數量上限
- SCALE_GAP:每層View間的縮放比例
- TRANS_Y_GAP:每層View間的垂直偏移量
自定義LayoutManager
關鍵步驟,繼承LayoutManager,實現onLayoutChildren方法:
public class CardSwipeLayoutManager 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) {
super.onLayoutChildren(recycler, state);
//解除所有子view,添加到scrap集合緩存
detachAndScrapAttachedViews(recycler);
// 取數據個數和CARD_SHOW_COUNT的較小值
int count = Math.min(getItemCount(), CardConfig.CARD_SHOW_COUNT);
if(count < 1) {
return;
}
//遍歷前count個itemView加載顯示
for (int i=0; i<count; i++) {
// 獲取緩存的View
View child = recycler.getViewForPosition(i);
//添加至頭部,顯示在底層
addView(child, 0);
//測量child的大小
measureChildWithMargins(child, 0, 0);
//獲取child外邊距=(recyclerview的寬度-child包含了decorate間距的總寬度) / 2
int widthSpace = (getWidth()-getDecoratedMeasuredWidth(child)) / 2;
int heightSpace = (getHeight()-getDecoratedMeasuredHeight(child)) / 2;
//擺放child的位置(居中擺放)
layoutDecorated(child, widthSpace, heightSpace,
widthSpace+getDecoratedMeasuredWidth(child),
heightSpace+getDecoratedMeasuredHeight(child));
//設置Y軸偏移和長寬縮放,層疊錯開顯示
int fraction = i;
if(fraction == count-1) {
//最後一個和倒數第二個的fraction一致
fraction = count - 2;
}
// 設置View的Y軸偏移和縮放
child.setTranslationY(CardConfig.TRANS_Y_GAP * fraction);
child.setScaleX(1 - CardConfig.SCALE_GAP*fraction);
child.setScaleY(1 - CardConfig.SCALE_GAP*fraction);
}
}
}
核心步驟就是依次取child,先測量child,再將child居中擺放,最後計算它的偏移和縮放。
其中注意幾個細節,在佈局前首先調用detachAndScrapAttachedViews將mChildren數組中View移除,並將View交由Recycler進行緩存。之後再依次從Recycler獲取View,按FILO順序加入mChildren數組,即後添加的View插入數組頭部,使先遍歷的View能顯示在上層。
最後一個View和倒數第二個View的偏移和縮放比例是一致的,即最後一個剛好被倒二個完整覆蓋,這樣是爲了在拖拽時視覺效果更連貫。
完整代碼見CardSwipeLayoutManager.java
手勢滑動移除實現
思路分析
通過ItemTouchHelper綁定RecyclerView,可以託管RecyclerView的手勢事件,創建ItemTouchHelper.Callback可以處理自定義滑動、拖拽相關業務邏輯。
移除View邏輯可以在ItemTouchHelper.Callback#onSwiped中處理,當滑動達到臨界值時會觸發onSwiped回調。
當拖拽時,底層的View也會相應的偏移和縮放,以填充上層View的位置。可以在ItemTouchHelper.Callback#onChildDraw中處理相應邏輯。
自定義ItemTouchHelper.SimpleCallback
- 首先設置滑動方向
public class CardSwipeCallback extends ItemTouchHelper.SimpleCallback {
private Context context;
private CardRecycleAdapter adapter;
public CardSwipeCallback(Context context, CardRecycleAdapter adapter) {
// 設置支持的手勢類型和方向
super(0, ItemTouchHelper.LEFT|ItemTouchHelper.UP|ItemTouchHelper.RIGHT|ItemTouchHelper.DOWN);
this.context = context;
this.adapter = adapter;
}
@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
// 不進行item交換操作
return false;
}
// ···
}
在構造函數中,設置了不支持Drag拖拽類型操作,Swipe滑動操作支持上下左右四個方向。
- 處理移出操作
public class CardSwipeCallback extends ItemTouchHelper.SimpleCallback {
// ···
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
// 判斷方向
if(direction==ItemTouchHelper.LEFT) {
Toast.makeText(context, "不喜歡", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(context, "喜歡", Toast.LENGTH_SHORT).show();
}
// 移除滑出的item並添加到尾部
CardBean item = adapter.getmData().remove(viewHolder.getLayoutPosition());
adapter.getmData().add(item);
adapter.notifyDataSetChanged();
}
// ···
}
在onSwiped中,首先判斷移出方向,彈對應toast。接着從適配器數據集中移除對應item,再重新添加到數據集尾部,實現循環效果。
- 處理下層View動畫
public class CardSwipeCallback extends ItemTouchHelper.SimpleCallback {
// ···
@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);
// 設置滑動臨界值,用於計算偏移和縮放差值,避免無限偏移縮放
double maxDistance = recyclerView.getWidth() / 2;
// 當前滑動距離(勾股定理)
double distance = Math.sqrt(dX*dX + dX*dX);
// 計算偏移縮放比例
double ratio = distance / maxDistance;
if(ratio > 1) {
// 限制不超過1
ratio = 1;
}
// 獲取當前recyclerview顯示item的個數,遍歷計算偏移和縮放
int count = recyclerView.getChildCount();
for (int i=0; i<count; i++) {
View child = recyclerView.getChildAt(i);
// 越前面越底層
int level = count - i - 1;
// 判斷非最底層的view才需要改變
if(level != count-1) {
// 以原來的位置加上偏移量和縮放比
child.setTranslationY((float) (CardConfig.TRANS_Y_GAP * (level-ratio)));
child.setScaleX((float) (1 - CardConfig.SCALE_GAP * level + CardConfig.SCALE_GAP*ratio));
child.setScaleY((float) (1 - CardConfig.SCALE_GAP * level + CardConfig.SCALE_GAP*ratio));
}
}
}
}
在onChildDraw方法中,首先利用滑動距離計算一個差值比例,在遍歷child,依次計算child的偏移和縮放,達到上下移動和放大縮小的效果。
關於計算偏移和縮放比例說明:
在前面自定義LayoutManager#onLayoutChildren中,以原child索引值作爲fraction計算偏移和縮放,偏移量和縮放比例依次遞增,之後child都是插入mChildren數組頭部。因此在此處自定義SimpleCallback#onChildDraw中遍歷child時,先取到的child是最底層的,這裏通過倒序求出原fraction,計算原偏移和縮放,再加上滑動差值比例計算的偏移和縮放,進行在原基礎上的位移和放大縮小。
點擊按鈕卡片飛出動畫實現
思路分析
給item佈局中的對應按鈕增加點擊事件監聽,移除對應數據並觸發適配器更新。動畫效果通過自定義ItemAnimator(這裏只需要自定義Remove動畫),之後通過RecyclerView#setItemAnimator應用ItemAnimator。
按鈕點擊監聽
public class CardRecycleAdapter extends BaseRecyclerAdapter<CardBean> {
// ···
@Override
public void onBind(final ViewHolder holder, final CardBean item, final int position) {
holder.setImageResource(R.id.ivCover, item.cover);
holder.getView(R.id.btnDelete).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(mContext, "不喜歡", Toast.LENGTH_SHORT).show();
// 給itemView設置tag,標記當前觸發往左邊滑出的動畫
holder.itemView.setTag(SwipeItemAnimator.SWIPE_REMOVE_LEFT);
// 移除數據,並觸發notifyItemRemoved
remove(item);
// 將數據添加回數據集,以便循環演示
mData.add(item);
}
});
holder.getView(R.id.btnLike).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(mContext, "喜歡", Toast.LENGTH_SHORT).show();
// 給itemView設置tag,標記當前觸發往右邊滑出的動畫
holder.itemView.setTag(SwipeItemAnimator.SWIPE_REMOVE_RIGHT);
remove(item);
mData.add(item);
}
});
}
}
這裏給兩個按鈕設置了點擊監聽,其中分別會給itemView設置tag來標記不同方向的滑出。
完整代碼見CardRecycleAdapter.java
自定義SimpleItemAnimator
RecyclerView中默認有一個DefaultItemAnimator,實現了Add、Remove、Move、Change操作的動畫。如果不是爲了區分不同方向的移出動畫(點擊"不喜歡"按鈕往左滑出、點擊"喜歡"按鈕往右滑出),使用默認動畫即可。這裏直接拷貝DefaultItemAnimator源碼,僅修改其中Remove動畫的實現。
public class SwipeItemAnimator extends SimpleItemAnimator {
public static final int SWIPE_REMOVE_LEFT = 1; // 標記左滑移除
public static final int SWIPE_REMOVE_RIGHT = 2; // 標記右滑移除
// ···
private void animateRemoveImpl(final RecyclerView.ViewHolder holder) {
final View view = holder.itemView;
final ViewPropertyAnimatorCompat animation = ViewCompat.animate(view);
mRemoveAnimations.add(holder);
//設置偏移量---向左還是向右
float translateX = 0;
// 判斷點擊時給view設置的tag,判斷滑出方向
if((int)view.getTag() == SWIPE_REMOVE_LEFT) {
translateX = -view.getWidth();
} else if((int)view.getTag() == SWIPE_REMOVE_RIGHT) {
translateX = view.getWidth();
}
animation.setDuration(getRemoveDuration())
.translationX(translateX)
.alpha(0).setListener(new VpaListenerAdapter() {
@Override
public void onAnimationStart(View view) {
dispatchRemoveStarting(holder);
}
@Override
public void onAnimationEnd(View view) {
animation.setListener(null);
//動畫結束還原位置
ViewCompat.setTranslationX(view, 0);
ViewCompat.setAlpha(view, 1);
dispatchRemoveFinished(holder);
mRemoveAnimations.remove(holder);
dispatchFinishedWhenDone();
}
}).start();
}
// ···
}
尾聲
至此,完成了開頭效果中的卡片式滑動佈局。
完整代碼見CardSwipeDemo