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.