Ripples from a Touch Point

Google has started to point out to app developers when they use ripple animations in Material Design-styled UIs, where those ripples do not seem to emanate from the touch point that triggered the animation.

Using Google’s search engine, the only thing that I found that seemed to explain how to make this work was a comment on an issue on Lucas Rocha’s TwoWayView library. Fortunately, that comment was enough for me to create something that seems to work.

As Mr. Butcher hints in his tweet, the key appears to be the setHotspot() method on Drawable. Added in API Level 21, this teaches the drawable a “hot spot”, and RippleDrawable apparently uses this as the emanation point for the ripple effect. setHotspot() take a pair of float values, presumably with an eye towards using setHotspot() inside of an OnTouchListener, as the MotionEvent reports X/Y positions of the touch event with float values.

This sample app demonstrates the use of setHotspot() in the context of a RecyclerView, as part of a 50+ page chapter that I am working on for the next book update. This RecyclerView is using a LinearLayoutManager, to replicate the basic structure of a classic ListView. My rows are based on a CardView:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:cardview="http://schemas.android.com/apk/res-auto"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:layout_margin="4dp"
  cardview:cardCornerRadius="4dp">

  <LinearLayout
    android:id="@+id/row_content"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:background="?android:attr/selectableItemBackground">

    <!-- other widgets in here -->

    </LinearLayout>

  </LinearLayout>

</android.support.v7.widget.CardView>

The LinearLayout that the CardView wraps has ?android:attr/selectableItemBackground as its background, pulling in a theme attribute. If this app runs on API Level 21, that background will be a RippleDrawable.

When I set up the row, I attach an OnTouchListener to set the hotspot, but only on API Level 21+ devices (as setHotspot() does not exist before then):

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
  row.setOnTouchListener(new View.OnTouchListener() {
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    @Override
    public boolean onTouch(View v, MotionEvent event) {
      v
        .findViewById(R.id.row_content)
        .getBackground()
        .setHotspot(event.getX(), event.getY());

      return(false);
    }
  });
}

So, if the user touches a row, I propagate the touch point to the background as its hotspot. I specifically return false to indicate that we are not consuming the touch event, so it will continue on to the rest of the event handlers, if needed.

Without this setHotspot() call, the RippleDrawable seems to default to the drawable’s center. So, in the case of a row for a list-style RecyclerView, the ripple effect would emanate from the center of the row. setHotspot(), tied to the touch event, changes the emanation point.

This seems to work. I have no idea if it is the official right answer, insofar as I have not seen any code from Google for how to implement this (e.g., a search of the SDK samples for setHotspot() turns up nothing). In particular, calling setHotspot() directly on the background worries me (should we be calling mutate() first?). So, imagine a grain of salt, measuring approximately 15cm on a side, and take that grain of salt with this implementation.