28

This is a short question:

Suppose I have a View with the RippleDrawable as background.

Is there an easy way to trigger the ripple from a specific position without triggering any touch or click events?

Xaver Kapeller
  • 49,491
  • 11
  • 98
  • 86
android developer
  • 114,585
  • 152
  • 739
  • 1,270

6 Answers6

35

Yes there is! In order to trigger a ripple programatically you have to set the state of the RippleDrawable with setState(). Calling setVisible() does NOT work!


The Solution

To show the ripple you have to set the state to pressed and enabled at the same time:

rippleDrawable.setState(new int[] { android.R.attr.state_pressed, android.R.attr.state_enabled });

The ripple will be shown as long as those states are set. When you want to hide the ripple again set the state to an empty int[]:

rippleDrawable.setState(new int[] {  });

You can set the point from which the ripple emanates by calling setHotspot().


How it works

I have debugged a lot and studied the source code of RippleDrawable up and down until I realised that the ripple is actually triggered in onStateChange(). Calling setVisible() has no effect and never causes any ripple to actually appear.

The relevant part of the source code of RippleDrawable is this:

@Override
protected boolean onStateChange(int[] stateSet) {
    final boolean changed = super.onStateChange(stateSet);

    boolean enabled = false;
    boolean pressed = false;
    boolean focused = false;

    for (int state : stateSet) {
        if (state == R.attr.state_enabled) {
            enabled = true;
        }
        if (state == R.attr.state_focused) {
            focused = true;
        }
        if (state == R.attr.state_pressed) {
            pressed = true;
        }
    }

    setRippleActive(enabled && pressed);
    setBackgroundActive(focused || (enabled && pressed));

    return changed;
}

As you can see if both the enabled and pressed attribute are set then both the ripple and background will be activated and the ripple will be displayed. Additionally as long as you set the focused state the background will be activated as well. With this you can trigger the ripple and have the background change color independently.

If you are interested you can view the entire source code of RippleDrawable here.

Xaver Kapeller
  • 49,491
  • 11
  • 98
  • 86
  • Say, suppose I have a view that I've handled `OnTouchListener` on it to handle dragging, and I've identified clicking by using `GestureDetectorCompat` with `onSingleTapUp` being called on its `GestureDetector.SimpleOnGestureListener`. For some reason using any of the solutions here to trigger the ripple effect didn't work, and I tried it even as a foreground instead of background, meaning `android:foreground="?attr/actionBarItemBackground"` on the parent view (FrameLayout) . Any idea why, and how I can do it? – android developer Mar 11 '19 at 08:01
  • @androiddeveloper I don't have your scenario exactly but I had an onTouch where I wanted to trigger rippleDrawable. Calling rippleDrawable.setState(new int[] { android.R.attr.state_pressed, android.R.attr.state_enabled }); on ACTION_DOWN and calling rippleDrawable.setState(new int[] { }); on ACTION_UP worked. Also, a possibility is the ripple drawable you're using isn't using the default states and needs a different set of states to trigger it. – Trevor Jun 28 '19 at 20:59
  • Sadly it was a long time ago that I asked this. Sorry and thank you. If I go back to such a case again I might check it out again – android developer Jun 28 '19 at 22:24
24

I incorporated/combined the answers from @Xaver Kapeller and @Nikola Despotoski above:

protected void forceRippleAnimation(View view)
{
    Drawable background = view.getBackground();

    if(Build.VERSION.SDK_INT >= 21 && background instanceof RippleDrawable)
    {
        final RippleDrawable rippleDrawable = (RippleDrawable) background;

        rippleDrawable.setState(new int[]{android.R.attr.state_pressed, android.R.attr.state_enabled});

        Handler handler = new Handler();

        handler.postDelayed(new Runnable()
        {
            @Override public void run()
            {
                rippleDrawable.setState(new int[]{});
            }
        }, 200);
    }
}

To programmatically force a ripple effect on command, simply call forceRippleAnimation(), passing the View you want to ripple as a parameter.

Luke
  • 2,187
  • 1
  • 18
  • 29
18

Here is combination of Nikola's setHotSpot() and https://stackoverflow.com/a/25415471/1474113

private void forceRipple(View view, int x, int y) {
    Drawable background = view.getBackground();
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && background instanceof RippleDrawable) {
        background.setHotspot(x, y);
    }
    view.setPressed(true);
    // For a quick ripple, you can immediately set false.
    view.setPressed(false);
}
Community
  • 1
  • 1
ypresto
  • 975
  • 1
  • 13
  • 23
8

First, you need to get the drawable from the View.

private void forceRippleAnimation(View v, float x, float y){
   Drawable background = v.getBackground();
   if(background instanceof RippleDrawable){
     RippleDrawable ripple = (RippleDrawable)background;
     ripple.setHotspot(x, y);
     ripple.setVisible (true, true);
   }

}

Method setHotspot(x,y); is used to set from where the ripple animation will start, otherwise if not set, the RippleDrawable will take the Rect where it resides (i.e the Rect of the View where it is set as background) and will start the ripple effect from the center.

setVisible(true, true) will make the drawable visible and last argument will force animation regardless of the current drawable state.

Nikola Despotoski
  • 49,966
  • 15
  • 119
  • 148
  • Nice. Does the animation work like a click, or a long click (meaning the selection will stay) ? – android developer Dec 01 '14 at 22:17
  • You should try setting drawable state programmatically. Like: `int[] state = new int[]{ android.R.attr.state_pressed }` and `ripple.setState(state);`. I haven't tested if you should couple it with `setVisible(true,true)`. Because the drawable is automatically updated/animated upon state changes. – Nikola Despotoski Dec 01 '14 at 23:03
  • I don't understand. Can you please post a sample for both behaviors? – android developer Dec 01 '14 at 23:38
  • @NikolaDespotoski Calling `setVisible()` does not actually trigger a ripple. Turns out you don't need to call `setVisible()` at all. You just have to set the state to both pressed and enabled. See my answer for more details! – Xaver Kapeller May 04 '15 at 08:56
2

I used the following Kotlin variant of Luke's code to manually show and hide a ripple when swiping cells out of a RecyclerView:

fun View.showRipple() {
    if (Build.VERSION.SDK_INT >= 21 && background is RippleDrawable) {
        background.state = intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled)
    }
}

fun View.hideRipple() {
    if (Build.VERSION.SDK_INT >= 21 && background is RippleDrawable) {
        background.state = intArrayOf()
    }
}
Pontomedon
  • 937
  • 9
  • 18
  • I love this. Thank you. I added a `View.ripple()` function too to make it even easier: ```fun View.ripple(scope: CoroutineScope) { showRipple(); scope.launch(Dispatchers.Main) { delay(200); hideRipple() } }``` – binwiederhier Apr 29 '22 at 14:53
0

Thank you guys! Your answers help with my problem, but I had to build a solution from multiple answers.

My problem was with transparent RecyclerView item (header) and buttons behind it. You may see this situation on picture:

How application looks

For click on followers/liked/subscriptions buttons I had to add View.OnTouchListener for RecyclerView header:

class ProfileHeaderHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

    private val clickView: View = itemView.findViewById(R.id.profile_header_view)

    fun bind(callback: View.OnTouchListener) {
        clickView.setOnTouchListener(callback)
    }

    fun unbind() {
        clickView.setOnTouchListener(null)
    }
}

And inside Fragment simulate button click and ripple effect:

/**
 * [View.performClick] should call inside [onTouch].
 */
override fun onTouch(view: View?, ev: MotionEvent?): Boolean {
    if (ev == null) return true

    val followersContainer = followersContainer ?: return true
    val likeContainer = likeContainer ?: return true
    val subscriptionsContainer = subscriptionsContainer ?: return true
    
    when {
        ev.onView(followersContainer) -> if (followersContainer.isClick(ev)) {
            followersContainer.performClick()
        }
        ev.onView(likeContainer) -> if (likeContainer.isClick(ev)) {
            likeContainer.performClick()
        }
        ev.onView(subscriptionsContainer) -> if (subscriptionsContainer.isClick(ev)) {
            subscriptionsContainer.performClick()
        }
    }

    return true
}

/**
 * [MotionEvent.ACTION_UP] - user make click (remove ripple)
 * [MotionEvent.ACTION_DOWN] - user start click (show ripple)
 * [MotionEvent.ACTION_CANCEL] - user move touch outside of [View] (remove ripple)
 */
private fun View.isClick(ev: MotionEvent): Boolean {        
    when (ev.action) {
        MotionEvent.ACTION_UP -> {
            changeRippleState(ev, isTouch = false)
            return true
        }
        MotionEvent.ACTION_DOWN -> changeRippleState(ev, isTouch = true)
        MotionEvent.ACTION_CANCEL -> changeRippleState(ev, isTouch = false)
    }
    
    return false
}

/**
 * [this] show have android:background with ripple drawable (?attr/selectableItemBackground).
 */
private fun View.changeRippleState(ev: MotionEvent, isTouch: Boolean) {
    val rippleDrawable = background as? RippleDrawable ?: return

    /**
     * For emulate click position.
     */
    rippleDrawable.setHotspot(ev.rawX, ev.rawY)
    isPressed = isTouch
}

I check another way to set press state for RippleDrawable and it's also works:

rippleDrawable.state = if (isTouch) {
    intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled)
} else {
    intArrayOf()
}

Extension for check MotionEvent happen inside View or outside:

fun MotionEvent?.onView(view: View?): Boolean {
    if (view == null || this == null) return false

    return Rect().apply {
        view.getGlobalVisibleRect(this)
    }.contains(rawX.toInt(), rawY.toInt())
}
SerjantArbuz
  • 982
  • 1
  • 12
  • 16