1

Building a Segmented Progress Bar in Android

 2 years ago
source link: https://medium.com/betclic-tech/building-a-segmented-progress-bar-in-android-e3f198db393d
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Building a Segmented Progress Bar in Android

Leverage the power of custom drawing.

Photo by Daniel Cheung on Unsplash

At Betclic, we’ve proudly released our first in-app metagaming experience. And believe me, it’s a huge step forward in the sportsbook industry.

Challenging our users on different betting situations and rewarding them instantly was unheard of amongst our competitors.

To achieve this, we wanted to bring the best user experience possible to engage our users. Amongst many topics, we’ve worked on the progression experience. We decided to build an exciting progress bar to reward our users for their progression.

1*jwIb3Rwl22Wfm_2j5arRCg.gif?q=20
building-a-segmented-progress-bar-in-android-e3f198db393d

When it comes to building user interfaces (UI), you’ll often rely on available tools provided by the SDK. However, there are times when your UI requirements prevent you from using them. You may try to tweak your tools, but it just won’t fit the bill.

To give the wanted look and feel of this progress bar, we decided to draw it ourselves. It turns out it’s not as complicated as one may think.

Creating Your Custom View

Imagine a painter in front of a whiteboard. He’ll need a set of brushes along with paint buckets.

In Android, the whiteboard would be the Canvas. To draw on it, you’ll need Paint objects instead of brushes and paint buckets. Finally, you’ll use Path objects to direct the painter’s arm onto the Canvas.

We can manipulate all of the objects above directly inside a View. More specifically, all the magic happens in the onDraw() callback.

Coming back to the progress bar, let’s start by breaking it down.

1*dGcm5C0VG7K-PlIv_uGM-A.gif?q=20
building-a-segmented-progress-bar-in-android-e3f198db393d

We have a set of quadrilaterals displaying different angles. They’re spaced from each other and have a filled state without space. Finally, we have a wavy animation synchronized with its filling progress.

Before trying to match all these requirements, we can start with a simpler version. Don’t worry, though. We’ll get to the bottom of it!

Drawing a One-Segment Progress Bar

The first step would be to draw its most basic version: a one-segment progress bar.

Let’s set aside the angles, spacings, and animations for now. It entails drawing a mere rectangle. We begin with allocating a Path and a Paint object.

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

You should never allocate objects inside the onDraw() method. Both Path and Paint objects must be created outside its scope. The View calls very often this callback and you’ll end up running out of memory. A lint message will warn you against it.

1*JUzz1YdlhePaH88Nz53vkg.png?q=20
building-a-segmented-progress-bar-in-android-e3f198db393d
Android warns you against object allocation inside draw methods

To achieve the drawing part, we may want to choose the Path 's drawRect() prebaked method. Because we will draw more complex shapes in the next steps, we will favor drawing point per point.

We will use two essential methods from the Path class:

  • moveTo(): place your brush to a specific coordinate.
  • lineTo(): draw a line between two coordinates.

Both methods accept Float values as arguments. For the rest of the article, I’ll cast all coordinates values in Float.

We start at the top left corner then we will move our cursor to the other coordinates.

The following graph represents the rectangle we will draw, given a certain width (w) and height (h).

1*332XR0MwT9JxKSb8dwxdiQ.png?q=20
building-a-segmented-progress-bar-in-android-e3f198db393d
A one-segment progress bar

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

Drawing such a shape implies positioning our cursor on the top left corner then tracing a line to the top right corner.

path.moveTo(0f, 0f)
path.lineTo(w, 0f)

We repeat this process to the bottom right and then the bottom left corner.

path.lineTo(w, h)
path.lineTo(0f, h)

Finally, we close our path to complete the rectangle.

path.close()

Our computing phase is complete. It’s time to use our paint buckets to put some colors on it!

The Paint object handles the styling. You can play with color, the alpha channel, and many other options. The Paint.Style enum lets you decide whether the shape will be filled (default), empty with a border, or both.

In our example, you have a filled rectangle with a semi-transparent grey color (the transparency is only visible on the GIF above).

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

For its alpha property, Paint requires an Integer from 0 to 255. Since we’re more accustomed to manipulating a Float from 0 to 1, I’ve created this simple converter:

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

We’re ready to render our first segmented progress bar. We only need to lay our brush on the whiteboard with the computed directions.

canvas.drawPath(path, paint)

Here is the complete code to draw our rectangle

Moving Forward With a Multi-Segments Progress Bar

Believe me or not, you’ve already done most of the job. Instead of manipulating a unique Path and Paint objects, we will create one instance of each per segment. Having multiple instances will allow us to handle their spacing and angles later.

var segmentCount: Int = 1 // Set wanted value hereprivate 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))
}
}

Let’s start without spacing. Drawing multiple segments results in dividing the View ‘s width accordingly. The height, however, stays unaffected.

Like before, we will strive to find the four coordinates per segment. We already know the Y coordinates, so it matters to find the equations to compute the X coordinates.

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

1*lSgZ6iPsXb0PGsrUqnEFBQ.png?q=20
building-a-segmented-progress-bar-in-android-e3f198db393d
A three-segment progress bar with spacing

Looking at the graph shows that the X coordinates depend on:

  • the segment’s position
  • the number of segments (count)
  • the amount of spacing (s)

With these three variables, we can compute any coordinates from this progress bar.

We can already compute the segment width. The View ‘s width contains as many segment width and spacing minus one spacing. Extracting segment width gives:

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

Knowing this, let’s start with the left coordinates. For each segment, the X coordinate is located at a segment width plus a spacing, depending on its position. Naturally, we get:

val topLeftX = (sw + s) * position
val bottomLeftX = (sw + s) * position

When we iterate in our list of Path objects, the position index will start at 0. So the first segment’s top-left coordinate correctly matches (0,0).

Moving on with the right corners, we apply the same logic:

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

Notice that both top and bottom coordinates have the same X value. For now, only the Y coordinate will differ — either 0 or h. It will change as soon as we add angles to the equation.

For convenience reasons, I’ve grouped the X coordinates inside a SegmentCoordinates data class:

data class SegmentCoordinates(
val topLeftX: Float,
val topRightX: Float,
val bottomLeftX: Float,
val bottomRightX: Float
)

To complete this phase, we can create a function to return the coordinates given the variables mentioned earlier.

I’d recommand to extract this code into a testable class. All these equations should be unit tested.

Let’s call this class SegmentCoordinatesComputer.

Wrapping up, we can apply our computing mechanism to our drawing elements:

Have you noticed a small change in the drawing code? When drawing each segment, we start by resetting the path before moving to the desired coordinates.

path.reset()

Because we now have several Path objects, it matters to reset them if reused during another drawing cycle properly.

Drawing The Progression

We have drawn the base of our component. Yet, we cannot call it a progress bar until we can see any progression on it.

Let’s revisit our previous example. Imagine we have progressed 2 out of 3. We should have the following graph:

A three-segment progress bar and progressed 2 out of 3

Here, you can see the progression as another segment on top of the others. Its coordinates slightly differ since it includes the spacing between the segments.

But we can recreate the computation based on our previous exercise.

  • The left coordinates will always be 0.
  • The right coordinates include a max() condition to prevent adding a 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)

Note that we’ve replaced the position variable with progress to give a better representation of what’s manipulated.

Here is the updatedSegmentCoordinatesComputer class:

To draw the progress segment, we need to declare another Path and Paint objects. We also want to store the progress value.

var progress: Int = 0
private val progressPath: Path = Path()
private val progressPaint: Paint = Paint(Paint.ANTI_ALIAS_FLAG)

Then we call the drawSegment() method with our progress Path, Paint and coordinates.

When you paint on your whiteboard, adding another painting layer goes on top of the previous one. When drawing on a Canvas, same thing applies. So make sure to draw the progress segment at the end of the onDraw() method to appear on top of the rest.

Try to change the value of the progress attribute to 2 and compare it with our graph above.

Spice It Up With Animations

How can we imagine a progress bar without animations? In our metagaming experience, advancing brings excitement to our users.

To begin with, we can replicate the ProgressBar ‘s animation. Since we couldn’t use it because of the segmented aspect, we want to reproduce its progress behavior.

So far, we have built a computing system to retrieve our segment coordinates. We will reuse this pattern by drawing in our segment step by step throughout the animation duration.

We can break it down into three phases:

  1. Starting: we get the segment coordinates given the current progress value.
  2. Ongoing: we update the coordinates by computing the linear interpolation between old and new coordinates.
  3. Ended: we get the segment coordinates given the new progress value.

We use a ValueAnimator to update a state from 0 (beginning) to 1 (ended). It will let us handle the interpolation between the ongoing phase.

We make sure to start the animation when the View has been laid out using the doOnLayout() callback.

You can decide what duration and interpolator you want. The following values replicate the default animation behavior from the ProgressBar

val progressDuration: Int = 300 // millisecondes
val progressInterpolator: Interpolator = LinearInterpolator()

To get the linear interpolation (lerp), we use an extension method to compare the original value (this) with the end value on a certain step (amount).

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

As the animation goes forward, we store the current coordinates and compute the newest given the animation’s position (amount).

The progressive drawing then occurs thanks to the invalidate() method. Using it forces the View to call the onDraw() callback.

With this animation in place, you now have a component reproducing the native Android progress bar fitting your UI requirements.

There is still the final animation touch I haven’t covered yet. If you recall the final effect we see at the beginning of the article, you can observe a sort-of-Kamehameha animation.

Unfortunately, I won’t be able to cover it in detail. It was created by our Motion Design team using After Effects and interpreted by the fantastic Lottie library. The trick, though, was to synchronize the animation on top of our animation.

Embellish Your Component With Bevels

Even though your component already fills the functional requirements we expect from a segment progress bar, you may want to decorate it a step further.

To break from a cubical design, you can shape your segments differently with bevels. Each segment keeps its space between each other, but we bend the inner segments with a specific angle.

A three-segment progress bar with bevels

To tackle this, I’ll bring you back to the school bench. How do you feel about your trigonometry skills? A bit rusty, maybe?

Let’s zoom in:

1*BY7abCV5Uyx1BHDUazbAiw.png?q=20
building-a-segmented-progress-bar-in-android-e3f198db393d
A little bit of trigonometry

We control both the height and the angle. We need to compute the distance between the dashed rectangle and the triangle.

If you remember a bit of your trigonometry, we’re talking about the tangent of the triangle. In the above graph, we introduce another compound in our equation: the segment tangent (st).

In Android, the tan() method expects an angle in radian. So you must convert it first. In Kotlin, it becomes:

val segmentAngle = Math.toRadians(angle.toDouble())
val segmentTangent = h * tan(segmentAngle).toFloat()

With this newest element, we must recompute the segment width’s value:

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

We could proceed with modifying our equations. But first, we also need to reconsider how we calculate the spacing.

Introducing angles break our spacing perception. We’re no longer on a horizontal plane. Look by yourself.

1*PsO32irN9mH49x-6kTlaPg.png?q=20
building-a-segmented-progress-bar-in-android-e3f198db393d

The spacing (s) we want doesn’t match anymore with the segment spacing (ss) we use in our equations. So it matters to adjust how we calculate this spacing. A little bit of trigonometry combined with the Pythagoras theorem should do the trick:

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

We get the following coordinates for the base segments. During the rest of the article, I’ll keep the s variable to describe the segment spacing.

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

where isLast = position == count — 1

From these equations, two things come up.

  1. The bottom left coordinate has a max() condition to avoid drawing outside its bounds for the first segment.
  2. The top right has the same issue for the last segment and shouldn’t add an extra segment tangent.

To conclude the computing part, we also need to update the progress coordinates.

1*RUJYcCwZPbRvldXZhTvl4Q.png?q=20
building-a-segmented-progress-bar-in-android-e3f198db393d
A three-segment progress bar with bevels and progressed 2 out of 3
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)

If you’ve extracted the computing code as advised, you can directly run your code!

Closing Thoughts

Drawing things out of nowhere may seem tedious at first. Yet, we get total flexibility on how we want to shape our progress bar.

We could decide to draw semi-arcs and the edge of the progress bar. And we would be prepared to handle it.

To conclude, you should expose a public API to your custom View. You may even coerce some values to prevent erratic behaviors.

For instance, I’ve decided to bound the angle from 0° to 60°. Going beyond will stretch your bevel to break your quadrilateral eventually.

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

To guide even further the developers using your public API, don’t hesitate to use annotations such as @FloatRange.

You can expose all attributes you see fit, such as:

  • segment count
  • segment and progress colors
  • spacing
  • animation’s duration and interpolation

Remember to call invalidate() when updating your values.

Here is the final class with public API and custom attributes for XML setup

I hope this article has inspired you to create your components. We’re striving to bring the best of our knowledge at Betclic, and we will soon come back with more insightful content!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK