One, foreword
After Android 5.0, with the introduction of Material Design, Android UI Design language is a big step forward, but it has not been widely promoted and applied in China.
First, to Design an App that completely follows Material Design, UI designers need to spend more time, and developers also need to spend more time to achieve the development, and the domestic environment is known to all.
Second, Material Design has a lot of transition animations and cool effects, which inevitably leads to some performance loss.
Third, although there has been a great improvement in App use experience in China, it is still not as important as foreign countries.
However, even if we can’t apply Material Design on a large scale, we can achieve some effects in some special places. After all, we still have dreams.
This article water ripple control source: portal (Java version and Kotlin have oh, welcome to enjoy, sweet words to a Star 🧡)
Two, the composition of water ripple control
Under normal circumstances, in the implementation of a click -> select, the simplest and crude way is to click on the control directly to change the background color/background image, but this effect is often very stiff, and the user does not have a good interaction process.
Material Design provides a good guide, such as the control has a Z-axis lift when clicked, control background color according to the finger click position appears a transition effect.
For example, today we will introduce the water ripple selection effect.
Once you’ve done this, you’ll find that the whole experience of clicking on a selection is vastly improved, and it can feel very smooth, and if the experience is good enough, it can even become addictive, as you involuntarily click back and forth between different buttons to experience the comfortable transition.
Primitive water ripples
We know that after Android 5.0, it is easy to create ripples by simply configuring ripple’s drawable. However, the ripple effect of the system is only a short click response process, and eventually the ripple disappears.
If you want water ripples to spread and hold, such as a water ripple selection effect, you can’t do that.
The original water ripple effect will not say, I believe that everyone will. Let’s take a look at how to customize the View to achieve a water ripple selected effect.
Steps to customize the water ripple selected control
Take a closer look at the click-and-select process, which can be broken down into the following processes:
- Gets the position coordinates of the click
- Using the click position as the origin, draw concentric circles with increasing radius
- Enhance controls
The z axis
, is essentially drawing shadows - Control rounded corner clipping
Three, to achieve the effect of water ripple selection
What tools are needed
To get started, take a look at the tools needed for the entire customization process:
- Inherited from FrameLayout or View
- Paint: Paint tool
- Scroller: Realize water ripple diffusion or contraction animation
- Path or RectF is used to set the clipping range
- PorterDuffXfermode: Color blending cropping tool
All of these are tools that are often used in custom views.
Inherited from FrameLayout
The reason I chose FrameLayout as the base ViewGroup is that if it inherits from a View, the control can only have the water ripple effect on its own. If it’s a ViewGroup, it can wrap other views around it to achieve the overall click effect. It’s like a native CardView.
class RippleLayoutKtl: FrameLayout {
/ /...
constructor(context: Context) : super(context) {
init(context, null)}constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
init(context, attrs)
}
constructor(context: Context, attrs: AttributeSet? , defStyleAttr:Int) :
super(context, attrs, defStyleAttr) {
init(context, attrs)
}
private fun init(context: Context, attrs: AttributeSet?). {
// Initialize the Scroller
scroller = Scroller(context, DecelerateInterpolator(3f))
// Initialize the water ripple brush
ripplePaint.color = rippleColor
ripplePaint.style = Paint.Style.FILL
ripplePaint.isAntiAlias = true
// Initialize the normal background color brush
normalPaint.color = normalColor
normalPaint.style = Paint.Style.FILL
normalPaint.isAntiAlias = true
// Initialize the shadow brush
shadowPaint.color = Color.TRANSPARENT
shadowPaint.style = Paint.Style.FILL
shadowPaint.isAntiAlias = true
// Set the shadow. If color is opaque, the opacity is determined by the alpha of shadowPaint
shadowPaint.setShadowLayer(shadowSpace/5f*4f, 0f, 0f, shadowColor)
// Set pandding to leave space for drawing shadows
setPadding((shadowSpace + paddingLeft).toInt(), (shadowSpace + paddingTop).toInt(),
(shadowSpace + paddingRight).toInt(), (shadowSpace + paddingBottom).toInt())
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_DOWN) {
center.x = event.x
center.y = event.y
if (state == 0) {
state = 1
expandRipple()
} else {
state = 0
shrinkRipple()
}
}
return super.onTouchEvent(event)
}
// Diffuse water ripples
private fun expandRipple(a) {
drawing = true
longestRadius = getLongestRadius()
scroller.startScroll(0.0, ceil(longestRadius).toInt(), 0.1200)
invalidate()
}
// Shrink the water ripple
private fun shrinkRipple(a) {
scroller.forceFinished(false)
longestRadius = curRadius
scroller.startScroll(curRadius.toInt(), 0, -curRadius.toInt(), 0.800)
drawing = true
invalidate()
}
// Calculate the longest radius of water ripple
private fun getLongestRadius(a) : Float {
return if (center.x > width / 2f) {
// Calculate the distance between the touch points and the two left vertices
val leftTop = sqrt(center.x.pow(2f) + center.y.pow(2f))
val leftBottom = sqrt(center.x.pow(2f) + (height - center.y).pow(2f))
if (leftTop > leftBottom) leftTop else leftBottom
} else {
// Calculate the distance between the touch points and the right two vertices
val rightTop = sqrt((width - center.x).pow(2f) + center.y.pow(2f))
val rightBottom = sqrt((width - center.x).pow(2f) + (height - center.y).pow(2f))
if (rightTop > rightBottom) rightTop else rightBottom
}.toFloat()
}
/ /...
}
Copy the code
In the init method, we initialize parameters such as the water ripple brush, background color brush, shadow brush, setting the padding, etc. More on shadow and padding later.
Get click to calculate the longest radius of water ripple
- Record the coordinate center of the water ripple
In the above code, the onTouchEvent is overwritten, and when the press event is received, it starts to expand the water wave or shrink the water ripple, and records the position of the finger press, which is the center of the water ripple, recorded as center.x center.y.
- Calculate the longest radius of water ripple
Watch a simple GIF animation
Take the center of the control as an example, where concentric circles expand to cover the entire control. We know that when the concentric circles are drawn, the part that is outside the control is automatically truncated, so the final result looks like this
To override the entire control,
The longest radius of the concentric circle is equal to the longest of the four distances between the touch point and the control’s four vertices, and the size of the radius can be calculated by using the Pythagorean theorem.
Here, touch points are classified as left and right of the control, as follows:
So, using the Pythagorean theorem, you compute R1 and R2, and then you take the larger one, and that’s the longest radius we want.
See getLongestRadius method above for specific calculation.
Trigger water ripple draw animation
Let’s first look at the method that triggers water ripple diffusion:
class RippleLayoutKtl: FrameLayout {
// ......
private fun expandRipple() {
drawing = true
longestRadius = getLongestRadius()
scroller.startScroll(0, 0, ceil(longestRadius).toInt(), 0, 1200)
invalidate()
}
// ......
}
Copy the code
In this method, the longest radius is obtained and saved using getLongestRadius using the calculation method described above.
Then start an animation with the scroll #startScroll method.
There are many ways to implement animation, such as ValueAnimator, Handler timing, and even threading, but a better way to customize a View is to use Scroller, which combines the View’s own drawing process to implement the animation process.
- Open animation
The typical way to use Scroller is to use Scroller #startScroll to smooth the View position, for example
// Method prototype
//startScroll(int startX, int startY, int dx, int dy, int duration)
// Move from 0, 0 to 100, 0
scroller.startScroll(0.0.100.0.1200)
Copy the code
We don’t need to move the View here, but we can use Scroller’s features to animate it indirectly. For example, here we are
scroller.startScroll(0.0, ceil(longestRadius).toInt(), 0.1200)
Copy the code
With the help ofx
The change in radius is converted to radiusr
The change of thex
As ar
Use. (Of course, you can use it tooy
Related parameters), so that you can get from0
到 longestRadius
Increasing concentric circle radius.
- Realization of animation
Start animation with scroll. startScroll, but if this is the only method, animation will not work, because it has to be combined with the View drawing process.
After startScroll, the invalidate() method is called, which, as we know, triggers the View’s draw process.
During DRAW, a method computeScroll inside the View is called. This method is the key to starting the animation, so we’ll override this method to get the progress of the current animation, which is the radius of the currently drawn concentric circles.
class RippleLayoutKtl: FrameLayout {
/ /...
override fun computeScroll(a) {
if (scroller.computeScrollOffset()) {
updateChangingArgs()
} else {
stopChanging()
}
}
private fun updateChangingArgs(a) {
curRadius = scroller.currX.toFloat()
var tmp = (curRadius / longestRadius * 255).toInt()
if (state == 0) {// Make the transition more natural
tmp -= 60
}
if (tmp < 0) tmp = 0
if (tmp > 255) tmp = 255
ripplePaint.alpha = tmp
shadowPaint.alpha = tmp
invalidate()
}
private fun stopChanging(a) {
drawing = false
center.x = width.toFloat() / 2
center.y = height.toFloat() / 2
}
/ /...
Copy the code
Via scroller.com in computeScroll puteScrollOffset (), this method can calculate the current animation executive position, and then return should continue to perform the animation.
Check whether the scroller has completed and return true to indicate that the animation has not completed. Enter updateChangingArgs to update the animation parameters:
// Get the drawing radius of the current water ripple concentric circle
curRadius = scroller.currX.toFloat()
// Calculate the semi-permeable value of water ripple, gradually rising, more natural transition
var tmp = (curRadius / longestRadius * 255).toInt()
Copy the code
At the end of the updateChangingArgs, invalidate is called again, which implements an endless loop of refresh
That is:
invalidate->draw(onDraw/dispatchDraw)->computeScroll->invalidate
Copy the code
If scroller.com puteScrollOffset () returns false, the end of the animation (no longer call invalidate method).
- Draw water ripples
With the animation parameters in place, all that’s left is to draw. There are two options, one is to draw in the onDraw method or the other is to draw in dispatchDraw.
If onDraw is selected, the constructor should call this method setWillNotDraw(false), otherwise the ViewGroup will not call onDraw without the background color.
Select dispatchDraw here.
class RippleLayoutKtl: FrameLayout {
/ /...
override fun dispatchDraw(canvas: Canvas) {
// Draw the default background color
canvas.drawPath(clipPath, normalPaint)
// Draw water ripples
canvas.drawCircle(center.x, center.y, curRadius, ripplePaint)
// Draw a subview
super.dispatchDraw(canvas)
}
/ /...
}
Copy the code
Drawing is actually very simple, that is, before drawing the child View, draw the background color and water ripples on it.
Rounded corners and shadows
If the realization of water ripple, as long as the above code can be. However, this effect is not delicate enough, we will give the control to achieve rounded corner clipping and shadow effect.
The rounded cutting
There are two ways to implement clipping in Android custom Views:
- ClipXXX method:
clipRect
或clipPath
Etc, specify the clipping range - PorterDuffXfermode Color blending cropping method: by setting different
PorterDuff
Hybrid mode enables rich clipping styles.
However, when clipXXX is used, the edges will be jagged if there are rounded corners, so the second method is used here.
PorterDuffXfermode color blending mode
As you can see, with different modes, you can control the bottom DST and top SRC layers to create different rendering effects.
SRC_ATOP is used in this paper, that is, the upper color is displayed at the intersection of SRC and DST, and all other positions are not drawn.
class RippleLayoutKtl: FrameLayout {
/ /...
// Clipping mode
private val xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
override fun dispatchDraw(canvas: Canvas) {
// [1.1] Create a new layer
val layerId = canvas.saveLayer(shadowRect, null, ALL_SAVE_FLAG)
// Draw the default background color
canvas.drawPath(clipPath, normalPaint)
// [2.1] Set clipping mode
ripplePaint.xfermode = xfermode
// Draw water ripples
canvas.drawCircle(center.x, center.y, curRadius, ripplePaint)
// [2.2] Cancel clipping mode
ripplePaint.xfermode = null
// [1.2] Draw the layer onto the canvas
canvas.restoreToCount(layerId)
// Draw a subview
super.dispatchDraw(canvas)
}
/ /...
}
Copy the code
Here are four new lines of code, one in pairs
- [1.1] – [1.2] : Create a new draw layer
What does it do?
By default, there is only one layer on the system canvas, that is, all drawings are applied directly to this layer. If you want a clean layer to draw something on, or to achieve some effect, you can create a new transparent layer using the Canvas. saveLayer method, and then render on the new layer. Draw onto the default layer provided by the system.
Why do we use this method here?
In PorterDuffXfermode, it should be possible to mix the colors without creating a new layer. The experiment found that normal clipping could not be achieved if the system default layer was used.
The author of this article also encountered the same problem. After his experiment, he found:
The SRC layer in the PorterDuffXfermode color blend is the non-transparent pixels in the entire canvas before xfermode is set.
In other words, the entire canvas of the default layer is colored. After mixing with DST, if the blending mode is SET to SRC_ATOP, the entire DST will still be displayed and the cliping effect cannot be achieved.
Others say it’s because SRC and DST are bitmaps, like this article.
This paper has verified the first one and found that it is consistent. The second one has not been tried. Those who are interested can try it.
DrawPath (clipPath, normalPaint) is a rectangle with rounded corners. The xfermode is set to SRC_ATOP. Where the concentric circles and rounded rectangles meet, the color of the water ripples will be displayed. The rest of the transparent areas will not be displayed.
Note: clipPath is set in the onSizeChanged method, as explained later.
- [2.1] – [2.2] : Set the color mixing mode
These two sentences correspond to set and uncrop mode.
First draw bottom SRC (rounded rectangle), then set xferMode for the water ripple brush, then draw DST (water ripple), and finally unmix mode.
In this way, a rounded water ripple is achieved.
Draw the shadow
class RippleLayoutKtl: FrameLayout {
/ /...
// Mix cropping mode
private val xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
override fun dispatchDraw(canvas: Canvas) {
[1] Enable software rendering mode
setLayerType(View.LAYER_TYPE_SOFTWARE, null)
// [2] Draw a shadow
canvas.drawRoundRect(shadowRect, radius, radius, shadowPaint)
// Set the mixed cropping mode
val layerId = canvas.saveLayer(shadowRect, null, ALL_SAVE_FLAG)
// Draw the default background color
canvas.drawPath(clipPath, normalPaint)
// Set clipping mode
ripplePaint.xfermode = xfermode
// Draw water ripples
canvas.drawCircle(center.x, center.y, curRadius, ripplePaint)
// Unclipping mode
ripplePaint.xfermode = null
// Draw the canvas onto the canvas
canvas.restoreToCount(layerId)
// Draw a subview
super.dispatchDraw(canvas)
}
/ /...
}
Copy the code
Drawing shadows and is very simple, and can be done in two lines of code:
- Enable software rendering mode. The system starts hardware rendering mode by default. If software rendering is not enabled, shadows cannot be drawn.
canvas.drawRoundRect
Draw a rectangle.
Why, you may wonder, is it possible to create a shadow by drawing a rounded rectangle?
Remember setting the shadow brush and setting the padding from the init method? Look at the code again:
private fun init(context: Context, attrs: AttributeSet?). {
/ /...
shadowPaint.color = Color.TRANSPARENT
shadowPaint.style = Paint.Style.FILL
shadowPaint.isAntiAlias = true
// Set the shadow. If color is opaque, the opacity is determined by the alpha of shadowPaint
shadowPaint.setShadowLayer(shadowSpace/5f*4f, 0f, 0f, shadowColor)
setPadding((shadowSpace + paddingLeft).toInt(), (shadowSpace + paddingTop).toInt(),
(shadowSpace + paddingRight).toInt(), (shadowSpace + paddingBottom).toInt())
}
Copy the code
- Set the shadow
There are two ways:
- Paint.setShadowLayer
/** * radius: the radius of the shadow, that is, the distance beyond the rectangle after drawing a rounded corner * dx/dy: the offset distance of the shadow * shadowColor: the color of the shadow. When color is opaque, the opacity is determined by shadowPaint's alpha, otherwise it is determined by shadowColor. */ public voidsetShadowLayer(float radius, float dx, float dy, int shadowColor)
Copy the code
- Paint.setMaskFilter
Paint.setMaskFilter(BlurMaskFilter(float radius, Blur style))
Copy the code
The first method is flexible and allows you to set more parameters. The main point is that the shadow color is independent of the Paint color. So take the first approach.
shadowPaint.setShadowLayer(shadowSpace/5f*4f, 0f, 0f, shadowColor)
Copy the code
Here, the radiation range of the shadow is set slightly smaller than the reserved shadowSpace, so that the shadow effect is more natural and no obvious boundary will appear.
- Set the shadow range
At initialization, the padding of the control is set to leave enough distance for the shadow to be drawn
setPadding((shadowSpace + paddingLeft).toInt(), (shadowSpace + paddingTop).toInt(),
(shadowSpace + paddingRight).toInt(), (shadowSpace + paddingBottom).toInt())
Copy the code
As you can see, shadowSpace is added to the padding of the control to control the display range of the child View and the display range of the shadow.
Finally, look at the shadow drawing range and rounded rectangle clipping range.
- Set the shadow range and rounded rectangle range
class RippleLayoutKtl: FrameLayout {
/ /...
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
shadowRect.set(shadowSpace, shadowSpace, w - shadowSpace, h - shadowSpace)
clipPath.addRoundRect(shadowRect, radius, radius , Path.Direction.CW)
}
/ /...
Copy the code
Set the shadow shadowRect and clipPath parameters when listening for control size changes. Then use it in dispatchDraw.
A brief description of the process of shrinking water ripples:
When the user clicks the control again after the water ripple has expanded or is in the process of spreading, the water ripple needs to be shrunk back.
class RippleSelectFrameLayoutKtl: FrameLayout {
/ /...
private fun shrinkRipple(a) {
scroller.forceFinished(false)
longestRadius = curRadius
scroller.startScroll(curRadius.toInt(), 0, -curRadius.toInt(), 0.800)
drawing = true
invalidate()
}
/ /...
}
Copy the code
First call scroll.forceFinished (false) to stop the current animation, then set the current water ripple radius as the maximum radius to Scroller with a -curradius range, that is, the radius gets smaller and smaller during the animation until it reaches 0.
In this way, the ripples of water shrink back.
Five, the ending
Finally, some finishing touches:
- Add XML configurable attributes such as water ripple color, shadow size, shadow color, rounded corner size, etc
- Add a state callback to pass the current state of the water ripple
- .
No longer details, details please see the source code (Java version and Kotlin have oh, welcome to enjoy, sweet words to a Star 🧡)
As a front-end developer, often want to give users a better experience, but the reality, but in any case, in the case of possible, or to seek some experience and demand balance, at least in some corner of the App, users in the use a function of time, suddenly will feel comfortable enough.