美文网首页week.ioAndroid实用控件
使用ItemTouchHelper高效地实现 今日头条 、网易新

使用ItemTouchHelper高效地实现 今日头条 、网易新

作者: YoKey | 来源:发表于2016-01-05 14:36 被阅读14482次

    主要效果图

    使用RecyclerView配合ItemTouchHelper实现,性能更好、更流畅!
    支持大数量item的情况(即 RecyclerView内容较多,可滑动的情况)
    下载Demo

    仅供参考,实际使用建议使用类似BRVAH的库再封装下

    部分效果演示.gif

    主要功能

    在普通模式下,长按“我的频道”的item,可以拖拽排序并进入编辑模式

    在编辑模式下,触摸“我的频道”的item,可以直接拖拽排序

    在任意模式下,点击“其他频道”的item,移动到“我的频道”,并伴随移动动画

    在编辑模式下,点击“我的频道”的item,移动到“其他频道”,并伴随移动动画

    实现思路

    一、实现拖拽排序
    3种方式

    1、WindowManager
    我在之前的项目中使用的方式,大致思路是:获取需要拖拽的View,生成镜像View,添加到WindowManager,移动时,通过Touch事件的X、Y坐标,利用windowManager的updateViewLayout方法更新位置。需要自己维护onInterceptTouchEvent、onTouchEvent,并且在拖拽的item移动的高度超过一屏时,需要手动控制RecyclerView(ListView/GridView)的滚动,较为繁琐。

    2、View的startDrag方法配合setOnDragListener
    这种方式不需要处理RecyclerView(ListView/GridView)的onInterceptTouchEvent和onTouchEvent,实现起来更方便一些,详情可以参考官方教程(Drag & Drop)

    3、使用RecyclerView包的ItemTouchHelper
    Demo使用的方式。只能用RecyclerView实现,但是性能、功能都很强大,实现也非常简单,ItemTouchHelper处理好了关于在RecyclerView上添加拖动排序与滑动删除的所有事情。
    通过ItemTouchHelper.Callback的onMove回调方法,对数组集合进行交换位置,并通过notifyItemMove方法刷新界面,RecyclerView默认的item动画为DefaultItemAnimator,它的notifyItemMove方法使范围内item有一个很自然的位移动画。

    二、实现不同Grid的item之间移动(伴随位移动画)
    不同Grid的item之间移动.png

    这部分是Demo中逻辑最复杂的部分,移动的同时还要排序,并且Demo考虑了内容特别多(RecyclerView可滑动)的情况下的移动。

    Demo实现的思路是依靠notifyItemMove方法实现 需要移动的item 的后面各个item的移动效果,例如在上图中,即item4、item5向左方向的移动动画,但是并不会有下方item3向上方item3移动的位移动画,所以这里还需要使用位移动画实现该效果。

    三、状态-普通模式、编辑模式

    普通模式下,逻辑很简单,长按“我的频道”的item,可以拖拽排序并进入编辑模式;
    编辑模式下,可以直接拖拽“我的频道”的item,同时保证点击事件可用以及不能影响RecyclerView的滑动,Demo的解决方式是,对item设置setTouchListener事件,当MOVE事件与DOWN事件的触发的间隔时间大于100ms时,则认为是拖拽starDrag,小于100ms不做任何处理,return false。

    核心代码

    拖拽排序:

    使用 ItemTouchHelper 和 ItemTouchHelper.Callback

    
    /**
     * ItemDragHelperCallback
     * Created by YoKeyword on 15/12/29.
     */
    public class ItemDragHelperCallback extends ItemTouchHelper.Callback {
    
        @Override
        public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
            int dragFlags;
            RecyclerView.LayoutManager manager = recyclerView.getLayoutManager();
            if (manager instanceof GridLayoutManager || manager instanceof StaggeredGridLayoutManager) {
                dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN | ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
            } else {
                dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
            }
            // 如果想支持滑动(删除)操作, swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END
            int swipeFlags = 0;
            return makeMovementFlags(dragFlags, swipeFlags);
        }
    
        @Override
        public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
            // 不同Type之间不可移动
            if (viewHolder.getItemViewType() != target.getItemViewType()) {
                return false;
            }
    
            if (recyclerView.getAdapter() instanceof OnItemMoveListener) {
                OnItemMoveListener listener = ((OnItemMoveListener) recyclerView.getAdapter());
                listener.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition());
            }
            return true;
        }
    
        @Override
        public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {}
    
        @Override
        public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
            // 不在闲置状态
            if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) {
                if (viewHolder instanceof OnDragVHListener) {
                    OnDragVHListener itemViewHolder = (OnDragVHListener) viewHolder;
                    itemViewHolder.onItemSelected();
                }
            }
            super.onSelectedChanged(viewHolder, actionState);
        }
    
        @Override
        public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
            if (viewHolder instanceof OnDragVHListener) {
                OnDragVHListener itemViewHolder = (OnDragVHListener) viewHolder;
                itemViewHolder.onItemFinish();
            }
            super.clearView(recyclerView, viewHolder);
        }
    
        @Override
        public boolean isLongPressDragEnabled() {
            // 不需要长按拖拽功能  我们手动控制
            return false;
        }
    
        @Override
        public boolean isItemViewSwipeEnabled() {
            // 不需要滑动功能
            return false;
        }
    }
    

    getMovementFlags()可以指定需要拖拽的方向;

    isLongPressDragEnabled()如果返回true,则支持长按拖拽,该Demo中,“其他频道”等不需要拖拽,所以返回false,手动调用ItemTouchHelper的startDrag方法启动拖拽。

    onMove()是在拖动到新位置时候的回调方法,我们在这里做数组集合的交换操作,在这里我们把它暴漏出去,交给Adapter自己处理;
    一般来说,实现拖拽排序的写法为:

    @Override
        public void onItemMove(int fromPosition, int toPosition) {
            String item = mItems.get(fromPosition);
            mItems.remove(fromPosition);
            mItems.add(toPosition , item);
            notifyItemMoved(fromPosition, toPosition);
        }
    

    onSelectedChanged()方法和clearView()方法,分别在item被选中以及取消选中的时候调用,这里同样将它们以接口暴漏出去,在Adapter的ViewHolder里实现接口,让item在选中时高亮;

        //我的频道
        class MyViewHolder extends RecyclerView.ViewHolder implements OnDragVHListener {
            private TextView textView;
            private ImageView imgEdit;
    
            public MyViewHolder(View itemView) {
                super(itemView);
                textView = (TextView) itemView.findViewById(R.id.tv);
                imgEdit = (ImageView) itemView.findViewById(R.id.img_edit);
            }
    
            // item 被选中时
            @Override
            public void onItemSelected() {
                textView.setBackgroundResource(R.drawable.bg_channel_p);
            }
    
            // item 取消选中时
            @Override
            public void onItemFinish() {
                textView.setBackgroundResource(R.drawable.bg_channel);
            }
        }
    

    最终在Activity中,调用:

    ItemDragHelperCallback callback = new ItemDragHelperCallback();
    ItemTouchHelper touchHelper = new ItemTouchHelper(callback);
    touchHelper.attachToRecyclerView(recyclerView);
    

    以上部分,可参照Demo1

    不同Grid的item的移动(item的删除和添加)

    Demo中仅仅使用一个RecyclerView实现,ViewType如下图所示:

    RecyclerView的ViewType.png

    首先是需要移动的item的位移动画(即"不同Grid的item之间移动.png"图中的item3),因为item3向上方移动的动画以及item4、item5向左移动的动画是同时的,并且我们使用的notifyItemMove自带的动画,所以我们要在调用notifyItemMove()的同时,启动item3的位移动画。
    所以我们需要制造一个item3的镜像ImageView,添加到recyclerView的父控件中,直接控制item3进行位移不会起作用,因为notifyItemMove的时候,RecyclerView处于动画和充绘界面中,item3并不受控制,并且因为RecyclerView的子控件的层级问题,当上方item向下方移动时,会被遮挡。

    生成镜像ImageView代码如下:

    /**
         * 添加需要移动的 镜像View
         */
        private ImageView addMirrorView(ViewGroup parent, RecyclerView recyclerView, View view) {
            /**
             * 我们要获取cache首先要通过setDrawingCacheEnable方法开启cache,然后再调用getDrawingCache方法就可以获得view的cache图片了。
             buildDrawingCache方法可以不用调用,因为调用getDrawingCache方法时,若果cache没有建立,系统会自动调用buildDrawingCache方法生成cache。
             若想更新cache, 必须要调用destoryDrawingCache方法把旧的cache销毁,才能建立新的。
             当调用setDrawingCacheEnabled方法设置为false, 系统也会自动把原来的cache销毁。
             */
            view.destroyDrawingCache();
            view.setDrawingCacheEnabled(true);
    
            final ImageView mirrorView = new ImageView(recyclerView.getContext());
            Bitmap bitmap = Bitmap.createBitmap(view.getDrawingCache());
            mirrorView.setImageBitmap(bitmap);
            view.setDrawingCacheEnabled(false);
    
            int[] locations = new int[2];
            view.getLocationOnScreen(locations);
            int[] parenLocations = new int[2];
            recyclerView.getLocationOnScreen(parenLocations);
            FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(bitmap.getWidth(), bitmap.getHeight());
            params.setMargins(locations[0], locations[1] - parenLocations[1], 0, 0);
    
            parent.addView(mirrorView, params);
    
            return mirrorView;
        }
    

    最终,镜像ImageView启动位移动画的同时,调用notifyItemMove:

    private void startAnimation(RecyclerView recyclerView, final View currentView, float targetX, float targetY) {
            final ViewGroup viewGroup = (ViewGroup) recyclerView.getParent();
            final ImageView mirrorView = addMirrorView(viewGroup, recyclerView, currentView);
    
            Animation animation = getTranslateAnimator(
                    targetX - currentView.getLeft(), targetY - currentView.getTop());
            currentView.setVisibility(View.INVISIBLE);
            mirrorView.startAnimation(animation);
    
            animation.setAnimationListener(new Animation.AnimationListener() {
                @Override
                public void onAnimationStart(Animation animation) {
                }
    
                @Override
                public void onAnimationEnd(Animation animation) {
                    viewGroup.removeView(mirrorView);
                    if (currentView.getVisibility() == View.INVISIBLE) {
                        currentView.setVisibility(View.VISIBLE);
                    }
                }
    
                @Override
                public void onAnimationRepeat(Animation animation) {
    
                }
            });
        }
    
        /**
         * 获取位移动画
         */
        private TranslateAnimation getTranslateAnimator(float targetX, float targetY) {
            TranslateAnimation translateAnimation = new TranslateAnimation(
                    Animation.RELATIVE_TO_SELF, 0f,
                    Animation.ABSOLUTE, targetX,
                    Animation.RELATIVE_TO_SELF, 0f,
                    Animation.ABSOLUTE, targetY);
            translateAnimation.setDuration(ANIM_TIME);
            translateAnimation.setFillAfter(true);
            return translateAnimation;
        }
    

    逻辑最复杂的部分来了:如何获取移动目标的位置?
    比如:“我的频道”的item移动到"其他频道",这种情况比较简单,因为总是移动到“其他频道”的第一个item
    正常情况下,可以这样获取:

    View targetView = recyclerView.getLayoutManager().findViewByPosition(mMyChannelItems.size() + COUNT_PRE_OTHER_HEADER);
    
    targetX = targetView.getLeft();
    targetY = targetView.getTop();
    

    但是当item足够多的时候,一屏幕不能容纳的时候,会有下面的情况:


    这时,item x+4 向下移动的同时,RecyclerView同时会向下滚动,导致“其他频道”的内容向上移动,这时再使用上面的方式获取目标位置就不正确了,要这样获取:

    // 移动后 高度将变化 (我的频道Grid 最后一个item在新的一行第一个)
    if ((mMyChannelItems.size() - COUNT_PRE_MY_HEADER) % spanCount == 0) {
        View preTargetView = recyclerView.getLayoutManager().findViewByPosition(mMyChannelItems.size() + COUNT_PRE_OTHER_HEADER - 1);
        targetX = preTargetView.getLeft();
        targetY = preTargetView.getTop();} 
    

    同样的道理,“其他频道”移动到“我的频道”一样的处理方式,不过细节更多、更复杂些,这里就不说明了,感兴趣的可以在文章最后查看源码。

    编辑模式下的Touch事件传递

    当MOVE事件与DOWN事件的触发的间隔时间大于100ms时,则认为是拖拽starDrag,小于100ms不做任何处理,return false。这样item的点击事件、RecyclerView的滚动事件都可以正常执行。

     myHolder.textView.setOnTouchListener(new View.OnTouchListener() {
                        @Override
                        public boolean onTouch(View v, MotionEvent event) {
                            if (isEditMode) {
                                switch (MotionEventCompat.getActionMasked(event)) {
                                    case MotionEvent.ACTION_DOWN:
                                        startTime = System.currentTimeMillis();
                                        break;
                                    case MotionEvent.ACTION_MOVE:
                                        if (System.currentTimeMillis() - startTime > SPACE_TIME) {
                                            mItemTouchHelper.startDrag(myHolder);
                                        }
                                        break;
                                    case MotionEvent.ACTION_CANCEL:
                                    case MotionEvent.ACTION_UP:
                                        startTime = 0;
                                        break;
                                }
    
                            }
                            return false;
                        }
                    });
    

    总结

    Demo里对于频道的排序和移动,要考虑的细节还是挺多的,但是理清好思路,解决起来并不是很困难。
    从最终Demo效果来看,RecyclerView配合ItemTouchHelper的实现方式,确实比今日头条、网易新闻 性能更高效、动画更流畅。

    完整源码、Demo下载

    Demo地址
    完整源码在GitHub

    相关文章

      网友评论

      • 是小生孟浪了:能不能把前三个给定住,也就是前三个不可编辑
      • 堇色流年:移动的时候往往是把当前item全部移动到另一个左边或者右边才会触发交换位置,我想是想当前item覆盖到了另一个item的一半以上就发生移动,比如我向左移动覆盖了左边一个item右边的一半就视为移动到它的左边。如果覆盖了另一个item左边一半以上就视为向右移动。
      • 码农砖家:连续快速点击其他频道添加到我的频道时崩溃:joy:
      • Jackson_ba56:砸场子连接 :https://github.com/MrJiao/CommonRecycler
        我是来砸场子的,谢谢作者文章,但是写法上还是太乱,由于这个原因我封装了一下RecyclerView的Adapter、Holder、监听器,重实现了一下博主demo。
        谢谢博主文章,有些地方写得很不错
        YoKey::joy: 是的,写法上直接生写的~ 现在回过来看的话,Adapter是需要封装下的,我看了你的rep,Bean需继承你的CommonEntity,Java只有一次宝贵的继承机会~ 现在有个很成熟的库:https://github.com/CymChad/BaseRecyclerViewAdapterHelper 写的很好,可以看下他的思路
      • 喜欢丶下雨天:认真看了全部代码,并且按你的思路自己写了一遍,写的真不错,有时间我想就你这个demo也写一篇blog
      • 五月槐花香:您好,最后1到2个,会出现,先刷新后动画的效果,一直没法同步,我一直没改好,请给点思路么
      • 程序狮:写得很详细,赞一个
      • 3946264bdd64:楼主,如果除了我的频道,其他频道,再分出来一个生活频道,娱乐频道这样,多个区域,你这个就不好做了吧
      • JackChen1024:当布局文件如下的时候,我的频道和其他频道的item移动动画会出现异常
        <?xml version="1.0" encoding="utf-8"?>
        <LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android&quot;
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

        <android.support.v7.widget.Toolbar
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        />

        <android.support.v7.widget.RecyclerView
        android:id="@+id/recy"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipChildren="false"
        android:clipToPadding="false"/>
        </LinearLayout>

        demo中FrameLayout下只嵌套RecyclerView没有问题
        JackChen1024:水平有限,不太会改,代码是你写的,你可能花点时间看看,就知道怎么改了,我可能要研究好久,包裹FrameLayout可以解决问题,但是我还是想知道问题在哪里
        YoKey:@Jack1999 额,这个Demo现在不再维护了,要不包层FrameLayout解决下; 或者自己改下demo的代码~ :blush:
        JackChen1024:经过测试,把根布局的FrameLayout换成垂直的LinearLayout动画也是有问题的,只有在RecyclerView的父view是FrameLayout的时候才正常
      • e1c0d26bd8e8:那我参考一下网上其他用WM实现的代码,多谢你的回复
        YoKey: @dream_developer 祝你好运😃
      • e1c0d26bd8e8:楼主,请问如果要实现拖动的item可以覆盖标题栏应该怎么修改代码?直接在你的代码上用clipChildren="false"的话,item可以覆盖标题栏,但是RecyclerView滚动的时候超出部分也会显示出来;RecyclerView结合WindowManager实现的话,就需要在RecyclerView上监听touch事件并配合Adapter里面的相关逻辑,滚动的代码也要加上,这样代码复杂度会有所增加。有没有更好的办法?
        YoKey: @dream_developer 他们的实现应该是WindowManager 以前我有个实现也是用WM 可以做到你说的效果 不过性能和ItemTouchHelper差不少
        e1c0d26bd8e8:@YoKey 见到魅族资讯、今日头条实现的效果是item可以覆盖任何地方,所以也想实现成那样的。直接使用ItemTouchHelper的话Item就只能在RecyclerView范围内移动了
        YoKey:@dream_developer 或者从布局上想想办法? 好像需求有点怪 :flushed:
      • d074abb94e47:大神您好,我想请教您一下,您这个demo在Android5以下的版本,在拖拽的时候没有默认的移动动画,我比较了一下网易新闻客户端,发现他们的那边在Android5以下的版本有拖拽时的移动动画,我想请教您应该怎么实现
        YoKey:@dxlm 我印象中在低版本手机上试过~ 因为用的是v4 v7的包 没什么高级特性~ 你下github上的demo编译下看看~ 我回头有空再看看
        d074abb94e47:@YoKey 移动动画在高版本的系统上的确是有的 :smile: ,但是我用的酷派f2,系统是4.4.4,就是在拖动一个item的时候,其他item在移位的过程中没有移动动画,我在别人的手机上,系统6.0上很明显其他item在移动过程中有移动动画
        YoKey:@dxlm 拖拽的时候 有移动动画啊~ :anguished:
      • 潇洒哥hy:http://blog.csdn.net/sinat_28959069/article/details/51971203 模仿楼主写的一个 可以看看~
        YoKey:@朝阳潇洒哥 :+1:
      • 9ac161dbfb54:我添加了一个header,怎么让holder不可移动 呃这样说吧,我怎么指定某一项不可移动
        9ac161dbfb54:@YoKey 好的
        YoKey:@9ac161dbfb54 在我的demo中,Adapter里mItemTouchHelper.startDrag(myHolder);这句代码在长按后触发的,即开始拖动,如果你想让某一item不能拖动,就不要调用该方法即可。
        mItemTouchHelper是继承ItemTouchHelper.Callback的类,不熟悉可以看下ItemTouchHelper相关的介绍 :smile:
      • 680a8dd6d669:如果要第一个不能编辑呢
        YoKey:@wirad 在我的demo中,Adapter里mItemTouchHelper.startDrag(myHolder);这句代码在长按后触发的,即开始拖动,如果你想让某一item不能拖动,就不要调用该方法即可。
        mItemTouchHelper是继承ItemTouchHelper.Callback的类,不熟悉可以看下ItemTouchHelper相关的介绍 :smile:
      • 随风风流:有iOS的demo吗?
        YoKey:@随风风流 :joy: 这...真没有~
      • 905d656d4a4f:兄弟,有木有我的频道和其他频道可以互相拖动的demo啊?
        YoKey:@茶几上的鱼 从源码里编译的话,里面有2个子Demo, 文章里下载地址的apk里好像只有一个 :smile:
        905d656d4a4f:@YoKey demo安装包里面的另外一个demo是哪个?
        YoKey:@茶几上的鱼 你可以看下ItemDragHelperCallback这个类里的onMove方法,不同频道互相拖动非常简单,你可以试试。或者看demo安装包里的另外一个Demo :smiley:
      • HelloVass:好评,好评,哈哈!!!

      本文标题:使用ItemTouchHelper高效地实现 今日头条 、网易新

      本文链接:https://www.haomeiwen.com/subject/mklahttx.html