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
CircleView
- Used internally byRippleView
to draw the circle/ripple.RippleView
- Our main class that will be use in our layout files. It extends RelativeLayout and adds knowledge about ripples.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
Comments