0

Scenario:

  • RecyclerView with a RecyclerView.Adapter. The notifyDataSetChanged()-method is called every 10ms because time sensitive information is shown, so the ViewHolders (class is extending RecyclerView.ViewHolder) are updated frequently.
  • The underlying data comes from a native C-library, so the Adapter-methods are overridden to query the data from the native library.
  • derived from this best practice video an interface was implemented in the Adapter-class to pass the onTouch-Events from the Adapter to the Activity. Therefore the ViewHolder-class implements View.OnTouchListener and passes all touch events and the listItem-position (queried bygetAdapterPosition()) via the interface to the Activity.
  • various onTouch-events (Click, LongClick, Swipe etc.) for each ListItem should be recognized in the activity.

Problem:

When a ListItem is touched shortly afternotifyDataSetChanged() has been called, the received values are:

1 getAdapterPosition(): -1

2 motionEvent.getAction(): ACTION_DOWN directly followed by ACTION_CANCEL

The android-documentation says:

Note that if you've called notifyDataSetChanged(), until the next layout pass, the return value of this method will be NO_POSITION.

So I guess the error occurs everytime the listItem is touched while notifyDatasetChanges() refreshes the viewHolders: getAdapterPosition() returns -1 according to the documentation. And, probably because the elements in the viewHolder are refreshed, the onTouchEvent throws a ACTION_CANCEL.

what I tried:

  1. I tried to stop the refreshing of the RecyclerView on ACTION_DOWN, but because of the ACTION_CANCEL event, I do not receive any ACTION_UP event and can not restart the refreshing of the data.
  2. I added a RecyclerView.OnItemTouchListener() to the recyclerView in the activity to receive the TouchEvent in onInterceptTouchEvent() before it is passed to the listItem and its onTouchListener and stop the refreshing of the recyclerview there. But, because I still need information about what item was clicked, I still need the items onTouchListener which is still returning -1 and ACTION_CANCEL.

Question:

What is the best practice to handle onTouch events in a RecyclerView which is frequently updating its data?

Activity-class

public class Activity extends AppCompatActivity implements DataStoreDataAdapter.OnListItemTouchListener {



    Handler dataUpdateHandler = new Handler();

    Runnable timerRunnable = new Runnable() {
        @Override
        public void run() {
            dataStoreDataAdapter.notifyDataSetChanged();

            dataUpdateHandler.postDelayed(this, 10);
        }
    };


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity);

        dataStoreDataAdapter = new DataStoreDataAdapter(getApplicationContext(),this);

        recyclerView = findViewById(R.id.list_view);
        layoutManager = new LinearLayoutManager(this);
        recyclerView.setLayoutManager(layoutManager);
        recyclerView.setAdapter(dataStoreDataAdapter);

        recyclerView.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
            @Override
            public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
                Log.i(TAG, "onInterceptTouchEvent: " + " touched by type " + e);
                int action = e.getAction();
                if (action == MotionEvent.ACTION_DOWN) {
                    dataUpdateHandler.removeCallbacks(timerRunnable);
                } else if (action == MotionEvent.ACTION_UP) {
                    dataUpdateHandler.post(timerRunnable);
                }
                return false;
            }

            @Override
            public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
                Log.i(TAG, "onTouchEvent: " + " touched by type " + e);
            }


            @Override
            public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {

            }
        });


        //set update Handler
        dataUpdateHandler.post(timerRunnable);
    }

    @Override
    public void onListItemTouch(int position, MotionEvent motionEvent) {
        Log.i(TAG, "onListItemTouch: ListItem position " + position + " motionEvent: " + motionEvent);
    }
}

Adapter-class

public class DataStoreDataAdapter extends RecyclerView.Adapter<DataStoreDataAdapter.ViewHolder> {

      private OnListItemTouchListener onListItemTouchListener;

    static class ViewHolder extends RecyclerView.ViewHolder implements View.OnTouchListener{
        View view;
        TextView Name;
       // ...  

        ViewHolder(@NonNull View itemView, OnListItemTouchListener onListItemTouchListener) {
            super(itemView);
            this.Name = itemView.findViewById(R.id.NameListItem);
            // ...

            this.onListItemTouchListener = onListItemTouchListener;
            itemView.setOnTouchListener(this);
        }

        @Override
        public boolean onTouch(View view, MotionEvent motionEvent) {
            onListItemTouchListener.onListItemTouch(getAdapterPosition(), motionEvent);
            return true;
        }
    }

    public interface OnListItemTouchListener{
        void onListItemTouch(int position, MotionEvent motionEvent);
    }

    public DataStoreDataAdapter(Context context, OnListItemTouchListener onListItemTouchListener) {
        super();
        this.onListItemTouchListener = onListItemTouchListener;
    }

    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {

        LayoutInflater inflater = LayoutInflater.from(parent.getContext());
        View view = inflater.inflate(R.layout.list_item,parent,false);

        return new ViewHolder(view, onListItemTouchListener);
    }

    @Override
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        // get the stored data for this position
        // ...get stuff from native library

        // put data into view elements...

    }

    @Override
    public int getItemCount() {
        return // ... itemCount from native library;
    }

}
Niklas Dada
  • 311
  • 4
  • 17

2 Answers2

0

What happens I think is that the notifyDataSetChanged is still in progress while the touch happens during it the items dont have a position and I dont think this can be fixed.

The Listener is fine in my opinion, what you really should change is the notifyDataSetChanged() part.

you could use DiffUtil instead so that only the items update that are actually changed (both in position or content).

go here for more information how to implement this: https://medium.com/@iammert/using-diffutil-in-android-recyclerview-bdca8e4fbb00

Finn Marquardt
  • 512
  • 3
  • 9
  • I was able to fix the "-1" itemList-position-issue by replacing `notifyDatasetChanged()` by `notifyItemRangeChanged(0,dataStoreDataAdapter.getItemCount())` Clarification is given by [this question](https://stackoverflow.com/questions/33789345/whats-better-notifydatasetchanged-or-notifyitemchanged-in-loop) It still does not fix the return of "ACTION_CANCEL" if the listItem is touched in the "wrong moment". I will have a look at DiffUtil and report if this succeeded- – Niklas Dada Oct 31 '19 at 09:47
  • I tried a few more things, but I still receive the ACTION_CANCEL events occasionally: I changed `notifyDataSetChanged()` to `notifyItemRangeChanged()` to avoid a structual change. I also call `requestDisallowInterceptTouchEvent(true)` from the ViewHolders `onTouch()` to prevent the parent-ViewGroups from intercepting the gesture. [This question](https://stackoverflow.com/questions/6018309/action-cancel-while-touching) gives clarification about this. But I still receive ACTION_CANCEL events on some of the touches. Anybody an idea about that? Can I query which viewGroup intercepts the touch? – Niklas Dada Nov 04 '19 at 11:25
0

I figured it out by myself. Here are the answers:

  1. getAdapterPosition(): -1: This happens, as I mentioned in the question already, because notifyDataSetChanged() rebuilds the whole layout. So if a list item is touched between a layout rebuild and the layout pass, the function will return -1. One solution was given by Finn Marquart: Use DiffUtils to reduce the amount of refreshes to a minimum for each item in the list. That not worked for me, because I need to refresh the item very often. So my solution was to use notifyItemRangeChanged(0,devicedataDataAdapter.getItemCount()) instead of notifyDataSetChanged(), because this only refreshes the elements in the viewholder and not recreates the whole list.
  2. avoid intercepting of the touch event in the view holder: some higher View intercepted the touch gesture*, so that the implemented onTouch() method for each view holder gets an ACTION_CANCEL event and would not have been informed about the gesture anymore (the ACTION_UP event can only been received on parent views like the the OnItemTouchListener() of the recyclerview). Responsible für this was the itemAnimator, which is a member of the recyclerview. After disabling the itemAnimator by setting it to null, the touch gestures are no longer been intercepted:

recyclerView.setItemAnimator(null);

Hope, that answer helps anybody out there. I spend way to much time figure this out :)


*I define the "gesture" as the sum of all touch events between an ACTION_DOWN and an ACTION_UP event.

Niklas Dada
  • 311
  • 4
  • 17