2

Background

I'm trying to allow to swipe to remove items of the recycler view, but for some reason it doesn't always play nicely, showing empty spaces instead of the cards.

I've made the code handle both flinging and moving the item, to trigger the animation of the swiping, and when the swiping animation ends, the item is removed from the dataset and notifies the adapter too.

Maybe it's because I'm new to RecyclerView, but I can't find what's missing.

The code

 public class MainActivity extends ActionBarActivity
    {
    private RecyclerView mRecyclerView;
    private LinearLayoutManager mLayoutManager;
    private MyAdapter mAdapter;
    private static final int DATA_COUNT=100;
    private ArrayList<String> mDataSet;

    @Override
    protected void onCreate(final Bundle savedInstanceState)
      {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.activity_main);
      mRecyclerView=(RecyclerView)findViewById(R.id.my_recycler_view);
      mRecyclerView.setHasFixedSize(true);
      mLayoutManager=new LinearLayoutManager(this);
  // TODO in case we use GridLayoutManager, consider using this: http://stackoverflow.com/q/26869312/878126
      mRecyclerView.setLayoutManager(mLayoutManager);
      mDataSet=new ArrayList<String>(DATA_COUNT);
      for(int i=0;i<DATA_COUNT;++i)
        mDataSet.add(Integer.toString(i));
      mAdapter=new MyAdapter(mDataSet);
      mRecyclerView.setAdapter(mAdapter);
      }

    // ///////////////////////////////////////////////////////////////
  // MyAdapter//
  // ///////////
    public class MyAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
      {
      private final ArrayList<String> mDataset;

      public class ItemViewType
        {
        private static final int HEADER=0, ITEM=1;
        }

      public MyAdapter(final ArrayList<String> myDataset)
        {
        mDataset=myDataset;
        }

      @Override
      public RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup parent,final int viewType)
        {
        final RecyclerView.ViewHolder holder;
        final View rootView;
        switch(viewType)
          {
          case ItemViewType.HEADER:
            rootView=LayoutInflater.from(parent.getContext()).inflate(R.layout.header,parent,false);
            holder=new HeaderViewHoler(rootView);
            break;
          case ItemViewType.ITEM:
            rootView=LayoutInflater.from(parent.getContext()).inflate(R.layout.card,parent,false);
            holder=new ItemViewHolder(rootView);
            rootView.setAlpha(1);
            rootView.setTranslationX(0);
            rootView.setTranslationY(0);
            handleSwiping(rootView,holder);
            break;
          default:
            holder=null;
            break;
          }
        return holder;
        }

      private void handleSwiping(final View rootView,final RecyclerView.ViewHolder holder)
        {
        final GestureDetectorCompat gestureDetector=new GestureDetectorCompat(rootView.getContext(),
            new GestureDetector.OnGestureListener()
            {
            ...      
            @Override
            public boolean onFling(final MotionEvent e1,final MotionEvent e2,final float velocityX,
                                   final float velocityY)
              {
              final int viewSwipeThreshold=rootView.getWidth()/4;
              if(velocityX<-viewSwipeThreshold)
                {
                onSwipe(rootView,holder.getPosition(),false);
                return true;
                }
              else if(velocityX>viewSwipeThreshold)
                {
                onSwipe(rootView,holder.getPosition(),true);
                return true;
                }
              return false;
              }
            });
        rootView.setOnTouchListener(new View.OnTouchListener()
        {
        private final float originalX=0;
        private final float originalY=0;
        private float startMoveX=0;
        private float startMoveY=0;

        @Override
        public boolean onTouch(final View view,final MotionEvent event)
          {
          final int viewSwipeHorizontalThreshold=rootView.getWidth()/3;
          final int viewSwipeVerticalThreshold=view.getContext().getResources()
              .getDimensionPixelSize(R.dimen.vertical_swipe_threshold);
          if(gestureDetector.onTouchEvent(event))
            return true;
          final float x=event.getRawX(), y=event.getRawY();
          final float deltaX=x-startMoveX, deltaY=y-startMoveY;
          switch(event.getAction()&MotionEvent.ACTION_MASK)
            {
            case MotionEvent.ACTION_DOWN:
              startMoveX=x;
              startMoveY=y;
              break;
            case MotionEvent.ACTION_UP:
              if(Math.abs(deltaX)<viewSwipeHorizontalThreshold)
                {
                rootView.animate().translationX(originalX).translationY(originalY).alpha(1).start();
                if(Math.abs(deltaY)<viewSwipeHorizontalThreshold)
                  rootView.performClick();
                }
              else if(deltaX<0)
                onSwipe(rootView,holder.getPosition(),true);
              else
                onSwipe(rootView,holder.getPosition(),false);
              break;
            case MotionEvent.ACTION_CANCEL:
              if(Math.abs(deltaX)<viewSwipeHorizontalThreshold
                  ||Math.abs(deltaY)<viewSwipeVerticalThreshold)
                rootView.animate().translationX(originalX).translationY(originalY).alpha(1).start();
              else if(deltaX<0)
                onSwipe(rootView,holder.getPosition(),true);
              else
                onSwipe(rootView,holder.getPosition(),false);
              break;
            case MotionEvent.ACTION_POINTER_DOWN:
              break;
            case MotionEvent.ACTION_POINTER_UP:
              break;
            case MotionEvent.ACTION_MOVE:
              rootView.setAlpha(Math.max(Math.min((255-Math.abs(deltaX))/255f,1.0f),0.1f));
              rootView.setTranslationX(deltaX);
              break;
            }
          return true;
          }
        });

        }

      @Override
      public void onBindViewHolder(final RecyclerView.ViewHolder holder,final int position)
        {
        final int itemViewType=getItemViewType(position);
        final View rootView=holder.itemView;
        rootView.setAlpha(1);
        rootView.setTranslationX(0);
        rootView.setTranslationY(0);
        }

      private void onSwipe(final View rootView,final int position,final boolean isToLeft)
        {
        ViewPropertyAnimator animator;
        if(isToLeft)
          animator=rootView.animate().translationX(-rootView.getWidth());
        else
          animator=rootView.animate().translationX(rootView.getWidth());
        animator.setListener(new Animator.AnimatorListener()
        {
        @Override
        public void onAnimationStart(Animator animation)
          {
          }

        @Override
        public void onAnimationEnd(Animator animation)
          {
          rootView.setAlpha(1);
          mDataset.remove(position);
          notifyItemRemoved(position);
          }

        @Override
        public void onAnimationCancel(Animator animation)
          {
          }

        @Override
        public void onAnimationRepeat(Animator animation)
          {
          }
        });
        animator.start();
        }

      @Override
      public int getItemCount()
        {
        return mDataset.size()+1;
        }

      @Override
      public int getItemViewType(final int position)
        {
        return position==0?ItemViewType.HEADER:ItemViewType.ITEM;
        }
      }

  // ///////////////////////////////////////
  // HeaderViewHoler //
  // //////////////////

    public static class HeaderViewHoler extends RecyclerView.ViewHolder
      {
      public TextView mTextView;

      public HeaderViewHoler(final View v)
        {
        super(v);
        }
      }

    // ///////////////////////////////////////
  // ItemViewHolder //
  // /////////////////
    public static class ItemViewHolder extends RecyclerView.ViewHolder
      {

      public ItemViewHolder(final View rootView)
        {
        super(rootView);
        rootView.setAlpha(1);
        rootView.setTranslationX(0);
        rootView.setTranslationY(0);
        }
      }
    }

The question

What is wrong in what I did? How come it sometimes works well and sometimes doesn't?

Is there maybe a better solution for the swipe-to-remove handling?

android developer
  • 114,585
  • 152
  • 739
  • 1,270

1 Answers1

3

You cannot access the position parameter in the callback because RecyclerView will not rebind a ViewHolder just because its position has changed. Removing an item changes the position of all items below it so all of your position references for those items will be obsolete.

Instead, you can use ViewHolder#getPosition to get the up to date position at the time of the user action.

In addition to that, do not add the gesture listener and touch listener in onBind, instead, add them when you create the ViewHolder. This way, you'll avoid creating a new object each time an item is rebound.

Update for the comment. Suggested changes:

@Override
    public RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
        RecyclerView.ViewHolder holder = null;
        View rootView;
        switch (viewType) {
        case ItemViewType.HEADER:
            rootView = LayoutInflater.from(parent.getContext()).inflate(R.layout.header, parent, false);
            holder = new HeaderViewHoler(rootView);
            break;
        case ItemViewType.ITEM:
            rootView = LayoutInflater.from(parent.getContext()).inflate(R.layout.card, parent, false);
            holder = new ItemViewHolder(rootView);
            //initialize gesture detector and touch listener, replace position with getPosiiton
        }
        return holder;
    }
Patrick Roberts
  • 49,224
  • 10
  • 102
  • 153
yigit
  • 37,683
  • 13
  • 72
  • 58
  • I didn't know that. I used to do the optimization you've written for ListView (so that it won't create a new listener each time). Will what you wrote fix all of the swiping issues? Can you please show me what should be changed in order to fix the code? – android developer Dec 31 '14 at 09:34
  • You should basically copy the onBind code (related to listeners) and move it to onCreate. Also replace `position` with `holder.getPosition` and it should work fine. (assuming your gesture code is correct). – yigit Dec 31 '14 at 09:42
  • This almost works. The swiping is still hard to perform, and when I scroll after removing items, I see gaps. For the gaps, I've fixed it by resetting the alpha (to 1) and translations (to 0) inside the onBindViewHolder . However, I don't get how to change the swiping mechanism correctly. I will now update the code of the question. – android developer Jan 01 '15 at 18:39
  • what do you mean by "hard" ? Also, you should call [requestDisallowTouch](http://developer.android.com/reference/android/view/ViewParent.html#requestDisallowInterceptTouchEvent(boolean)) when dragging starts and call it again when dragging ends. – yigit Jan 01 '15 at 20:49
  • By "hard" I mean that as a user, it's hard to cause an item to be removed by swiping. It doesn't work naturally enough. I've added the function you wrote. Works better, but still I have issues with the dragging. sometimes, when I swipe to the left, it first goes to the center and then to the right (and vice versa). Also , I somehow managed to get an exception when the animation ended, on the method "notifyItemRemoved()" :IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrolling – android developer Jan 01 '15 at 23:12
  • Maybe I need to remove the gesture handling? – android developer Jan 01 '15 at 23:19
  • Hmm, you probably received that exception because your animation has been canceled by the Item animator to run a new animation (it thought the view should be moved). You can avoid it by deferring the move using a handler. I know it is not pretty but RV cannot handle adapter changes during a layout calculation. There is also a pending improvement in default animator to not to cancel animations that are not created by it. – yigit Jan 01 '15 at 23:43
  • About the jump, I read through your code, cannot spot where the problem is but probably some value is not reset properly. If you add some logs to your event handler and also to the places where you set translationX, it should show what is going on. Also, for fling, you can use a velocity tracker. See Roman's [example](https://github.com/romannurik/Android-SwipeToDismiss/blob/master/src/com/example/android/swipedismiss/SwipeDismissListViewTouchListener.java). – yigit Jan 01 '15 at 23:44
  • Will it help if I publish the app for you to check it? Here it is: https://drive.google.com/file/d/0B-PZZGk2vPohU0NLU1FScUNRc1E/view?usp=sharing – android developer Jan 02 '15 at 12:28
  • I'll try to check when i have time, may not be a couple of days. Meanwhile, one more thing to improve is to use `RV.addOnItemTouchListener` instead of adding touch listeners on onCreateVH. The current way may confuse RV and calculate scroll events wrong vs by using an ItemTouchListener, you can grab the events you know and RV will be able to ignore them properly. You can use `rv.findChildViewUnder(ev.getX(), ev.getY())` to get the target View and `rv.getChildViewHolder(view)` to get the view holder. – yigit Jan 02 '15 at 20:11
  • It's ok. I'm not in a hurry. – android developer Jan 03 '15 at 10:52
  • This is exactly what I was looking for! My indexes were messed up after moving/removing items because I was not using the view holder's `layoutPosition` and also attaching listeners in `onBindViewHolder()` (which won't be called after such gestures). Thanks a lot :) – Stephen Vinouze Apr 27 '16 at 08:14