Ripple effect Android

Sometimes you just want it to ripple - maybe when you press a button, maybe when you’re inputing speech, in a game, or what not. And sometimes you just want a copy-and-paste solution to get that ripple start spreading. That’s the intention of this blog post.

What we’ll need

This post will build upon a previous voice to text post, just to have a use-case to apply the ripple effect on.

We will need three different files

  1. CircleView - Used internally by RippleView to draw the circle/ripple.
  2. RippleView - Our main class that will be use in our layout files. It extends RelativeLayout and adds knowledge about ripples.
  3. attrs.xml - To set e.g. color, stroke-width and radius of the ripple.

Code dump

First of all we need a circle - that's the job of the CircleView as detailed below

class CircleView(
    context: Context?,
    attrs: AttributeSet?,
    colour: Int,
    rippleType: Int,
    private val rippleStrokeWidth: Float
) : View(context, attrs) {

    private val paint = Paint()

    init {
        if (context == null) throw IllegalArgumentException("Context is null.")
        if (attrs == null) throw IllegalArgumentException("Attribute set is null.")

        visibility = View.INVISIBLE

        paint.apply {
            isAntiAlias = true
            color = colour
            style = when (rippleType) {
                FILL.type -> {
                    strokeWidth = 0f
                    Paint.Style.FILL
                }
                STROKE.type -> {
                    strokeWidth = rippleStrokeWidth
                    Paint.Style.STROKE
                }
                FILL_AND_STROKE.type -> {
                    strokeWidth = rippleStrokeWidth
                    Paint.Style.FILL_AND_STROKE
                }
                else -> throw IllegalArgumentException("Unknown fill style: $rippleType.")
            }
        }
    }

    override fun onDraw(canvas: Canvas?) {
        val radius: Float = Math.min(width, height) / 2.toFloat()
        canvas?.drawCircle(radius, radius, radius - rippleStrokeWidth, paint)
    }
}

Now we need a class that animate circles on demand. For that we have RippleView. It will animate the scale in X and Y, as well as the alpha for extra effect. It also parses the styled attributes allowing for more styling control to the user (more on this below).

class RippleView(context: Context?, private val attrs: AttributeSet?) : RelativeLayout(context, attrs) {

    private val DEFAULT_RIPPLE_SCALE = 4f
    private val DEFAULT_FILL_TYPE = FillStyle.FILL.type
    private val MIN_RIPPLE_DURATION = 400
    private val MAX_RIPPLE_DURATION = 1000
    private val NO_RIPPLE_DURATION = -1

    companion object {
        /**
        * This enum is tightly coupled with attrs#RippleView. This should be redesign if possible.
        */
        enum class FillStyle(val type: Int) {
            FILL(type = 0), STROKE(type = 1), FILL_AND_STROKE(type = 2)
        }
    }

    private var rippleScale: Float = DEFAULT_RIPPLE_SCALE
    private var rippleColor: Int = 0
    private var rippleType: Int = 0
    private var rippleStrokeWidth: Float = 0f
    private var rippleRadius: Float = 0f
    private var duration: Int = NO_RIPPLE_DURATION

    init {
        if (context == null) throw IllegalArgumentException("Context is null.")
        if (attrs == null) throw IllegalArgumentException("Attribute set is null.")

        val styledAttributes = context.obtainStyledAttributes(attrs, R.styleable.RippleView)
        rippleRadius = styledAttributes.getDimension(R.styleable.RippleView_rv_radius, resources.getDimension(R.dimen.rippleRadius))
        rippleStrokeWidth = styledAttributes.getDimension(R.styleable.RippleView_rv_strokeWidth, resources.getDimension(R.dimen.rippleStrokeWidth))
        rippleColor = styledAttributes.getColor(R.styleable.RippleView_rv_color, ContextCompat.getColor(context, R.color.colorAccent))
        rippleType = styledAttributes.getInt(R.styleable.RippleView_rv_type, DEFAULT_FILL_TYPE)
        rippleScale = styledAttributes.getFloat(R.styleable.RippleView_rv_scale, DEFAULT_RIPPLE_SCALE)
        duration = styledAttributes.getInteger(R.styleable.RippleView_rv_duration, NO_RIPPLE_DURATION)
        styledAttributes.recycle()
    }

    /**
    * Call this class to initiate a new ripple animation.
    */
    fun newRipple() {
        val circleView = CircleView(context, attrs, rippleColor, rippleType, rippleStrokeWidth).apply {
            visibility = View.VISIBLE
        }

        addView(circleView, getCircleViewLayoutParams())

        val animationDuration = if (duration == NO_RIPPLE_DURATION) {
            randomAnimationDuration()
        } else duration

        generateRipple(duration = animationDuration, target = circleView).apply {
            start()
        }
    }

    private fun generateRipple(duration: Int, target: CircleView): AnimatorSet {
        val animatorList = ArrayList<Animator>()

        animatorList.add(provideAnimator(
            target = target,
            type = View.SCALE_X,
            animDuration = duration,
            scale = rippleScale
        ))

        animatorList.add(provideAnimator(
            target = target,
            type = View.SCALE_Y,
            animDuration = duration,
            scale = rippleScale
        ))

        animatorList.add(provideAnimator(
            target = target,
            type = View.ALPHA,
            animDuration = duration
        ))

        return AnimatorSet().apply {
            playTogether(animatorList)
        }
    }

    private fun provideAnimator(
        target: View,
        type: Property<View, Float>,
        animDuration: Int,
        scale: Float = DEFAULT_RIPPLE_SCALE
    ): ObjectAnimator {
        val scaleAmount = if (type == View.ALPHA) 0f else scale

        return ObjectAnimator.ofFloat(target, type, scaleAmount).apply {
            duration = animDuration.toLong()
            addListener(object : Animator.AnimatorListener {
                override fun onAnimationEnd(animation: Animator) {
                    removeView(target)
                }
                override fun onAnimationStart(animation: Animator) {}
                override fun onAnimationRepeat(animation: Animator) {}
                override fun onAnimationCancel(animation: Animator) {}
            })
        }
    }

    private fun getCircleViewLayoutParams(): LayoutParams {
        val widthHeight = (2 * rippleRadius + rippleStrokeWidth).toInt()
        return RelativeLayout.LayoutParams(widthHeight, widthHeight).apply {
            addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE)
        }
    }

    private fun randomAnimationDuration() = ThreadLocalRandom.current().nextInt(MIN_RIPPLE_DURATION, MAX_RIPPLE_DURATION)
}

The last piece of the puzzle is to define the possible attributes. Add the following to your attrs.xml file

<declare-styleable name="RippleView">
    <attr name="rv_color" format="color"/>
    <attr name="rv_strokeWidth" format="dimension"/>
    <attr name="rv_radius" format="dimension"/>
    <attr name="rv_scale" format="float"/>
    <attr name="rv_duration" format="integer"/>
    <attr name="rv_type" format="enum">
        <enum name="fill" value="0"/>
        <enum name="stroke" value="1"/>
        <enum name="fill_and_stroke" value="2"/>
    </attr>
</declare-styleable>

We allow for change in colour, size and fill type. We also allow for a duration though we wont use it in the example below. If duration is left out, a random duration will be used per ripple.

Usage

We use the view as any other view

<com.varvet.voicetotextarch.RippleView
    android:layout_width="130dp"
    android:layout_height="130dp"
    app:rv_color="@color/colorPrimary"
    app:rv_radius="15dp"
    app:rv_scale="4"
    app:rv_type="stroke">

    <Button
        android:id="@+id/mic_button"
        android:layout_width="@dimen/mic_button_width"
        android:layout_height="@dimen/mic_button_height"
        android:layout_centerInParent="true"
        android:background="@drawable/mic_black"/>
</com.varvet.voicetotextarch.RippleView>

Here we wrap the ripple around a Button, which in our demo below will represent the mic icon. This will allow for ripples to radiate out from the mic like sound waves!

Demo

The voice to text feature work great as an example application. The RecognitionListener interface triggers on dB changes - using this we can create the illusion that our speech radiates in sound waves.

Here we don't set a duration on the animation, we just let the RippleView randomise a duration (preferably the duration in this case would be strongly dependent on the dB value. But those values differs on different devices and depending on how loud you're speaking, hence the more general approach taken here).

The result may look something like this