Home » Android » xml – Ripple effect on top of Image – Android

xml – Ripple effect on top of Image – Android

Posted by: admin June 15, 2020 Leave a comment

Questions:

I’ve been experimenting with the ripple animation in my latest side project. I’m having some trouble finding an “elegant” solution to using it in certain situations for touch events. Namely with images, especially in list, grid, and recycle views. The animation almost always seems to animate behind the view, not the on top of it. This is a none issue in Buttons and TextViews but if you have a GridView of images, the ripple appears behind or below the actual image. Obviously this is not what I want, and while there are solutions that I consider to be a work around, I’m hoping there is something simpler i’m just unaware of.

I use the following code to achieve a custom grid view with images. I’ll give full code CLICK HERE so you can follow along if you choose.

Now just the important stuff. In order to get my image to animate on touch I need this

button_ripple.xml

<ripple
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:color="@color/cream_background">
    <item>
        <selector xmlns:android="http://schemas.android.com/apk/res/android">
            <!-- Pressed -->
            <item
                android:drawable="@color/button_selected"
                android:state_pressed="true"/>
            <!-- Selected -->
            <item
                android:drawable="@color/button_selected"
                android:state_selected="true"/>
            <!-- Focus -->
            <item
                android:drawable="@color/button_selected"
                android:state_focused="true"/>
            <!-- Default -->
            <item android:drawable="@color/transparent"/>
        </selector>
    </item>
</ripple>

custom_grid.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:gravity="center_horizontal"
              android:orientation="horizontal">

    <ImageView
        android:id="@+id/sceneGridItem"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/button_ripple"
        android:gravity="center_horizontal"/>

</LinearLayout>

activity_main.xml

<GridView
    android:id="@+id/sceneGrid"
    android:layout_marginTop="15dp"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:verticalSpacing="15dp"
    android:numColumns="5" />

The line where all magic and problems occur is when I set the background. While this does in fact give me a ripple animation on my imageview, it animates behind the imageview. I want the animation to appear on top of the image. So I tried a few different things like

Setting the entire grid background to button_ripple.

<GridView
    android:id="@+id/sceneGrid"
    android:layout_marginTop="15dp"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:verticalSpacing="15dp"
    android:background="@drawable/button_ripple"
    android:numColumns="5" />

It does exactly what you’d think, now the entire grid has a semi transparent background and no matter what image i press the entire grid animates from the center of the grid. While this is kind of cool, its not what I want.

Setting the root/parent background to button_ripple.

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
                  android:layout_width="wrap_content"
                  android:layout_height="wrap_content"
                  android:gravity="center_horizontal"
                  android:background="@drawable/button_ripple"
                  android:orientation="horizontal">

The area is now larger and fills the entire cell of the grid (not just the image), however it doesn’t bring it to the front.

Changing custom_grid.xml to a RelativeLayout and putting two ImageViews on top of each other

custom_grid.xml

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:orientation="horizontal">

    <ImageView
        android:id="@+id/gridItem"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_alignParentLeft="true" />

    <ImageView
        android:id="@+id/gridItemOverlay"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_alignParentLeft="true"
        android:background="@drawable/button_ripple" />

</RelativeLayout>

CustomGridAdapter.java

....
gridItemOverLay = (ImageView) findViewById(R.id.gridItemOverlay);
gridItemOverlay.bringToFront();

This works. Now the bottom ImageView contains my image, and the top animates, giving the illusion of a ripple animation on top of my image. Honestly though this is a work around. I feel like this is not how it was intended. So I ask you fine people, is there a better way or even a different way?

How to&Answers:

I liked android developer’s answer so I decided to investigate how to do step 2 of his solution in code.

You need to get this piece of code from Jake Wharton here : https://gist.github.com/JakeWharton/0a251d67649305d84e8a

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.ImageView;


public class ForegroundImageView extends ImageView {
  private Drawable foreground;


  public ForegroundImageView(Context context) {
    this(context, null);
  } 


  public ForegroundImageView(Context context, AttributeSet attrs) {
    super(context, attrs);


    TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ForegroundImageView);
    Drawable foreground = a.getDrawable(R.styleable.ForegroundImageView_android_foreground);
    if (foreground != null) {
      setForeground(foreground);
    } 
    a.recycle();
  } 


  /** 
   * Supply a drawable resource that is to be rendered on top of all of the child 
   * views in the frame layout. 
   * 
   * @param drawableResId The drawable resource to be drawn on top of the children. 
   */ 
  public void setForegroundResource(int drawableResId) {
    setForeground(getContext().getResources().getDrawable(drawableResId));
  } 


  /** 
   * Supply a Drawable that is to be rendered on top of all of the child 
   * views in the frame layout. 
   * 
   * @param drawable The Drawable to be drawn on top of the children. 
   */ 
  public void setForeground(Drawable drawable) {
    if (foreground == drawable) {
      return; 
    } 
    if (foreground != null) {
      foreground.setCallback(null);
      unscheduleDrawable(foreground);
    } 


    foreground = drawable;


    if (drawable != null) {
      drawable.setCallback(this);
      if (drawable.isStateful()) {
        drawable.setState(getDrawableState());
      } 
    } 
    requestLayout();
    invalidate();
  } 


  @Override protected boolean verifyDrawable(Drawable who) {
    return super.verifyDrawable(who) || who == foreground;
  } 


  @Override public void jumpDrawablesToCurrentState() { 
    super.jumpDrawablesToCurrentState(); 
    if (foreground != null) foreground.jumpToCurrentState();
  } 


  @Override protected void drawableStateChanged() { 
    super.drawableStateChanged(); 
    if (foreground != null && foreground.isStateful()) {
      foreground.setState(getDrawableState());
    } 
  } 


  @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    if (foreground != null) {
      foreground.setBounds(0, 0, getMeasuredWidth(), getMeasuredHeight());
      invalidate();
    } 
  } 


  @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    if (foreground != null) {
      foreground.setBounds(0, 0, w, h);
      invalidate();
    } 
  } 


  @Override public void draw(Canvas canvas) {
    super.draw(canvas);


    if (foreground != null) {
      foreground.draw(canvas);
    } 
  } 
} 

This is the attrs.xml:

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <declare-styleable name="ForegroundImageView">
    <attr name="android:foreground"/>
  </declare-styleable>
</resources>

Now, create your ForegroundImageView like so in your layout.xml:

<com.example.ripples.ForegroundImageView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:scaleType="centerCrop"
    android:foreground="?android:selectableItemBackground"
    android:src="@drawable/apples"
    android:id="@+id/image" />

The image will now ripple.

Answer:

Instead of trying to add the ripple to each individual view in the adapter, you can just add it at the GridView level like this:

<GridView
    android:id="@+id/gridview"

    ...

    android:drawSelectorOnTop="true"
    android:listSelector="@drawable/your_ripple_drawable"/>

Answer:

Maybe try one of these solutions (got the tip from here) :

  1. Wrap the drawable in a RippleDrawable² before setting it on the ImageView:

    Drawable image = …
    RippleDrawable rippledImage = new RippleDrawable(
        ColorStateList.valueOf(rippleColor), image, null);
    imageView.setImageDrawable(rippledImage);
    
  2. Extend ImageView and add a foreground attribute to it (like FrameLayout has³). See this example⁴ from +Chris Banes of adding it to a LinearLayout. If you do this then make sure you pass through the touch co-ordinates so that the ripple starts from the correct point:

    @Override
    public void drawableHotspotChanged(float x, float y) {
        super.drawableHotspotChanged(x, y);
        if (foreground != null) {
            foreground.setHotspot(x, y);
        }
    }
    

Answer:

I have an image gallery (built with a RecyclerView and a GridLayoutManager). The image is set using Picasso. To add a ripple to the image I’ve wrapped the ImageView with a FrameLayout. The item layout is:

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="120dp"
    android:foreground="@drawable/ripple"
    android:clickable="true"
    android:focusable="true"
    >
    <ImageView
        android:id="@+id/grid_item_imageView"
        android:layout_width="match_parent"
        android:layout_height="120dp"
        android:layout_gravity="center"
        android:scaleType="centerInside"
        />
</FrameLayout>

Note the android:foreground. It’s not the same as android:background. I’ve tried without android:clickable="true" and android:focusable="true" and it also works, but it doesn’t hurt.

Then add a ripple.xml drawable into res/drawable:

<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_pressed="true">
        <shape android:shape="rectangle">
            <solid android:color="#7FF5AC8E" />
        </shape>
    </item>
    <item>
        <shape android:shape="rectangle">
            <solid android:color="@android:color/transparent" />
        </shape>
    </item>
</selector>

Note that this shows a semi-transparent color on top of the image when the item is selected (for devices < 5.0). You can remove it if you don’t want it.

Then add the ripple.xml drawable with ripple into res/drawable-v21:

<ripple xmlns:android="http://schemas.android.com/apk/res/android"
        android:color="@color/coral">
</ripple>

You can use the default ripple effect instead of the custom ripple.xml, but it’s really difficult to see on top of an image because it’s grey:

android:foreground="?android:attr/selectableItemBackground"