前言
在Android发展的进程中,网格布局一直比较有热度,其中一个原因是对用户来说便捷操作,对app厂商而言也会带来很多的曝光量,对于很多头部app,展示网格菜单几乎是必选项。实现网格的方式有很多种,比如GridView、GridLayout,TableLayout等,实际上,由于RecyclerView的灵活性和可扩展性很高,这些View基本没必要去学了,为什么这样说呢?主要原因是基于RecyclerView可以实现很多布局效果,传统的很多Layout都可以通过RecyclerView去实现,比如ViewPager、SlideTabLayout、DrawerLayout、ListView等,甚至连九宫格解锁效果也可以实现。
在很早之前,实现网格的拖拽效果主要是通过GridView去实现的,如果列数为1的话,那么GridView基本上就实现了ListView一样的上下拖拽。
话说回来,我们现在基本不用去学习这类实现了,因为RecyclerView足够强大,通过简单的数据组装,是完全可以替代GridView和ListView的。
效果
本篇我们会使用RecyclerView来实现网格拖拽,本篇将结合图片分片案例,实现拖拽效果:
如果要实现网格菜单的拖拽,也是可以使用这种方式的,只要你的想象丰富,理论上我们借助RecyclerView其实可以做出很多效果:
拖拽效果原理
拖动其实需要处理3个核心的问题,事件处理、图像平移、数据交换。
事件处理
实际上无论传统的拖拽效果还是最新的拖拽效果,都离不开事件处理,不过,好处就是,google为RecyclerView提供了ItemTouchHelper来处理这个问题,相比传统的GridView实现方式,省去了很多事情,如动画、目标查找等。
不过,我们回顾下原理,其实他们很多方面都是相似的,不同之处就是ItemTouchHelper设计的非常好用,而且接口暴露的非常彻底,甚至能决定哪些View可以拖动、哪些view不能拖动,哪些View可以删除,以及什么方向可以拖动,如果我们上、下、左、右四个方向都选中的话,斜对角拖动完全没问题, 事件处理这里,GridView使用的方式相对传统,而ItemTouchHelper借助RecyclerView的一个接口(看样子是开的后门),通过View自身去拦截事件。
public interface OnItemTouchListener {
//是否让RecyclerView拦截事件
boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
//拦截之后处理RecyclerView的事件
void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e);
//监听禁止拦截事件的请求结果
void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept);
}
这种其实相对GridView来说简单的多。
图像平移
无论是RecyclerView和传统GridView拖动,都需要图像平移。我们知道,RecyclerView和GridView本身是通过子View的边界(left\top\right\bottom)来移动的,那么,在平移图像的时候必然不能选择这种方式,只能选择Matrix 变换,也就是图像移动,而View本身不移动。通俗一点也就是修改transitionX和transitionY,或者修改x,y。
相比RecyclerView,不同的是,GridView的子View本身并不移动,而是将图像绘制到一个GridView之外的View上,相当于灵魂附体到外面View上,实现上是比较复杂。
对于RecyclerView来说,ItemTouchHelper设计比较巧妙的一点是,通过RecyclerView#ItemDecoration来实现,在捕获可以滑动的View之后,在绘制时对View进行偏移。
class ItemTouchUIUtilImpl implements ItemTouchUIUtil {
static final ItemTouchUIUtil INSTANCE = new ItemTouchUIUtilImpl();
@Override
public void onDraw(Canvas c, RecyclerView recyclerView, View view, float dX, float dY,
int actionState, boolean isCurrentlyActive) {
if (Build.VERSION.SDK_INT >= 21) {
if (isCurrentlyActive) {
Object originalElevation = view.getTag(R.id.item_touch_helper_previous_elevation);
if (originalElevation == null) {
originalElevation = ViewCompat.getElevation(view);
float newElevation = 1f + findMaxElevation(recyclerView, view);
ViewCompat.setElevation(view, newElevation);
view.setTag(R.id.item_touch_helper_previous_elevation, originalElevation);
}
}
}
view.setTranslationX(dX);
view.setTranslationY(dY);
}
//省略一些有关或者无关的代码
}
不过,我们看到,Android 5.0的版本借助了setElevation 使得被拖拽View不被其他顺序的View遮住,那Android 5.0之前是怎么实现的呢?
其实,做过TV app的都比较清楚,子View绘制顺序可以通过下面方式调整,借助下面的方法,在TV上某个View获取焦点之后,就不会被后面的View盖住。
View#getChildDrawingOrder(...)
此方法实际上是改变了View的绘制顺序,原理是通过下面方式,将View的索引和绘制顺序进行了映射,比如原来的第一个View模式是第1个被绘制的子View,但可以变更成最后一个绘制的View。
原理:让第1个位置绘制第index的view,伪代码如下:
void drawChildFunction(drawIndex,canvas){
children[mapChildIndex(drawIndex)].draw(canvas);
}
具体实现方法参考如下:
ArrayList<View> buildOrderedChildList() {
final int childrenCount = mChildrenCount;
if (childrenCount <= 1 || !hasChildWithZ()) return null;
if (mPreSortedChildren == null) {
mPreSortedChildren = new ArrayList<>(childrenCount);
} else {
// callers should clear, so clear shouldn't be necessary, but for safety...
mPreSortedChildren.clear();
mPreSortedChildren.ensureCapacity(childrenCount);
}
final boolean customOrder = isChildrenDrawingOrderEnabled();
for (int i = 0; i < childrenCount; i++) {
// add next child (in child order) to end of list
final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
// 映射View
final View nextChild = mChildren[childIndex];
final float currentZ = nextChild.getZ();
// 如果Z值大的话往后移动,5.0之前的代码没有这段
int insertIndex = i;
while (insertIndex > 0 && mPreSortedChildren.get(insertIndex - 1).getZ() > currentZ) {
insertIndex--;
}
mPreSortedChildren.add(insertIndex, nextChild);
}
return mPreSortedChildren;
}
ItemTouchHelper 同样借助了此方法,在我们测试后发现,其实Android 4.4之前的版本没有明显的效果差异,但是这里依然好奇,为什么不统一使用一种方式呢?
没有找到明确的答案,但是从代码效率来说,显然setElevation性能更好一些,同时也释放了对绘制顺序的功能的占用。
private void addChildDrawingOrderCallback() {
if (Build.VERSION.SDK_INT >= 21) {
return; // we use elevation on Lollipop
}
if (mChildDrawingOrderCallback == null) {
mChildDrawingOrderCallback = new RecyclerView.ChildDrawingOrderCallback() {
@Override
public int onGetChildDrawingOrder(int childCount, int i) {
if (mOverdrawChild == null) {
return i;
}
int childPosition = mOverdrawChildPosition;
if (childPosition == -1) {
childPosition = mRecyclerView.indexOfChild(mOverdrawChild);
mOverdrawChildPosition = childPosition;
}
if (i == childCount - 1) {
//将最后索引位置展示被拖拽的View
return childPosition;
}
//后面的View 绘制顺序往前移动
return i < childPosition ? i : i + 1;
}
};
}
mRecyclerView.setChildDrawingOrderCallback(mChildDrawingOrderCallback);
}
这里为什么要讲解之前的版本怎么做的呢?主要原因是,目前除了手机设备以外,有相当一部分设备是Android 4.4 的,而且事件传递过程中需要了解这方面的思想。
数据更新
这里其实ReyclerView的优势更加明显,我们知道RecyclerView可以做到无requestLayout的局部刷新,性能更好。
@Override
public boolean onItemMove(int fromPosition, int toPosition) {
Collections.swap(mDataList, fromPosition, toPosition);
notifyItemMoved(fromPosition, toPosition);
return true;
}
不过,数据交换后还有一点需要处理,对Matrix相关属性清理,防止无法落到指定区域。
@Override
public void clearView(View view) {
if (Build.VERSION.SDK_INT >= 21) {
final Object tag = view.getTag(R.id.item_touch_helper_previous_elevation);
if (tag instanceof Float) {
ViewCompat.setElevation(view, (Float) tag);
}
view.setTag(R.id.item_touch_helper_previous_elevation, null);
}
view.setTranslationX(0f);
view.setTranslationY(0f);
}
本篇实现
以上基本都是对ItemTouchHelper的原理梳理了,当然,如果你没时间看上面的话,就看实现部分吧。
图片分片
下面我们把多张图片分割成 [行数 x 列数]数量的图片。
Bitmap srcInputBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.image_4);
Bitmap source = Bitmap.createScaledBitmap(srcInputBitmap, width, height, true);
srcInputBitmap.recycle();
int colCount = spanCount;
int rowCount = 6;
int spanImageWidthSize = source.getWidth() / colCount;
int spanImageHeightSize = (source.getHeight() - rowCount * padding/2) / rowCount;
Bitmap[] bitmaps = new Bitmap[rowCount * colCount];
for (int i = 0; i < rowCount; i++) {
for (int j = 0; j < colCount; j++) {
int y = i * spanImageHeightSize;
int x = j * spanImageWidthSize;
Bitmap bitmap = Bitmap.createBitmap(source, x, y, spanImageWidthSize, spanImageHeightSize);
bitmaps[i * colCount + j] = bitmap;
}
}
在这种过程我们一定要处理一个问题,如果我们对网格设置了边界线(ItemDecoration)且是纵向布局的话,那么RecyclerView天然都不会横向滑动,但是纵向就不一样了,纵向总高度要减去rowCount * bottomPadding,这里bottomPadding== padding/2,如下面代码。
为什么要这么做呢?因为RecyclerView计算高度的时候,需要考虑这个高度,如果不去处理,那么ReyclerView可能会滑动,虽然影响不大,但是如果实现全屏效果,拖动View时RecyclerView还能上下滑的话体验比较差。
public class SimpleItemDecoration extends RecyclerView.ItemDecoration {
public int delta;
public SimpleItemDecoration(int padding) {
delta = padding;
}
@Override
public void getItemOffsets(Rect outRect, View view,
RecyclerView parent, RecyclerView.State state) {
int position = parent.getChildAdapterPosition(view);
RecyclerView.Adapter adapter = parent.getAdapter();
int viewType = adapter.getItemViewType(position);
if(viewType== Bean.TYPE_GROUP){
return;
}
GridLayoutManager layoutManager = (GridLayoutManager) parent.getLayoutManager();
//列数量
int cols = layoutManager.getSpanCount();
//position转为在第几列
int current = layoutManager.getSpanSizeLookup().getSpanIndex(position,cols);
//可有可无
int currentCol = current % cols;
int bottomPadding = delta / 2;
if (currentCol == 0) { //第0列左侧贴边
outRect.left = 0;
outRect.right = delta / 4;
outRect.bottom = bottomPadding;
} else if (currentCol == cols - 1) {
outRect.left = delta / 4;
outRect.right = 0;
outRect.bottom = bottomPadding;
//最后一列右侧贴边
} else {
outRect.left = delta / 4;
outRect.right = delta / 4;
outRect.bottom = bottomPadding;
}
}
}
更新数据
这部分是常规操作,主要目的是设置LayoutManager、Decoration、Adapter以及ItemTouchHelper,当然,ItemTouchHelper比较特殊,因为其内部是ItemDecoration、OnItemTouchListener、Gesture的组合,因此封装为attachToRecyclerView 来调用。
mLinearLayoutManager = new GridLayoutManager(this, spanCount, LinearLayoutManager.VERTICAL, false);
mLinearLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup(){
@Override
public int getSpanSize(int position) {
if(mAdapter.getItemViewType(position) == Bean.TYPE_GROUP){
return spanCount;
}
return 1;
}
});
mAdapter = new RecyclerViewAdapter();
mRecyclerView.setAdapter(mAdapter);
mRecyclerView.setLayoutManager(mLinearLayoutManager);
mRecyclerView.addItemDecoration(new SimpleItemDecoration(padding));
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new GridItemTouchCallback(mAdapter));
itemTouchHelper.attachToRecyclerView(mRecyclerView);
这里,我们主要还是关注ItemTouchHelper,在初始化的时候,我们给了一个GridItemTouchCallback,用于监听相关处理逻辑,最终通知Adapter调用notifyXXX更新View。
public class GridItemTouchCallback extends ItemTouchHelper.Callback {
private final ItemTouchCallback mItemTouchCallback;
public GridItemTouchCallback(ItemTouchCallback itemTouchCallback) {
mItemTouchCallback = itemTouchCallback;
}
@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
if(viewHolder.getItemViewType() == Bean.TYPE_GROUP){
return 0; //设置此类型的View不可拖动
}
// 上下左右拖动
int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
return makeMovementFlags(dragFlags, 0);
}
@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
// 通知Adapter移动View
return mItemTouchCallback.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition());
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
// 通知Adapter删除View
mItemTouchCallback.onItemRemove(viewHolder.getAdapterPosition());
}
@Override
public void onChildDraw(@NonNull Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
@Override
public void onChildDrawOver(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
Log.d("GridItemTouch","dx="+dX+", dy="+dY);
super.onChildDrawOver(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
}
}
这里,主要是对Flag的关注需要处理,第一参数是拖拽方向,第二个是删除方向,我们本篇不删除,因此,第二个参数为0即可。
public static int makeMovementFlags(int dragFlags, int swipeFlags) {
return makeFlag(ACTION_STATE_IDLE, swipeFlags | dragFlags)
| makeFlag(ACTION_STATE_SWIPE, swipeFlags)
| makeFlag(ACTION_STATE_DRAG, dragFlags);
}
当然,删除和拖拽都不要的viewHolder,那么直接返回0。
用法
评论区有同学想要源码,很多代码都已经在文章里了,这里我贴一下Activity的源码应该就可以了。xml代码就是recyclerView,就不贴出来了。
UI初始化逻辑
下面是Activity中的相关逻辑
public class GridDragActivity extends Activity {
RecyclerView mRecyclerView;
RecyclerViewAdapter mAdapter;
private LinearLayoutManager mLinearLayoutManager = null;
HandlerThread handlerThread = new HandlerThread("GridDragActivity");
private final int spanCount = 5;
private final int padding = 10;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.drag_activity);
mRecyclerView = findViewById(R.id.recyclerView);
mLinearLayoutManager = new GridLayoutManager(this, spanCount, LinearLayoutManager.VERTICAL, false);
mAdapter = new RecyclerViewAdapter();
mRecyclerView.setAdapter(mAdapter);
mRecyclerView.setLayoutManager(mLinearLayoutManager);
mRecyclerView.addItemDecoration(new SimpleItemDecoration(padding));
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new GridItemTouchCallback(mAdapter));
itemTouchHelper.attachToRecyclerView(mRecyclerView);
handlerThread.start();
mRecyclerView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
mRecyclerView.getViewTreeObserver().removeOnPreDrawListener(this);
loadData();
return false;
}
});
}
@Override
protected void onDestroy() {
handlerThread.quitSafely();
super.onDestroy();
}
private void loadData() {
Handler handler = new Handler(handlerThread.getLooper());
handler.post(new Runnable() {
@Override
public void run() {
int width = mRecyclerView.getWidth();
int height = mRecyclerView.getHeight();
Bitmap srcInputBitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.image_4);
Bitmap source = Bitmap.createScaledBitmap(srcInputBitmap, width, height, true);
srcInputBitmap.recycle();
int colCount = spanCount;
int rowCount = 6;
int spanImageWidthSize = source.getWidth() / colCount;
int spanImageHeightSize = (source.getHeight() - rowCount * padding / 2) / rowCount;
Bitmap[] bitmaps = new Bitmap[rowCount * colCount];
for (int i = 0; i < rowCount; i++) {
for (int j = 0; j < colCount; j++) {
int y = i * spanImageHeightSize;
int x = j * spanImageWidthSize;
Bitmap bitmap = Bitmap.createBitmap(source, x, y, spanImageWidthSize, spanImageHeightSize);
bitmaps[i * colCount + j] = bitmap;
}
}
List<Bean> beans = generateDataList(rowCount * colCount, bitmaps);
runOnUiThread(new Runnable() {
@Override
public void run() {
mAdapter.setDataList(beans);
}
});
}
});
}
private List<Bean> generateDataList(int count, Bitmap[] bitmaps) {
List<Bean> dataList = new ArrayList<>();
for (int i = 0; i < count; i++) {
Bean bean = new Bean();
bean.setString("" + i);
bean.setBitmap(bitmaps[i]);
bean.setColor(Color.parseColor(ColorUtils.generateRandomColor()));
dataList.add(bean);
}
return dataList;
}
}
Adapter 源码
这里要注意的是通过RecyclerViewAdapter继承ItemTouchCallback ,实现onItemMoov和onItemRemove方法。
另外,数据变化后调用notifyItemMoved和notifyItemRemoved,从而触发RecyclerView的局部刷新。
public class RecyclerViewAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> implements ItemTouchCallback {
private List<Bean> mDataList;
public RecyclerViewAdapter() {
mDataList = new ArrayList<>();
}
public void setDataList(List<Bean> dataList) {
this.mDataList.clear();
if(dataList == null || dataList.isEmpty()){
notifyDataSetChanged();
return;
}
this.mDataList.addAll(dataList);
int max = Math.max(this.mDataList.size(), dataList.size());
notifyItemRangeChanged(0,max);
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if(viewType == Bean.TYPE_CHILD) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_recyclerview, parent, false);
return new RecyclerViewHolder(view);
}
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_group_recyclerview, parent, false);
return new GroupRecyclerViewHolder(view);
}
@Override
public int getItemViewType(int position) {
return mDataList.get(position).itemType;
}
@Override
public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder bindHolder, final int position) {
if(bindHolder instanceof RecyclerViewHolder) {
RecyclerViewHolder holder = (RecyclerViewHolder) bindHolder;
holder.mTextView.setText(mDataList.get(position).getString());
holder.mBitmapView.setImageBitmap(mDataList.get(position).getBitmap());
holder.itemView.setBackgroundColor(mDataList.get(position).getColor());
}
if(bindHolder instanceof GroupRecyclerViewHolder) {
GroupRecyclerViewHolder holder = (GroupRecyclerViewHolder) bindHolder;
holder.mTextView.setText(mDataList.get(position).getString());
holder.itemView.setBackgroundColor(mDataList.get(position).getColor());
}
}
@Override
public int getItemCount() {
return mDataList.size();
}
@Override
public boolean onItemMove(int fromPosition, int toPosition) {
//为了保证UI和数据一致,我们要修复数据,当然,本篇的Recycler列表是不可以滑动的,问题不明显
mDataList.add(toPosition,mDataList.remove(fromPosition));
notifyItemMoved(fromPosition, toPosition);
return true;
}
@Override
public boolean onItemRemove(int position) {
mDataList.remove(position); // 移除数据
notifyItemRemoved(position);
return true;
}
public class RecyclerViewHolder extends RecyclerView.ViewHolder {
ImageView mBitmapView;
TextView mTextView;
public RecyclerViewHolder(View itemView) {
super(itemView);
mTextView = itemView.findViewById(R.id.textView);
mBitmapView = itemView.findViewById(R.id.bitmap);
}
}
public class GroupRecyclerViewHolder extends RecyclerView.ViewHolder {
TextView mTextView;
public GroupRecyclerViewHolder(View itemView) {
super(itemView);
mTextView = itemView.findViewById(R.id.textView);
}
}
}
xml 布局源码
这里要注意的是overScrollMode需要设置为never,因为在一些情况下,RecyclerView自带阻尼(或者弹簧)效果,因此这里为了拖动效果好看一些,需要禁止这种效果。
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
android:background="@android:color/black"
/>
总结
本篇到这里就结束了,我们利用RecyclerView实现了宫格图片的拖拽效果,主要是借助ItemTouchHelper实现,从ItemTouchHelper中我们能看到很多巧妙的的设计,里面有很多值得我们学习的技巧,特别是对事件的处理、绘制顺序调整的方式,如果做吸顶,未尝不是一种方案。
另外,在本篇,我们还需要注意的是数据和UI一致性问题,因为在移动的过程中,数据的索引位置可能和UI产生差异需要主动优化,以及notifyItemMoved和notifyItemRemoved的用法。当然,在Item滑动的过程还有一些筛选机制,后续有时间的话我们再分析。
作者:时光少年
链接:https://juejin.cn/post/7348707728921853971
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。