2
头图

Eason recently encountered a need to display a segmented progress bar. In order to give the progress bar the desired look and feel, when building a user interface (UI), you usually rely on the available tools provided by the SDK and try to pass Adjust the SDK to meet the current UI requirements; but sadly, it basically does not meet our expectations in most cases. So Eason decided to draw it himself.

Create a custom view

To draw a custom animation in Android, you need to use Paint and draw it on the canvas according to the Path object.

We can directly manipulate all the above objects View in the Canvas. More specifically, the drawing of all graphics takes place in the onDraw() callback.

class SegmentedProgressBar @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
    ) : View(context, attrs, defStyleAttr) {

      override fun onDraw(canvas: Canvas) {
          // Draw something onto the canvas
      }
  }

Back to the progress bar, let us decompose the realization of the entire progress bar from the beginning.

overall idea of
First draw a group of quadrilaterals showing different angles, which are spaced apart from each other and have a filling state with no space. Finally, we have a wave animation synchronized with its filling progress.

Before trying to meet all of these requirements, we can start with a simpler version. But don’t worry. We will start from the basics and gradually explain in simple terms!

Draw a single segment progress bar

The first step is to draw its most basic version: a single-stage progress bar.

Put aside complex elements such as angles, spacing, and animations for now. This custom animation as a whole only needs to draw a rectangle. We start by assigning aPath and a Paint object.

private val segmentPath: Path = Path()
private val segmentPaint: Paint = Paint( Paint.ANTI_ALIAS_FLAG )
Try not to allocate objects inside the onDraw() method. These two Path and Paint objects must be created within their scope. Calling this onDraw callback in View many times will cause your memory to gradually decrease. The lint message in the compiler will also warn you not to do this.

To realize the drawing part, we may choose the drawRect() method of Path. Because we will draw more complex shapes in the next steps, we prefer to draw point by point.

moveTo(): Place the pen to a specific coordinate.

lineTo(): Draw a line between two coordinates.

Both methods accept Float values as parameters.

Start from the upper left corner, and then move the cursor to other coordinates.

The following figure shows the rectangle to be drawn, given a certain width (w) and height (h ).

In Android, when drawing, the Y axis is inverted. Here, we count from top to bottom.

Drawing such a shape means positioning the cursor in the upper left corner and then drawing a line in the upper right corner.

path.moveTo(0f, 0f)

path.lineTo(w, 0f)

Repeat this process in the lower right and lower left corners.

path.lineTo(w, h)

path.lineTo(0f, h)

Finally, close the path to complete the drawing of the shape.

path.close()

calculation phase of 161af7702cfaf1 has been completed. It's time to color it with paint!

For the processing of Paint objects, you can use color, alpha channel and other options. The Paint.Style enumeration determines whether the shape will be filled (default), hollow with a border, or both.
In the example, a filled rectangle with translucent gray will be drawn:

paint.color = color

paint.alpha = alpha.toAlphaPaint()

For the alpha attribute, Paint needs Integer from 0 to 255. Since I am more accustomed to Float operating a from 0 to 1, I created this simple converter

fun Float.toAlphaPaint(): Int = (this * 255).toInt()

The above is ready to present our first segmented progress bar. We only need to draw our Paint on the canvas according to the calculated x and y directions.

canvas.drawPath(path,paint)

Here is part of the code:

    class SegmentedProgressBar @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
    ) : View(context, attrs, defStyleAttr) {
        @get:ColorInt
        var segmentColor: Int = Color.WHITE
        var segmentAlpha: Float = 1f

        private val segmentPath: Path = Path()
        private val segmentPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)

        override fun onDraw(canvas: Canvas) {
            val w = width.toFloat()
            val h = height.toFloat()

            segmentPath.run {
                moveTo(0f, 0f)
                lineTo(w, 0f)
                lineTo(w, h)
                lineTo(0f, h)
                close()
            }

            segmentPaint.color = segmentColor
            segmentPaint.alpha = alpha.toAlphaPaint()
            canvas.drawPath(segmentPath, segmentPaint)
        }
    }

Use multi-stage progress bar to move forward

Does it feel like it's almost finished? correct! Most of the custom animation work has been completed. Instead of manipulating unique Path and Paint objects, we will create an instance for each segment.

var segmentCount: Int = 1 // Set wanted value here
private val segmentPaths: MutableList<Path> = mutableListOf()
private val segmentPaints: MutableList<Paint> = mutableListOf()
init {
    (0 until segmentCount).forEach { _ ->
        segmentPaths.add(Path())
        segmentPaints.add(Paint(Paint.ANTI_ALIAS_FLAG))
    }
}

We didn't set the spacing at the beginning. If you need to draw multiple animations, you need to divide the View width accordingly, but the more worry-free thing is that you don't need to consider the height. As before, you need to find the four coordinates of each segment. We already know the Y coordinate, so it is important to find the equation for calculating the X coordinate.

Below is a three-stage progress bar. We introduce the line width (sw) and spacing (s) elements to annotate the new coordinates.

As can be seen from the above figure, the X coordinate depends on:

  • The start position of each paragraph (startX)
  • Total number of segments (count)
  • Interval amount (s)

With these three variables, we can calculate any coordinate from this progress bar:

each segment width:

val sw = (w - s * (count - 1)) / count

Starting from the left coordinate, for each line segment, the X coordinate is located at the line segment width sw plus the spacing s. According to the above relationship, we can get:

val topLeftX = (sw + s) * position

val bottomLeftX = (sw + s) * position

Similarly, the upper right corner and the lower right corner:

val topRightX = sw (position + 1) + s position

val bottomRightX = sw (position + 1) + s position

start drawing

    class SegmentedProgressBar @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
    ) : View(context, attrs, defStyleAttr) {

        @get:ColorInt
        var segmentColor: Int = Color.WHITE
        var segmentAlpha: Float = 1f
        var segmentCount: Int = 1
        var spacing: Float = 0f

        private val segmentPaints: MutableList<Paint> = mutableListOf()
        private val segmentPaths: MutableList<Path> = mutableListOf()
        private val segmentCoordinatesComputer: SegmentCoordinatesComputer = SegmentCoordinatesComputer()

        init {
            initSegmentPaths()
        }

        override fun onDraw(canvas: Canvas) {
            val w = width.toFloat()
            val h = height.toFloat()

            (0 until segmentCount).forEach { position ->
                val path = segmentPaths[position]
                val paint = segmentPaints[position]
                val segmentCoordinates = segmentCoordinatesComputer.segmentCoordinates(position, segmentCount, w, spacing)

                drawSegment(canvas, path, paint, segmentCoordinates, segmentColor, segmentAlpha)
            }
        }

        private fun initSegmentPaths() {
            (0 until segmentCount).forEach { _ ->
                segmentPaths.add(Path())
                segmentPaints.add(Paint(Paint.ANTI_ALIAS_FLAG))
            }
        }

        private fun drawSegment(canvas: Canvas, path: Path, paint: Paint, coordinates: SegmentCoordinates, color: Int, alpha: Float) {
            path.run {
                reset()
                moveTo(coordinates.topLeftX, 0f)
                lineTo(coordinates.topRightX, 0f)
                lineTo(coordinates.bottomRightX, height.toFloat())
                lineTo(coordinates.bottomLeftX, height.toFloat())
                close()
            }

            paint.color = color
            paint.alpha = alpha.toAlphaPaint()

            canvas.drawPath(path, paint)
        }
    }
path.reset(): When drawing each line segment, we first reset the path before moving to the desired coordinates.

Drawing progress

We have drawn the basics of the components. However, currently we cannot call it a progress bar. Because there is no progress part yet. We should add the logic of the following figure:

The overall idea is the same as when drawing the bottom rectangle shape before:

  • The left coordinate will always be 0.
  • The right coordinate includes a max() condition to prevent adding negative spacing when the progress is 0.

    val topLeftX = 0f

    val bottomLeftX = 0f

    val topRight = sw progress + s max (0, progress - 1)

    val bottomRight = sw progress + s max (0, progress - 1)

To draw the progress segment, we need to declare another Path and Paint object, and store the progress value of this object.

var progress: Int = 0

private val progressPath: Path = Path()

private val progressPaint: Paint = Paint( Paint.ANTI_ALIAS_FLAG )

Then, we call drawSegment() to draw graphics based on Path, Paint and coordinates.

Add animation effects

How can we stand a progress bar without animation?

So far, we have known how to calculate the coordinates of our line segment including the starting point. We will repeat this pattern by gradually drawing our fragments throughout the duration of the animation.

We can be divided into three stages:

  1. Start: We get the segment coordinates given the current progress value.
  2. Work in progress: We update the coordinates by calculating a linear interpolation between the old and new coordinates.
  3. End: We get the coordinates of the line segment given the new progress value.

We use aValueAnimator to update the state from 0 (start) to 1 (end). It will handle the interpolation between the ongoing stages.

    class SegmentedProgressBar @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
    ) : View(context, attrs, defStyleAttr) {

        [...]

        var progressDuration: Long = 300L
        var progressInterpolator: Interpolator = LinearInterpolator()

        private var animatedProgressSegmentCoordinates: SegmentCoordinates? = null

        fun setProgress(progress: Int, animated: Boolean = false) {
            doOnLayout {
                val newProgressCoordinates =
                    segmentCoordinatesComputer.progressCoordinates(progress, segmentCount, width.toFloat(), height.toFloat(), spacing, angle)

                if (animated) {
                    val oldProgressCoordinates =
                        segmentCoordinatesComputer.progressCoordinates(this.progress, segmentCount, width.toFloat(), height.toFloat(), spacing, angle)

                    ValueAnimator.ofFloat(0f, 1f)
                        .apply {
                            duration = progressDuration
                            interpolator = progressInterpolator
                            addUpdateListener {
                                val animationProgress = it.animatedValue as Float
                                val topRightXDiff = oldProgressCoordinates.topRightX.lerp(newProgressCoordinates.topRightX, animationProgress)
                                val bottomRightXDiff = oldProgressCoordinates.bottomRightX.lerp(newProgressCoordinates.bottomRightX, animationProgress)
                                animatedProgressSegmentCoordinates = SegmentCoordinates(0f, topRightXDiff, 0f, bottomRightXDiff)
                                invalidate()
                            }
                            start()
                        }
                } else {
                    animatedProgressSegmentCoordinates = SegmentCoordinates(0f, newProgressCoordinates.topRightX, 0f, newProgressCoordinates.bottomRightX)
                    invalidate()
                }

                this.progress = progress.coerceIn(0, segmentCount)
            }
        }

        override fun onDraw(canvas: Canvas) {
            [...]

            animatedProgressSegmentCoordinates?.let { drawSegment(canvas, progressPath, progressPaint, it, progressColor, progressAlpha) }
        }
    }

In order to obtain linear interpolation (lerp), we use the extension method to compare the original value (this) with the value at a certain step of end () amount.

    fun Float.lerp( 
      end: Float, 
      @FloatRange(from = 0.0, to = 1.0) amount: Float 
    ): Float = 
    this * (1 - amount.coerceIn (0f, 1f)) + end * amount。强制输入(0f,1f)

As the animation progresses, the current coordinates are recorded and the latest coordinates (amount) of the given animation position are calculated.

Because of the invalidate() method, then progressive drawing occurs. Using it will force the View to call the onDraw() callback.

Now with this animation, you have implemented a component to reproduce the native Android progress bar that meets the UI requirements.

Decorate your components with beveled corners

Even if the component has met our expected functional requirements for the segmented progress bar, Eason wants to add to it.

To break the cube design, bevel angles can be used to shape different line segments. There is a space between each segment, but we bend the inner segment at a certain angle.

feel unable to start? Let's zoom in on the part:

We control the height and angle, and need to calculate the distance between the dotted rectangle and the triangle.

If you remember some of the tangents of the triangle. In the figure above, we have introduced another compound in the equation: the tangent of the line segment (st ).

In Android, the tan() method requires an angle in radians. So you have to convert it first:

val segmentAngle = Math.toRadians(angle.toDouble())

val segmentTangent = h * tan (segmentAngle).toFloat()

Using this latest element, we must recalculate the value of the segment width:

val sw = (w - (s + st) * (count - 1)) / count

We can continue to modify our equations. But first, we need to reconsider how to calculate the spacing.

The introduction of angles breaks our perception of spacing, making it no longer on a horizontal plane. Everyone see for yourself

The spacing we want (s) no longer matches the segment spacing (ss) used in the equation, so it is important to adjust the way this spacing is calculated. But combining the Pythagorean theorem should solve the problem:

val ss = sqrt (s. pow (2) + (s * tan (segmentAngle).toFloat()). pow (2))

val topLeft = (sw + st + s) * position

val bottomLeft = (sw + s) position + st max (0, position - 1)

val topRight = (sw + st) (position + 1) + s position-if (isLast) st else 0f

val bottomRight = sw (position + 1) + (st + s) position

From these equations, two things can be drawn:

  1. The lower left corner coordinates have a max() condition, which can avoid drawing outside the boundary of the first paragraph.
  2. The last paragraph in the upper right corner has the same problem, and no extra segment tangent should be added.

In order to end the calculation part, we also need to update the progress coordinates:

val topLeft = 0f

val bottomLeft = 0f

val topRight = (sw + st) progress + s max (0, progress - 1) - if (isLast) st else 0f

val bottomRight = sw progress + (st + s) max (0, progress-1)

Complete code:

    class SegmentedProgressBar @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
    ) : View(context, attrs, defStyleAttr) {

        @get:ColorInt
        var segmentColor: Int = Color.WHITE
            set(value) {
                if (field != value) {
                    field = value
                    invalidate()
                }
            }

        @get:ColorInt
        var progressColor: Int = Color.GREEN
            set(value) {
                if (field != value) {
                    field = value
                    invalidate()
                }
            }

        var spacing: Float = 0f
            set(value) {
                if (field != value) {
                    field = value
                    invalidate()
                }
            }

        // TODO : Voluntarily coerce value between those angle to avoid breaking quadrilateral shape
        @FloatRange(from = 0.0, to = 60.0)
        var angle: Float = 0f
            set(value) {
                if (field != value) {
                    field = value.coerceIn(0f, 60f)
                    invalidate()
                }
            }

        @FloatRange(from = 0.0, to = 1.0)
        var segmentAlpha: Float = 1f
            set(value) {
                if (field != value) {
                    field = value.coerceIn(0f, 1f)
                    invalidate()
                }
            }

        @FloatRange(from = 0.0, to = 1.0)
        var progressAlpha: Float = 1f
            set(value) {
                if (field != value) {
                    field = value.coerceIn(0f, 1f)
                    invalidate()
                }
            }

        var segmentCount: Int = 1
            set(value) {
                val newValue = max(1, value)
                if (field != newValue) {
                    field = newValue
                    initSegmentPaths()
                    invalidate()
                }
            }

        var progressDuration: Long = 300L

        var progressInterpolator: Interpolator = LinearInterpolator()

        var progress: Int = 0
            private set

        private var animatedProgressSegmentCoordinates: SegmentCoordinates? = null
        private val progressPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)
        private val progressPath: Path = Path()
        private val segmentPaints: MutableList<Paint> = mutableListOf()
        private val segmentPaths: MutableList<Path> = mutableListOf()
        private val segmentCoordinatesComputer: SegmentCoordinatesComputer = SegmentCoordinatesComputer()

        init {
            context.obtainStyledAttributes(attrs, R.styleable.SegmentedProgressBar, defStyleAttr, 0).run {
                segmentCount = getInteger(R.styleable.SegmentedProgressBar_spb_count, segmentCount)
                segmentAlpha = getFloat(R.styleable.SegmentedProgressBar_spb_segmentAlpha, segmentAlpha)
                progressAlpha = getFloat(R.styleable.SegmentedProgressBar_spb_progressAlpha, progressAlpha)
                segmentColor = getColor(R.styleable.SegmentedProgressBar_spb_segmentColor, segmentColor)
                progressColor = getColor(R.styleable.SegmentedProgressBar_spb_progressColor, progressColor)
                spacing = getDimension(R.styleable.SegmentedProgressBar_spb_spacing, spacing)
                angle = getFloat(R.styleable.SegmentedProgressBar_spb_angle, angle)
                progressDuration = getInteger(R.styleable.SegmentedProgressBar_spb_duration, progressDuration)
                recycle()
            }

            initSegmentPaths()
        }

        fun setProgress(progress: Int, animated: Boolean = false) {
            doOnLayout {
                val newProgressCoordinates =
                    segmentCoordinatesComputer.progressCoordinates(progress, segmentCount, width.toFloat(), height.toFloat(), spacing, angle)

                if (animated) {
                    val oldProgressCoordinates =
                        segmentCoordinatesComputer.progressCoordinates(this.progress, segmentCount, width.toFloat(), height.toFloat(), spacing, angle)

                    ValueAnimator.ofFloat(0f, 1f)
                        .apply {
                            duration = progressDuration
                            interpolator = progressInterpolator
                            addUpdateListener {
                                val animationProgress = it.animatedValue as Float
                                val topRightXDiff = oldProgressCoordinates.topRightX.lerp(newProgressCoordinates.topRightX, animationProgress)
                                val bottomRightXDiff = oldProgressCoordinates.bottomRightX.lerp(newProgressCoordinates.bottomRightX, animationProgress)
                                animatedProgressSegmentCoordinates = SegmentCoordinates(0f, topRightXDiff, 0f, bottomRightXDiff)
                                invalidate()
                            }
                            start()
                        }
                } else {
                    animatedProgressSegmentCoordinates = SegmentCoordinates(0f, newProgressCoordinates.topRightX, 0f, newProgressCoordinates.bottomRightX)
                    invalidate()
                }

                this.progress = progress.coerceIn(0, segmentCount)
            }
        }

        private fun initSegmentPaths() {
            segmentPaths.clear()
            segmentPaints.clear()
            (0 until segmentCount).forEach { _ ->
                segmentPaths.add(Path())
                segmentPaints.add(Paint(Paint.ANTI_ALIAS_FLAG))
            }
        }

        private fun drawSegment(canvas: Canvas, path: Path, paint: Paint, coordinates: SegmentCoordinates, color: Int, alpha: Float) {
            path.run {
                reset()
                moveTo(coordinates.topLeftX, 0f)
                lineTo(coordinates.topRightX, 0f)
                lineTo(coordinates.bottomRightX, height.toFloat())
                lineTo(coordinates.bottomLeftX, height.toFloat())
                close()
            }

            paint.color = color
            paint.alpha = alpha.toAlphaPaint()

            canvas.drawPath(path, paint)
        }

        override fun onDraw(canvas: Canvas) {
            val w = width.toFloat()
            val h = height.toFloat()

            (0 until segmentCount).forEach { position ->
                val path = segmentPaths[position]
                val paint = segmentPaints[position]
                val segmentCoordinates = segmentCoordinatesComputer.segmentCoordinates(position, segmentCount, w, h, spacing, angle)

                drawSegment(canvas, path, paint, segmentCoordinates, segmentColor, segmentAlpha)
            }

            animatedProgressSegmentCoordinates?.let { drawSegment(canvas, progressPath, progressPaint, it, progressColor, progressAlpha) }
        }
    }

I hope this article will be inspiring for everyone who is creating components or making wheels. Our official account team is working hard to bring the best knowledge to everyone, We'll be back soon!

❤️/ Thanks for your support/

The above is all the content shared this time, I hope it will be helpful to you^_^

If you like it, don't forget to share, like, and favorite.

Welcome to pay attention to the public number programmer bus , the three-terminal brothers from Byte, Xiaopi, and China Merchants Bank, share programming experience, technical dry goods and career planning, and help you avoid detours into the big factory.


程序员巴士
52 声望9 粉丝

一辆有趣、有范儿、有温度的程序员巴士,涉猎大厂面经、程序员生活、实战教程、技术前沿等内容,关注我,交个朋友。