搜狐技术产品

Android RecyclerView宫格拖拽效果实现

本文字数: 4358 字 预计阅读时间: 20 分钟

作者:时光少年
链接: https://juejin.cn/post/7348707728921853971
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 01 前言
在 Android 发展的进程中,网格布局一直比较有热度,其中一个原因是对用户来说便捷操作,对 app 厂商而言也会带来很多的曝光量,对于很多头部 app ,展示网格菜单几乎是必选项。实现网格的方式有很多种,比如 GridView 、 GridLayout , TableLayou t等,实际上,由于 RecyclerView 的灵活性和可扩展性很高,这些 View 基本没必要去学了,为什么这样说呢?主要原因是基于 RecyclerView 可以实现很多布局效果,传统的很多 Layout 都可以通过 RecyclerView 去实现,比如 ViewPage r 、 SlideTabLayout 、 DrawerLayout 、 ListView 等,甚至连九宫格解锁效果也可以实现。 在很早之前,实现网格的拖拽效果主要是通过 G ridView 去实现的,如果列数为 1 的话,那么 GridView 基本上就实现了 ListView 一样的上下拖拽。 话说回来,我们现在基本不用去学习这类实现了,因为 RecyclerView 足够强大,通过简单的数据组装,是完全可以替代 GridView 和 ListView 的。 02 效果 本篇我们会使用 R ecyclerView 来实现网格拖拽,本篇将结合图片分片案例,实现拖拽效果:


如果要实现网格菜单的拖拽,也是可以使用这种方式的,只要你的想象丰富,理论上我们借助 RecyclerView 其实可以做出很多效果:


03 拖拽效果原理
拖动其实需要处理 3 个核心的问题 : 事件处理、图像平移、数据交换。 04 事件处理 实际上无论传统的拖拽效果还是最新的拖拽效果,都离不开事件处理,不过,好处就是, go ogle 为 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 来说简单的多。 05 图像平移 无论是 RecyclerView 和传统 GridView 拖动,都需要图像平移。我们知道, RecyclerView 和 G ridView 本身是通过子 View 的边界( left\top\right\bottom )来移动的,那么,在平移图像的时候必然不能选择这种方式,只能选择 Matrix 变换,也就是图像移动,而 View 本身不移动。通俗一点也就是修改 t ransitionX 和 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 使得被拖拽 V iew 不被其他顺序的 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 的,而且事件传递过程中需要了解这方面的思想。 06 数据更新 这里其实 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);
}
07 本篇实现 以上基本都是对 ItemTouchHelper 的原理梳理了,当然,如果你没时间看上面的话,就看实现部分吧。 08 图片分片 下面我们把多张图片分割成 [行数 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;
        }
    }
}
09 更新数据 这部分是常规操作,主要目的是设置 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 。 10 总结 本篇到这里就结束了,我们利用 RecyclerView 实现了宫格图片的拖拽效果,主要是借助 ItemTouchHelper 实现,从 ItemTouchHelper 中我们能看到很多巧妙的的设计,里面有很多值得我们学习的技巧,特别是对事件的处理、绘制顺序调整的方式,如果做吸顶,未尝不是一种方案。 11 源码 评论区有同学想要源码,很多代码都已经在文章里了,这里我贴一下 Activity 的源码应该就可以了。 xml 代码就是 recyclerView ,就不贴出来了。
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;
    }
}