Eason recently met a requirement to display a segmented progress bar. In order to give the progress bar the desired look and feel, when building the user interface (UI), people usually rely on the available tools provided by SDK and try to adapt the SDK to the current UI requirements. Sadly, most of the time it doesn’t meet our expectations. So Eason decided to draw it himself.

Create a custom view

To draw custom giFs on Android, you need to use Paint and follow the Path object to draw onto the canvas.

We can directly manipulate all of the views on the Canvas. More specifically, all graphics are drawn 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}}Copy the code

Going back to the progress bar, let’s break down the entire progress bar implementation from the beginning.

The overall idea is: first draw a set of quadrilateral showing different angles, they are separated from each other and have no space filled state. Finally, we have a wave animation synchronized with its fill progress.

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

Draw a single progress bar

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

Leave aside the complexities of angles, spacing, and animation for a moment. 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 )
Copy the code

Try not to allocate objects inside the onDraw() method. The two Path and Paint objects must be created within their scope. This onDraw callback will cause you to run out of memory when the View calls it many times. The lint message in the compiler also warns against doing this.

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

MoveTo () : Places the brush to a specific coordinate.

LineTo (): Draws a line between two coordinates.

Both methods accept a Float value as an argument.

Start at the top left corner and move the cursor to other coordinates.

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

In Android, the Y-axis is inverted when drawing. In this case, we calculate it 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.

path.moveTo(0f, 0f)

path.lineTo(w, 0f)

Repeat the process in the lower right and left corners.

path.lineTo(w, h)

path.lineTo(0f, h)

Finally, close the path to finish drawing the shape.

path.close()

The calculation phase is complete. It’s time to paint it!

For Paint objects, you can use colors, Alpha channels, and other options. The paint.style enumeration determines whether the shape will be filled (the default), hollow with a border, or both. In the example, a filled rectangle with translucent gray is drawn:

paint.color = color

paint.alpha = alpha.toAlphaPaint()

For the alpha attribute, Paint requires an Integer from 0 to 255. More accustomed to Float from 0 to 1 operating a, I created this simple converter

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

It’s ready to display our first segment progress bar. All we need to do is draw our Paint on the canvas in 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)
        }
    }
Copy the code

Use multiple progress bars to progress

Does it feel like it’s almost done? Right! Most of the custom animations are done. Instead of operating on unique Path and Paint objects, we’ll 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))
    }
}
Copy the code

We didn’t set the spacing at first. If we wanted to draw multiple animations, we would divide the width of the View accordingly, but we didn’t need to worry about height. And just like before, we need to find four coordinates for each segment. We already know the Y coordinate, so it’s important to find the equation for the X coordinate.

Below is a three-step progress bar. We annotate the new coordinates by introducing line segment width (sw) and spacing (s) elements.

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

  • Position at the beginning of each segment (startX)
  • Total number of segments (count)
  • Interval amount (s)

With these three variables, we can calculate any coordinates from the progress bar:

Width of each section:

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

For each line segment, the X coordinate is located at the width of the line segment sw plus the spacing s. According to the above relationship, it can be obtained:

Val topLeftX = (sw + s) * position

Val bottomLeftX = (sw + s) * position

Same for upper right and lower right:

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

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

Began to draw

    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(a) {(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)
        }
    }
Copy the code

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

Draw the progress

We have drawn the foundation of the component. However, we can’t call it a progress bar right now. Because the progress part is not shown yet. We should add the following logic:

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

  • The left coordinate will always be 0.
  • The right coordinate includes a Max () condition to prevent negative spacing from being added when 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 section, we need to declare another Path and Paint object and store the progress value for this object.

var progress: Int = 0

private val progressPath: Path = Path()

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

We then call drawSegment() to draw the graph based on Path, Paint, and coordinates.

Add animation effects

How can we live with a progress bar without animation?

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

We can divide it into three stages:

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

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

    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) } } }Copy the code

To get linear interpolation (LERP), we use an extended method to compare the original value (this) with the value () at some end step.

    fun Float.lerp( 
      end: Float, 
      @floatrange (from = 0.0, to = 1.0) amount: Float 
    ): Float = 
    this * (1 - amount.coerceIn (0f.1f() + end * amount. Force input (0f.1f)Copy the code

As the animation progresses, record the current coordinates and calculate the most recent coordinates (amount) for the given animation position.

Because of the invalidate() method, progressive plotting then occurs. Using it forces the View to call the onDraw() callback.

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

Decorate your components with bevels

Even though the component already met the functional requirements we expected for a segmented progress bar, Eason wanted to add to it.

To break the cube design, bevel angles can be used to shape different line segments. We keep space between each segment, but we bend the inner segment at a specific Angle.

Don’t you know where to start? Let’s zoom in:

To control the height and Angle, we need to calculate the distance between the dashed rectangle and the triangle.

If you remember some of the tangents of triangles. In the figure above, we introduce another compound into the equation: line segment tangent (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()

With this latest element, we must recalculate the segment width:

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

We can keep modifying our equations. But first, we need to rethink how we calculate spacing.

Introducing angles disrupts our perception of spacing so that it is no longer on a horizontal plane. 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 the Pythagoras 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

Two things emerge from these equations:

  1. The lower-left coordinate has a Max () condition to avoid drawing outside the bounds of the first segment.
  2. The last segment in the upper right has the same problem and should not add additional segment tangents.

To finish the calculation, 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(a)
                }
            }

        @get:ColorInt
        var progressColor: Int = Color.GREEN
            set(value) {
                if(field ! = value) { field =value
                    invalidate(a)}}var spacing: Float = 0f
            set(value) {
                if(field ! = value) { field =value
                    invalidate(a)}}// 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(a)
                    invalidate(a)}}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(a) {
            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()

            (0until 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) } } }Copy the code

Hopefully, this article will inspire anyone who is building components or wheels. Our official account team is working hard to bring the best knowledge to you, We’ll be back soon!

❤️/ Thanks for your support /

That is all the content of this sharing. I hope it will help you

Don’t forget to share, like and bookmark your favorite things

Welcome to the public number programmer bus, from byte, shrimp, zhaoyin three brothers, share programming experience, technical dry goods and career planning, help you to avoid detours into the factory.