Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Mobile / Android

Distribution Bar

4.94/5 (7 votes)
16 Nov 2015CPOL7 min read 11.4K   244  
Display values as a stacked-column

Introduction

I was working on a personal app that communicates with an Arduino board over Bluetooth, and connected to the Arduino board's digital I/O pins is a serial device. After extracting numbers from this device, and putting them into three groups, I needed to display them and how they contributed to their sum. So, I was in need of a specific type of widget to do this (i.e., able to display up to three values). A ProgressBar was not suitable for this requirement as it is limited to primary and secondary values. I would need to derive a class from View and do the drawing on my own.

Background

This widget can be thought of as somewhat of a stacked column chart, which shows the relationship of individual items to the whole, comparing the contribution of each value to their total. You may have used such a chart within Microsoft Excel. That's the look I was after, without all of the chart-y stuff.

My three groups represented low, normal, and high values, with all of them being optional. So, the widget will be broken up into 1, 2, or 3 sections depending on whether that section has a value or not. If it does not, it is simply omitted from drawing. The section's value will need to be drawn "superimposed" in its section. For aesthetic purposes, I also wanted the four outside corners to be rounded, not a lot, but just enough to soften the sharp corner. Let's go...

Using the code

In the onDraw() method, I will be using a combination of drawRoundRect() and drawRect() to achieve the desired result. First, all three sections will be drawn using drawRoundRect(). Then, drawRect() will be used to go back and square those corners that are not supposed to be rounded.

So, how big does a given section need to be? Well, if a value is some percentage of the whole, a section will be that same percentage of the widget's width. For example, if the three values are 45, 360, and 120, the width of the first section will be 45/525, or 8.5% of the widget's width; the width of the second section will be 360/525, or 68.5% of the widget's width; the width of the third section will be 120/525, or 22.8% of the widget's width. If the widget's width is 464, then the widths of the three sections are 39.7, 318.1, and 106 respectively.

drawRoundRect() wants to know three things: the boundaries of the rectangle, the radius of the corners (I used a value of 11), and what kind of paint to use. The code for all three sections is basically the same except for the left and right sides of the rectangle. Since the sections are "stacked" from left to right, the left side of each successive section has the same value as the right side of the previous section. This way they are butted up next to one another. When computing the value for the right side of a section, it is the value of its left side plus whatever percentage it is of the whole. Using the numbers from above, the left and right boundaries of the first section are 0.0 and 39.7; the boundaries of the second section are 39.7 and 357.9; the boundaries of the third section are 357.9 and 464. The onDraw() method tentatively looks like:

protected void onDraw(Canvas canvas) 
{
    super.onDraw(canvas);

    int nWidth  = getWidth();
    int nHeight = getHeight();		

    float left   = 0.0f;
    float top    = 0.0f;
    float right  = 0.0f;
    float bottom = nHeight;

    // low value
    left = 0.0f;
    float low = (float) m_nLowCount / m_nTotalCount;
    right = left + (nWidth * low);
    rect.set(left, top, right, bottom);
    canvas.drawRoundRect(rect, m_radius, m_radius, paintLow);
	
    // normal value
    left = right;
    float normal = (float) m_nNormalCount / m_nTotalCount;
    right = left + (nWidth * normal);
    rect.set(left, top, right, bottom);
    canvas.drawRoundRect(rect, m_radius, m_radius, paintNormal);
    
    // high value
    left = right;
    float high = (float) m_nHighCount / m_nTotalCount; // unused because the remainder of the bar is the 'high' value
    right = nWidth;
    rect.set(left, top, right, bottom);
    canvas.drawRoundRect(rect, m_radius, m_radius, paintHigh);
}

This produces something like:

Image 1
The distribution of the three sections looks correct. What I needed to do next was square up those inside corners so that the bar looks like one solid piece. I spent a while looking around for solutions to this "problem" but came up short. Maybe there was a way that is more native to the way Android does things, but what I ended up doing was making a few calls to drawRect() with very small rectangles just big enough to cover the radius. The following image sums it up nicely:

Image 2

Since both shapes are filled with the same color, the net result is a rectangle with rounded corners on the left and square corners on the right. So, to square the right corners of the first section, I added the following code to the onDraw() method:

canvas.drawRect(rect.right - m_radius, rect.top, rect.right, rect.top + m_radius, paintLow);
canvas.drawRect(rect.right - m_radius, rect.bottom - m_radius, rect.right, rect.bottom, paintLow);

If the widget's height is 45, the first call draws a rectangle with left, top, right, and bottom boundaries of 28.7, 0, 39.7, and 11 respectively. The second call draws a rectangle with boundaries of 28.7, 34, 39.7, and 45 respectively. Since I added roughly the same code to all three sections, I ended up putting it into a separate function that is called after each drawRoundRect(). The additional if() conditions in this function were to handle cases where one or more sections were missing. For example, if there was only one section, its left and right boundaries would be equal to 0 and width, and would therefore not need its corners squared.

private void squareCorners( Paint paint, Canvas canvas, int width )
{
	
    if (rect.left == 0.0f)
    {
    	if (rect.right != (float) width)
    	{
            // square the right side of this rectangle
            canvas.drawRect(rect.right - m_radius, rect.top, rect.right, rect.top + m_radius, paint);
            canvas.drawRect(rect.right - m_radius, rect.bottom - m_radius, rect.right, rect.bottom, paint);
    	}
    }
    else
    {
    	// square the left side of this rectangle
    	canvas.drawRect(rect.left, rect.top, rect.left + m_radius, rect.top + m_radius, paint);
    	canvas.drawRect(rect.left, rect.bottom - m_radius, rect.left + m_radius, rect.bottom, paint);
    }

    if (rect.right == (float) width)
    {
    	if (rect.left != 0.0f)
    	{
            // square the left side of this rectangle
            canvas.drawRect(rect.left, rect.top, rect.left + m_radius, rect.top + m_radius, paint);
            canvas.drawRect(rect.left, rect.bottom - m_radius, rect.left + m_radius, rect.bottom, paint);
    	}
    }        
    else
    {
    	// square the right side of this rectangle
    	canvas.drawRect(rect.right - m_radius, rect.top, rect.right, rect.top + m_radius, paint);
    	canvas.drawRect(rect.right - m_radius, rect.bottom - m_radius, rect.right, rect.bottom, paint);
    }
}

This produces something like:

Image 3
Nice! All that's left now is to draw each section's value on itself. The method to do this is of course drawText(). But what coordinates will need to be used? When creating the Paint object for drawing the text, it has a setTextAlign(Align.CENTER) method where the text is drawn centered horizontally on the x and y coordinates. What this means is that when calling drawText(), the second parameter will be the (horizontal) center of the widget's width. If only Align.CENTER handled the vertical centering, too. Since drawText() interprets the third parameter as the vertical starting point, we'll need to compute that as follows:

float cy = (rectArea.height() + rectText.height()) / 2.0f;

When I got to playing around with different values for low, normal, and high, I noticed that if a value was a small enough percentage of the total, it's section would be too small to properly display its value. So I "added" a little padding to each side of the text boundaries to make sure it would fit. The function to draw centered text looks like:

private void drawCenteredText( Canvas canvas, Paint paint, String strText, RectF rectArea )
{
    // get the text boundaries
    Rect rectText = new Rect();
    textPaint.getTextBounds(strText, 0, strText.length(), rectText);
    
    // need enough room for text plus 'padding' pixels on each side
    if ((padding + rectText.width() + padding) < rectArea.width())
    {
        // draw text centered horizontally and vertically 
	    float cx = (rectArea.left + rectArea.right) / 2.0f;
	    float cy = (rectArea.height() + rectText.height()) / 2.0f;
	    canvas.drawText(strText, cx, cy, textPaint);
    }
}

This function is called after each call to squareCorners(). The distribution bar now looks like:

Image 4
Yeah! I tested this with all sorts of value combinations, especially those on the far edges of valid (e.g., [0,1,0], [0,1,1], [1,1,1], [1,300,25]). The widget displayed properly in both portrait and landscape orientations. For those cases where one or more of the values are missing, the widget responds by simply omitting that section from its drawing, like:

Image 5
Since the low value now represents 27.2% of the whole, its section has a width of 126.5. Likewise, the high value now represents 72.7% of the whole, and its section has a width of 337.4. For those cases where a section is not wide enough to hold its text's value, the text is simply omitted, like:

Image 6
Use the library just like you would any other. See here for details. When adding the widget to your layout file, be sure and use the full package name.

Points of Interest

It may depend on your monitor's resolution to notice, but I wanted the sections to have a gradient color scheme where the inner color was darker and the outer colors were slightly lighter. To do this, I created a LinearGradient object for the shader of each of the three sections. The constructor's fifth parameter is an array of colors. The sixth parameter is the gradient line of how you want those colors distributed. Since I wanted an even distribution (from the inside out), I just used null. Not wanting to create new objects inside of the onDraw() method but still needing to be able to respond to screen size/orientation changes, I updated the LinearGradient object in the onSizeChanged() method:

protected void onSizeChanged(int w, int h, int oldw, int oldh) 
{			
    Resources r = getResources();
	
    // light on top and bottom, dark in the middle
    int[] blue  = new int[]{r.getColor(R.color.ltblue), r.getColor(R.color.dkblue), r.getColor(R.color.ltblue)};
    int[] green = new int[]{r.getColor(R.color.ltgreen), r.getColor(R.color.dkgreen), r.getColor(R.color.ltgreen)};
    int[] red   = new int[]{r.getColor(R.color.ltred), r.getColor(R.color.dkred), r.getColor(R.color.ltred)};
	
    gradientLow = new LinearGradient(0, 0, 0, getHeight(), blue, null, TileMode.CLAMP);
    paintLow.setShader(gradientLow);
	
    gradientNormal = new LinearGradient(0, 0, 0, getHeight(), green, null, TileMode.CLAMP);
    paintNormal.setShader(gradientNormal);
	
    gradientHigh = new LinearGradient(0, 0, 0, getHeight(), red, null, TileMode.CLAMP);
    paintHigh.setShader(gradientHigh);
}

I wanted an above-normal size font, my first instinct was to call setTextSize() with a parameter that I would normally use in a layout file, namely 16 to 22. When I started plugging these values in, the text would look fine on the emulator, but be way too small on a real device. If I used a value to make the real device look correct (e.g., 55-ish), the text drawn on the emulator would be gargantuan. What I did to rectify this was to put a value in the dimens.xml file and just let the framework handle the scaling for me. Then it was just a matter of calling setTextSize() with that resource value, like:

textPaint.setTextSize(getResources().getDimensionPixelOffset(R.dimen.text_medium));

Now the font's size looks correct across devices. An additional feature I'd like to add, but didn't for the sake of getting this article published in a timely manner, was to have a "bubble" above or below each section with what percentage that section represented.

Enjoy!

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)