preface

Previous article: Fake wechat sliding button

This article is the second custom View practice, the last article realized a simple sliding button, know the basic steps of some custom View, this article is the use of Bezier curve to achieve a loading control, then enter the text.

Address: WaveLoadingView

rendering

As you can see, WaveLoadingView can also be used to display scenes of progress in addition to loading.

implementation

In the renderings, the wave is the form of the curve, so we need to find a way to draw the curve, in the field of mathematics, there are many kinds of functions used to achieve the curve, but in the development, the more commonly used is the sine curve and Bessel curve, the following is a brief introduction:

1. Sine curve

The sine curve is a very familiar curve, and its function is as follows:

Y = Asin(ωx + φ) + h

A represents the amplitude, which represents the distance between the peak and trough of the curve;

ω represents angular velocity and is used to control the period of the sinusoidal curve;

φ represents the initial phase and is used to control the left and right movement of the sinusoidal curve;

H represents the offset and is used to control the upward and downward movement of the curve.

When A, ω, h take certain values and φ take different values, the curve can be moved in the horizontal direction, as follows:

Above is the ever-changing sine curve of A = 2, ω = 0.8, h = 0, φ.

2. Bezier curve

Bezier curves have first order, second order,… The bezier curve of order N can be derived from the Bezier curve of order (n-1). The derivation of Bezier curve can be read in depth to understand the Bezier curve.

Here I use a second-order Bezier curve, whose function is as follows:

f(t) = (1- t)^2 * P0 + 2t(1- t)P1 + t^2 * P2 (0<= t <= 1)

P0, P1, and P2 are all known points, called control points. T is a variable, ranging from 0 to 1, and the value of the function changes as T changes.

P0 = (x0, y0) = (-20, 0), P1 = (x1, y1) = (-10, 20), P2 = (x2, y2) = (0, 0), and then substitute the values of these three points into the second-order Bezier curve function, forming the following curve:

FIG. 1

So we draw a curve (the two lines are for auxiliary purposes), then we continue to take P3 = (x3, y3) = (10, -20), P4 = (x4, y4) = (20, 0), and then substitute P2, P3, P4 into the second-order Bessel curve function again, forming the following curve:

Figure 2

So this is a little bit of a sinusoidal curve, as long as we keep taking the control points, and keep putting in the second-order Bezier function, we can form a periodic curve, and here we also find that the second-order Bezier function is not a periodic function, so it’s not as continuous as the forward curve, A second-order Bezier curve function can draw only one curve through three control points at a time.

3. How to choose?

We also found the bezier curve relative to the realization of the sine curve is a bit complicated, but in Android, bezier curve has a wrapper API for our use, use rise very simple, don’t need us to use the code to achieve the function, opposite sine curve requires us starting from scratch, to use the code to achieve sine function, There is also a lot of calculation, scope checking, etc., so from the point of view of the complexity of use, the workload of choosing Bezier curve is a little less.

In Android, bezier curves are implemented via Path, where the functions associated with second-order Bezier curves are:

path.quadTo(x1, y1, x2, y2)// Absolute coordinates
path.rQuadTo(x1, y1, x2, y2)// Relative coordinates
Copy the code

Paste the Bezier curve of Figure 1 again:

FIG. 1

Assuming that the coordinate system refers to the xy axis of Figure 1, that is, the x axis is to the right and the y axis is up, and the origin is (0, 0), the curve of the figure above can be drawn by using the following code, as follows:

var path = Path()
path.moveTo(- 20f, 0f)//(x0, y0) = (-20, 0)
path.quadTo(
    - 10f, 20f, //(x1, y1) = (-10, 20)
    0f, 0f     //(x2, y2) = (0, 0)
)

// The above is absolute coordinates. The following code is drawn in relative coordinates

var path = Path()
path.moveTo(- 20f, 0f)//(x0, y0) = (-20, 0)
path.rQuadTo(
    10f, 20f,   //(x1, y1) is (10, 20) relative to (x0, y0)
    20f, 0f     //(x2, y2) is (20, 0) relative to (x0, y0)
)
Copy the code

If you want to draw the Bezier curve of Figure 2, you simply add quadTo or rQuadTo to the previous curve, as follows:

var path = Path()
path.moveTo(- 20f, 0f)//(x0, y0) = (-20, 0)
path.quadTo(
    - 10f, 20f, //(x1, y1) = (-10, 20)
    0f, 0f     //(x2, y2) = (0, 0)
)
path.quadTo(
    10f, - 20f,  //(x3, y3) = (10, -20)
    20f, 0f    //(x4, y4) = (20, 0)
)


// The above is absolute coordinates. The following code is drawn in relative coordinates

var path = Path()
path.moveTo(- 20f, 0f)//(x0, y0) = (-20, 0)
path.rQuadTo(
    10f, 20f,   //(x1, y1) is (10, 20) relative to (x0, y0)
    20f, 0f     //(x2, y2) is (20, 0) relative to (x0, y0)
)
path.rQuadTo(
    10f, - 20f,  //(x3, y3) is (10, -20) relative to (x2, y2)
    20f, 0f     //(x4, y4) is (20, 0) relative to (x2, y2)
)
Copy the code

Each point in absolute coordinates is referred to the origin of the coordinate system; If the moveTo method is called once and the rQuadTo method is called multiple times, then the second rQuadTo method refers to the last coordinate value of the previous rQuadTo method. For example, in the calculation of the relative coordinates above, The (x3, y3), (x4, y4) of the second rQuadTo method is computed by reference to (x2, y2), not by reference to (x0, y0).

Just for the sake of illustration, the frame is going to be x to the right, y to the up, but in Android, the frame is x to the right, y to the down, and the origin is the top left corner of the View, so notice that.

Implementation steps

Let’s start with the main implementation steps:

1, measure the size of the control

I use a Shape enumeration to represent the four shapes of the control as follows:

enum class Shape{
    CIRCLE,// Circle, default shape
    SQUARE, / / square
    RECT, / / rectangle
    NONE// No shape constraints
}
Copy the code

For circles and squares, the measurement width and height of the control should remain the same, while for rectangles and NONE, the measurement width and height of the control can be different, as follows:

 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
     val measureWidth = MeasureSpec.getSize(widthMeasureSpec)
     val measureHeight = MeasureSpec.getSize(heightMeasureSpec)
     when(shape){
         Shape.CIRCLE, Shape.SQUARE -> {// Circle or square
             val measureSpec = if(measureHeight < measureWidth) heightMeasureSpec else widthMeasureSpec
             // The measureSpec passed is the same
             super.onMeasure(measureSpec, measureSpec)
         }else- > {// Rectangle or NONE
             // The measureSpec passed is different
             super.onMeasure(widthMeasureSpec, heightMeasureSpec)
         }
     }
 }

Copy the code

So if the user uses a circle or square, but the input width and height are different, I will take the measurement mode of the minimum width and height to measure the control, so as to ensure that the control measurement width and height is the same; If the user uses rectangle or NONE, they just keep the measurements.

A control may go through several measures to determine its width and height. After several onMeasure() calls, onSizeChanged() will be called only once, and onLayout() will be called to determine its final width and height. I get the measure width inside onSizeChanged() to determine the paint range size of the control and the temporary control size as follows:

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    // Control the size of the painting range
    canvasWidth = measuredWidth
    canvasHeight = measuredHeight
    // Control size, temporarily equal to canvas size, which will change later in onLayout ()
    viewWidth = canvasWidth
    viewHeight = canvasHeight
    / /...
}

Copy the code

Control painting scope size and control size relationship as follows:

The green box is the size of the control painting scope, the red box is the control size, that is to say, after the control size is determined, I only take the middle part of the painting, many people will have a question? Why draw only the middle part and not the whole control scope? This is because the following happens when the parent layout of the control is ConstraintLayout and the control is match_parent in width or height:

Figure 1: Control size: layout_width = “match_parent”, layout_height = “200dp”

Figure 2: Control size: layout_width = “200dp”, layout_height = “match_parent”

The blue box is the screen of the phone, and the black background is the size of the control. Remember what I said in the onMeasure() method above, if the shape of the control is round, then the width and height of the control should be the same and the minimum should be taken as the baseline. So if the control size input is layout_width = “match_parent”, layout_height = “200dp” or layout_width = “200DP”, Layout_height = “match_parent”, after measuring the control size should be width = height = 200dp, the effect should be as follows:

Figure 3

This is because the ConstraintLayout invalidates setMeasuredDimension() of the child control, resulting in measuredHeight and height differences. ConstraintLayout is the parent layout, and the width or height of the control is set to “match_parent”, and you define the measurement process, so that the size of the View measured is not equal to the final View size. GetMeasureHeigth () or getMeasureWidth()! = getWidth() or getHeigth(), why ConstraintLayout does this and another Linearlayout does not? I don’t know, maybe you need to read the source code, and my solution is to make each drawing scope in the center of the control, like figure 1 and figure 2, so that it is not so ugly.

2, cut the canvas shape

If the shape of the control is square or rectangle, you can also set rounded corners. One method is to use BitmapShader to achieve this, using BitmapShader to go through 3 steps:

1. Create a Bitmap;

2. Create a Canvas with the Bitmap created by 1 and draw waves on the Canvas;

3, Finally create a BitmapShader associated with the Bitmap of 1, and then set it to the brush. Use the brush to draw a shape on the Canvas passed in by the onDraw method, and then the shape will contain waves.

However, I didn’t use BitmapShader because moving waves can be used to open an infinite loop that calls onDraw() constantly. It’s not recommended to create new objects through onDraw(). However, I still have to create new Canvas objects every time, so in order to reduce object allocation, I use Canvas clipPathAPI to cut the Canvas into the shape I want, and then draw the waves on the trimmed Canvas, which can also achieve the same effect as BitmapShader, as follows:

private fun preDrawShapePath(w: Int, h: Int) {                                                     
    clipPath.reset()                                                                               
    when (shape) {                                                                                 
        Shape.CIRCLE -> {                                                                                       / /...
            //path The path is circular
            clipPath.addCircle(                                                                     
                shapeCircle.centerX, shapeCircle.centerY,                                           
                shapeCircle.circleRadius,                                                           
                Path.Direction.CCW                                                                 
            )                                                                                         
        }                                                                                           
        Shape.SQUARE -> {                                                                           
            / /...
            //path The path is a square or rounded square
            if (shapeCorner == 0f) clipPath? .addRect(shapeRect, Path.Direction.CCW)else                                                                                   
            clipPath.addRoundRect(                                                             
                shapeRect,                                                                     
                shapeCorner, shapeCorner,                                                       
                Path.Direction.CCW     
            )                                                                                   
        }                                                                                           
        Shape.RECT -> {                                                                             
            / /...}}}Copy the code

PreDrawShapePath () adds different shapes to the Path according to Shape to save the Path information in advance. As described earlier, the scope of each drawing is in the center of the control. Everything omitted is centered. The Path with the shape saved will be used in the onDraw method as follows:

override fun onDraw(canvas: Canvas?). {
    clipCanvasShape(canvas)           
    / /...
}    

private fun clipCanvasShape(canvas: Canvas?). {    
    // Call canvas's clipPath method to crop the canvas
    if(shape ! = Shape.NONE) canvas? .clipPath(clipPath)/ /...
}                                                      
Copy the code

Use the canvas.clippath () method in the onDraw method to pass in the Path clipping canvas so that the scope of future painting is confined to the canvas shape.

3. Draw waves

Draw waves using bezier curves as follows:

private fun preDrawWavePath(a) {
    wavePath.reset()
    // The wavelength equals the width of the canvas
    val waveLen = canvasWidth
    / / wave
    val waveHeight = (waveAmplitude * canvasHeight).toInt()
    // The initial y coordinate of the wave
    waveStartY = calculateWaveStartYbyProcess()
    // Move path to the starting position, using the path.moveto () method
    wavePath.moveTo(-canvasWidth * 2f, waveStartY)
    // The following is the process of drawing waves, using the path.rxx () method, which means that the coordinates of the last end point are used as the origin, thus simplifying the calculation
    val rang = -canvasWidth * 2..canvasWidth
    for (i in rang step waveLen) {
        wavePath.rQuadTo(
            waveLen / 4f, waveHeight / 2f,
            waveLen / 2f, 0f
        )
        wavePath.rQuadTo(
            waveLen / 4f, -waveHeight / 2f,
            waveLen / 2f, 0f
        )
    }
    // The depth of the wave is the height of the canvas
    wavePath.rLineTo(0f, canvasHeight.toFloat())
    wavePath.rLineTo(-canvasWidth * 3f, 0f)
    // Finally use path.close() to close the path of the wave so that the entire wave is enclosed
    wavePath.close()
}
Copy the code

PreDrawWavePath () stores the information about the wave path in the path. The following image shows the entire wave path as follows:

I am full of the parent container to control size, so the control range of paint is the size of the green box, the wavelength of the wave is the width of a canvas that the width of the green box, I move, the starting point of the wave to the outside of the scope of the screen, starting from the starting point, drew three wavelength, the wave to draw the scope of the screen, thus convenient forwarder will move up and down the waves, Finally, use path.close() to close the path of the wave, enclosing the entire wave.

The Path that holds the wave Path information is used in the onDraw method as follows:

override fun onDraw(canvas: Canvas?). {
    clipCanvasShape(canvas)
    drawWave(canvas)
    / /...
}

private fun drawWave(canvas: Canvas?). {
    wavePaint.style = Paint.Style.FILL_AND_STROKE
    wavePaint.color = waveColor
    / /...
    // Use the canvas drawPath() method to draw the wave on the canvascanvas? .drawPath(wavePath, wavePaint) }Copy the code

Use the Canvas drawPath() method to draw the wave directly on the canvas. This will display the following effect on the screen:

So that’s one wave. What about the second wave? You can use another Path and draw another one as described in the preDrawWavePath() method, as long as the starting point of the wave is different, but I didn’t use this method. Instead, I translated the Canvas using the Canvas translate() method, and drew the second one using the offset of the two translations. As follows:

private fun drawWave(canvas: Canvas?). {
    wavePaint.style = Paint.Style.FILL_AND_STROKE
    
    // Save the state of the canvas twice, denoted as canvas 1 and 2canvas? .save()1 / / canvascanvas? .save()2 / / canvas
    
    // The current canvas is canvas 3
    // Call canvas Translate () to horizontally translate canvas 3canvas? .translate(canvasSlowOffsetX,0)
    wavePaint.color = adjustAlpha(waveColor, 0.7f)
    // First draw the first wave on Canvas 3canvas? .drawPath(wavePath, wavePaint)// Restore the saved canvas 2 statecanvas? .restore()// Below is canvas 2
    // Call canvas Translate () to horizontally translate canvas 2canvas? .translate(canvasFastOffsetX,0)
    wavePaint.color = waveColor
    // Then draw a second wave on canvas 2canvas? .drawPath(wavePath, wavePaint)// Restore the saved canvas 1 statecanvas? .restore()// Paint on canvas 1
}
Copy the code

If you are familiar with the save() and restore() methods of Canvas, each call to save() can be interpreted as a stack of Canvas (save), and each call to restore() can be interpreted as a stack of Canvas (restore). Canvas 3 is already available by default, and Canvas 1 and 2 are generated by saving. CanvasSlowOffsetX and canvasFastOffsetX values were different, resulting in different shifts between Canvases 2 and 3. Therefore, two waves can be formed by drawing the same Path on two canvases with different offsets. The effect picture is as follows:

4. Make the waves move

To get the wave moving is simple, use an infinite loop animation, calculate the offset of the canvas in the animation’s progress callback, and then call invalidate() as follows:

waveValueAnim.apply {
    duration = ANIM_TIME
    repeatCount = ValueAnimator.INFINITE// Infinite loop
    repeatMode = ValueAnimator.RESTART
    addUpdateListener{ animation ->
      	/ /...
        canvasFastOffsetX = (canvasFastOffsetX + fastWaveOffsetX) % canvasWidth
        canvasSlowOffsetX = (canvasSlowOffsetX + slowWaveOffsetX) % canvasWidth
        invalidate()
     }
}
Copy the code

Start the animation at the appropriate time as follows:

override fun onDraw(canvas: Canvas?). {
    clipCanvasShape(canvas)
    drawWave(canvas)
    / /...
    // Start animation
    startLoading()
}

fun startLoading(a){
   if(! waveValueAnim.isStarted) waveValueAnim.start() }Copy the code

At this point the entire control is complete.

5, to optimize

We all know that the resources of the mobile phone is very limited, I was in a custom View, particularly relates to an infinite loop animation, attention should be paid to optimize our code, because the general screen refresh cycle is 16 ms, this means that within the 16 ms you to finish the all calculation and process about animation, or it will cause the frame, thus caton, When I was customizing the View, I thought I could do some optimization from the following points to improve efficiency:

5.1 Reduce the memory allocation of objects and reuse objects as far as possible

Every system when the GC ms suspension system level, and infinite loop animation logic code will be cycling in a short time the call, so that if in the logic code on the heap to create temporary variables, too much can lead to memory usage in a short period of time to rise, thereby the GC behavior of frequent trigger system, this will no doubt be a drag on the efficiency of the animation, Let the animation get stuck.

When customizing a View with an infinite loop animation, we can’t ignore the memory allocation of the object, and don’t always new the object in the onDraw() method: If these temporary variables are fixed each time and do not need to be created repeatedly each time the loop executes, we can consider converting them from temporary variables to member variables. These member variables can be initialized during animation initialization or View initialization, and can be called directly when needed. For the drawing of irregular graphics, we need Path, and for the more complex Path, Canvas drawing will be more time-consuming, so what we need to do is to optimize the Path creation process as much as possible. The reset() method is used to reset the object, and the rewind() method can reuse the memory allocated by the Path object without releasing the memory allocated before.

5.2. Extract repeated operations and reduce floating point operations as much as possible

When a custom View is not hard to avoid encounter a large number of operations, especially when doing an infinite loop animation, its logic code could be cycling in a short time the call, so that if doing too much repetition in the logic operation will undoubtedly reduce the efficiency of the animation, especially when doing floating-point arithmetic, CPU to deal with floating point arithmetic, will be particularly slow, It takes multiple instruction cycles to complete.

So we should also try to reduce floating-point operations, which can be converted to integers without concern for accuracy, and we should also extract the repeated operations from the logical code instead of doing them every time, for example in WaveLoadingView, Most of my calculations for creating a Path are done in onLayout(), and the result of repeated operations is saved in Path and used in onDraw() because onDraw() will be called frequently during animation.

5.3. Consider using SurfaceView

The traditional View measurement, layout, drawing are completed in the UI thread, and the Android UI thread in addition to the View drawing, but also need to carry out additional user processing logic, polling message events, etc., so when the View drawing and animation is more complex, the calculation is relatively large, It’s no longer appropriate to use View as a way to draw. In this case, we can consider using the SurfaceView. The SurfaceView can draw graphics in non-UI threads, relieving the stress of the UI thread. Of course WaveLoadingView can also be implemented using SurfaceView.

conclusion

The implementation of WaveLoadingView is explained. Kotlin is used to write the process of custom View. The overall amount of code is indeed much less than that of Java, but the language is only a tool after all, we mainly learn the practice process of custom View. You will find that custom View is not as difficult as you think, come and go just a few methods, most of the time is spent on implementation details and operations, more implementation please see the end of the address.

Address: WaveLoadingView