AndroidX RecyclerView實踐-手寫卡片式佈局

前言

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) {
                // ···
            }
        });
    }
}

完整代碼見CardRecycleAdapter.java

卡片佈局實現

思路分析

圖解

仔細觀察最終效果圖,注意到每層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

  1. 首先設置滑動方向
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滑動操作支持上下左右四個方向

  1. 處理移出操作
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,再重新添加到數據集尾部,實現循環效果。

  1. 處理下層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,計算原偏移和縮放,再加上滑動差值比例計算的偏移和縮放,進行在原基礎上的位移和放大縮小。

完整代碼見CardSwipeCallback.java

點擊按鈕卡片飛出動畫實現

思路分析

給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();
    }
    
    // ···
}

完整代碼見SwipeItemAnimator.java

尾聲

至此,完成了開頭效果中的卡片式滑動佈局。

完整代碼見CardSwipeDemo

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章