Adapter / Material Design / RecyclerView

Material – Part 6

Previously in this series we have applied basic Material design to a simple RSS reader app, and most recently we converted our ListView to the new RecyclerView. However, at the conclusion of the previous article, we had completed this migration but the net result in terms of UI was zero – the behaviour was exactly the same. This begs the question: “So why bother?” In this article we’ll look at dragging items in the list to change their order (which is not impossible, but can be pretty tricky using ListView), and see how much easier it is using RecyclerView.

Before we get stuck in to the techniques it is worth pointing out that allowing the user to re-order a list of RSS feed items is a bit of a tenuous example – it’s really not something which you’d want to include in a real RSS reader app. However, the concept of re-ordering list items in general is a common one, so it certainly warrants some explanation even if it does not really fit with our example app.

The behaviour that we’re looking to implement is that the user can enter drag to re-order mode by long pressing on the item that they wish to move. This item will then float and can be dragged to a new position within the list, and other items will reposition as the item is dragged. Releasing will move the item to its new position in the list.

The first thing that we’re going to do is add a simple overlay layer to our main layout. The purpose of this is to enable us to easily and cheaply animate the list item that the user is dragging. An ideal candidate here would be to use ViewOverlay which is designed precisely for this purpose. However ViewOverlay was introduced in API 18, and our app is compatible back to API 14 so is unavailable on all devices that we’re targeting. But our requirements are fairly basic and we can achieve what we need with a few small additions.

First we need to add a FrameLayout containing an ImageView to our main layout:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <android.support.v7.widget.RecyclerView
    android:id="@+id/list"
    android:scrollbars="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>

  <TextView
    android:id="@+id/empty"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:gravity="center"
    android:textAppearance="@style/TextAppearance.AppCompat.Title" />

  <FrameLayout
      android:layout_width="match_parent"
      android:layout_height="match_parent">
    <ImageView
        android:id="@+id/overlay"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
  </FrameLayout>

</merge>

We’re going to take a bitmap snapshot of the list item being dragged and set the image of the ImageView which can then be moved within the FrameLayout as the user drags with his / her finger.

The next thing that we need to do is create an instance of a class which we’ll look at in a moment which will be responsible for handling the touch events, and performing the dragging operations. This class implements RecyclerView.OnItemTouchListener and will receive touch events for the RecyclerView:

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

    recyclerView = (RecyclerView) findViewById(R.id.list);
    ImageView overlay = (ImageView) findViewById(R.id.overlay);
    LinearLayoutManager layoutManager = new LinearLayoutManager(this);
    layoutManager.setOrientation(LinearLayoutManager.VERTICAL);
    recyclerView.setLayoutManager(layoutManager);
    recyclerView.addOnItemTouchListener(new DragController(recyclerView, overlay));

    DataFragment dataFragment = (DataFragment) getFragmentManager().findFragmentByTag(DATA_FRAGMENT_TAG);
    if (dataFragment == null) {
        dataFragment = (DataFragment) Fragment.instantiate(this, DataFragment.class.getName());
        dataFragment.setRetainInstance(true);
        FragmentTransaction transaction = getFragmentManager().beginTransaction();
        transaction.add(dataFragment, DATA_FRAGMENT_TAG);
        transaction.commit();
    }
}

Instantiating DragController requires us to pass in a reference to the RecyclerView, and the ImageView that we’re going to use as the overlay.

Now let’s turn our attention to DragController:

public class DragController implements RecyclerView.OnItemTouchListener {
    private RecyclerView recyclerView;
    private ImageView overlay;
    private final GestureDetectorCompat gestureDetector;

    private boolean isDragging = false;

    public DragController(RecyclerView recyclerView, ImageView overlay) {
        this.recyclerView = recyclerView;
        this.overlay = overlay;
        GestureDetector.SimpleOnGestureListener longClickGestureListener = new GestureDetector.SimpleOnGestureListener() {
            @Override
            public void onLongPress(MotionEvent e) {
                super.onLongPress(e);
                isDragging = true;
                dragStart(e.getX(), e.getY());
            }
        };
        this.gestureDetector = new GestureDetectorCompat(recyclerView.getContext(), longClickGestureListener);
    }

    @Override
    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
        if (isDragging) {
            return true;
        }
        gestureDetector.onTouchEvent(e);
        return false;
    }

    @Override
    public void onTouchEvent(RecyclerView rv, MotionEvent e) {
        int x = (int) e.getX();
        int y = (int) e.getY();
        View view = recyclerView.findChildViewUnder(x, y);
        if (e.getAction() == MotionEvent.ACTION_UP) {
            dragEnd(view);
            isDragging = false;
        } else {
            drag(y, view);
        }
    }

This is handling our touch events for the RecyclerView items. This has already been registered as an OnItemTouchListener for the RecyclerView, so the onInterceptTouchEvent() and onTouchEvent() methods will be called when touch events occur. onInterceptTouchEvent() is called before the touch event is processed and enables us to control whether we handle the event, or we defer the handling to another component. In our case we want to take control during drag operations, so we hold boolean indicating the dragging state.

We use a GestureDetector to detect a long click (to ensure that the detection of a long click is consistent across all apps), and enter dragging mode when we receive one. During dragging operations we’ll indicate that we want to intercept all touch events because whenever we return true from onInterceptTouchEvent() our onTouchEvent() method will be called to handle the event. Once we detect an up action (indicating that the user has lifted their finger) then we’ll end the drag operation.

Next let’s look at what we do when we start end end the drag:

private boolean isFirst = true;
private static final int ANIMATION_DURATION = 100;
private int draggingItem = -1;
private float startY = 0f;
private Rect startBounds = null;

private void dragStart(float x, float y) {
    View draggingView = recyclerView.findChildViewUnder(x, y);
    View first = recyclerView.getChildAt(0);
    isFirst = draggingView == first;
    startY = (y - draggingView.getTop());
    paintViewToOverlay(draggingView);
    overlay.setTranslationY(y - startY);
    draggingView.setVisibility(View.INVISIBLE);
    draggingItem = recyclerView.indexOfChild(draggingView);
    startBounds = new Rect(draggingView.getLeft(), draggingView.getTop(), draggingView.getRight(), draggingView.getBottom());
}

private void drag(int y, View view) {
    overlay.setTranslationY(y - startY);
}

private void dragEnd(View view) {
    overlay.setImageBitmap(null);
    view.setVisibility(View.VISIBLE);
    view.setTranslationY(overlay.getTranslationY() - view.getTop());
    view.animate().translationY(0f).setDuration(ANIMATION_DURATION).start();
}

private void paintViewToOverlay(View view) {
    Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(bitmap);
    view.draw(canvas);
    overlay.setImageBitmap(bitmap);
    overlay.setTop(0);
}

At the start of a drag event we first determine the view which has received a long click which represents the item being dragged. We also determine the first visible child of the RecyclerView as it will be useful to know this later on. Next we calculate the starting Y offset of the view being dragged as we’ll need this to correctly position the ImageView in the overlay. Next we paint the view being dragged to the overlay by creating a bitmap matching the dimensions of the view being dragged, creating a Canvas to enable us to draw to that Bitmap, and then actually drawing our View to the canvas before setting the ImageView bitmap using the Bitmap we’ve just created. Next we position the ImageView using the startY value which we calculated earlier. Then we set the visibility of the View within the RecyclerView to INVISIBLE. Finally we store the bounds of view being dragged as we’ll need these later on to determine whether the videw has been moved outside its current bounds.

Basically what we have done here is take a copy of the View, place it in the overlay, and then the hide original view within the RecyclerView. This allows us to move the ImageView around independently of the RecyclerView.

During a drag operation we simply move the overlay as the user moves their finger (we’ll expand on this to start swapping items in due course).

At the end of a drag operation we clear the overlay ImageView, make View within the RecyclerView visible one again, then translate this view to match the last position of the overlay ImageView, and finally animate the view back to its actual position within the RecyclerView. This means that we get a clean transition back to it’s actual position if the user has released the drag and the final position of the dragged View does not match its position within the RecyclerView.

If we actually try this we can see the following behaviour:

This is pretty smooth and we haven’t actually implemented the item swapping yet, we can see how the view being dragged follows the users finder movements, and then animates to its final resting place at the end of the drag.

Actually swapping items as the dragged item moves requires a little more work, and we’ll implement that in the next article.

I am indebted to Lucas Rocha as the source code for his excellent TwoWayView provided me with some valuable insights in to touch handling for RecyclerView.

The source code for this article is available here.

© 2014, Mark Allison. All rights reserved.

Copyright © 2014 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.