RecyclerView系列总结:
《AndroidX RecyclerView总结-测量布局》
《AndroidX RecyclerView总结-Recycler》
《AndroidX RecyclerView总结-滑动处理》
《AndroidX RecyclerView总结-ItemTouchHelper》
RecyclerView除了可以展示线性、网格、瀑布流等常规列表布局,还支持自定义个性化的布局。这里实现卡片式滑动布局,效果如图:
最终实现效果是一个层叠卡片式布局,支持滑动拖拽移除,并且将移除的item再添加回数据集以便循环演示。点击对应按钮触发对应方向的自动滑出动画。当往左滑出的时候弹出"不喜欢"吐司,往右边滑出弹出"喜欢"吐司。
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'
使用本地图片资源。
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());
}
}
关键步骤,继承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中处理相应逻辑。
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,再重新添加到数据集尾部,实现循环效果。
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
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