Skip to content

ProgressTextView

ZieIony edited this page Oct 24, 2019 · 3 revisions

The first task is to create a simple progress bar with text. I would like it to display text in one color when the text is on the 'done' part of the progress bar and in another color when the text is on the 'remaining' part.

My initial idea was to extend TextView and draw it twice internally. More or less like this:

void onDraw(Canvas canvas){
    setupDonePartColors();
    canvas.clipToDonePart();
    super.onDraw(canvas);

    setupDoneRemainingColors();
    canvas.clipToRemainingPart();
    super.onDraw(canvas);

ProgressTextView : TextView

The first version can be found here: ProgressTextView_Bad.kt. It's in Kotlin - I hope that you're fine with that.

class ProgressTextView_Bad : TextView {

    @JvmOverloads
    constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
        // ..
    }
}

So, the class extends TextView, which was my initial idea, and is closed as I don't plan to extend it in my project. As it's closed, I don't need the 3-parameter and 4-parameter constructors. I'm using only the code constructor (with Context) and the XML constructor (Context and AttributeSet). Both are declared at once using a default parameter and @JvmOverloads. Then I added a couple of attributes I needed:

<attr name="guide_progress" format="float" />
<attr name="guide_progressColor" format="color" />
<attr name="guide_progressTextColor" format="color" />

<declare-styleable name="ProgressTextView_Bad">
	<attr name="guide_progress" />
	<attr name="guide_progressColor" />
	<attr name="guide_progressTextColor" />
</declare-styleable>

All attributes are prefixed to avoid name conflicts. The styleable declaration usually is named after the view class. Notice how the attributes are first declared and then used. I could declare and use them in one place (inside declare-styleable), but it will be useful later to have them like that.

constructor(context: Context, attrs: AttributeSet? = null) : super(context, attrs) {
	attrs?.let {
		val a = getContext().obtainStyledAttributes(attrs, R.styleable.ProgressTextView_Bad)

		progress = a.getFloat(R.styleable.ProgressTextView_Bad_guide_progress, 0.0f)
		progressColor = a.getColorStateList(R.styleable.ProgressTextView_Bad_guide_progressColor)
			?: DEFAULT_PROGRESSCOLOR
		progressTextColor = a.getColorStateList(R.styleable.ProgressTextView_Bad_guide_progressTextColor)
			?: DEFAULT_PROGRESSTEXTCOLOR

		a.recycle()
	}
}

Reading attributes in code is easy, but requires some attention. In this case, I used the 2-parameter version of Context.obtainStyledAttributes(), because I don't have a theme attribute and a default style. Reading primitive attributes requires providing default values. Object attributes, like ColorStateList, can be null, but we can provide default values using Kotlin's elvis operator.

var progressTextColor
	get() = _progressTextColor
	set(value) {
		_progressTextColor = value
		invalidate()
	}

Remember that this is Kotlin and I'm not assigning attributes' values to fields, but to properties. This is important because these properties also invalidate the view.

override fun draw(canvas: Canvas) {
	// save current values
	val textColor = textColors
	val backgroundColor = background

	// set progress colors. This triggers invalidate
	setTextColor(progressTextColor)
	background = ColorDrawable(progressColor.getColorForState(drawableState, progressColor.defaultColor))

	// draw half of the view using clipping
	var saveCount = canvas.save()
	canvas.clipRect(0.0f, 0.0f, progress * width, height.toFloat())
	super.draw(canvas)
	canvas.restoreToCount(saveCount)

	// restore original values
	setTextColor(textColor)
	background = backgroundColor

	// draw the other half of the view
	saveCount = canvas.save()
	canvas.clipRect(progress * width, 0.0f, width.toFloat(), height.toFloat())
	super.draw(canvas)
	canvas.restoreToCount(saveCount)
}

This is the most important part of this class - the drawing code. It's pretty short and commented, so I focus on its issues. The main issue of this code is that it setting text color and background calls View.invalidate() and the view invalidates itself in a loop. This is bad from the performance and the energy consumption points of view. Invalidating the view also breaks the background on API>=21. There's no way to make it not call invalidate(), so I had to look for other options.

ProgressTextView : View + Canvas.drawText()

If there's no way to make it work by extending TextView, I had to extend View and draw text manually. The code can be found here: ProgressTextView

<attr name="guide_text" format="string" />
<attr name="guide_textSize" format="dimension" />
<attr name="guide_textColor" format="color" />

<declare-styleable name="ProgressTextView_Medium">
	<attr name="guide_text" />
	<attr name="guide_textSize" />
	<attr name="guide_textColor" />
	<attr name="guide_progress" />
	<attr name="guide_progressColor" />
	<attr name="guide_progressTextColor" />
</declare-styleable>

View class doesn't have any text-related attributes, so I also had to add my own text, text size and text color attributes. I reused the three progress-related attributes from the previous attempt. I also added the code responsible for reading the attributes, required properties and default values. Let's go to the drawing code:

override fun onDraw(canvas: Canvas) {
	super.onDraw(canvas)

	paint.getTextBounds(text, 0, text.length, bounds)

	// draw half of the view using clipping
	var saveCount = canvas.save()
	canvas.clipRect(0.0f, 0.0f, progress * width, height.toFloat())

	paint.color = progressColor.getColorForState(drawableState, progressColor.defaultColor)
	canvas.drawRect(0.0f, 0.0f, progress * width, height.toFloat(), paint)

	canvas.translate(paddingLeft.toFloat(), max(paddingTop.toFloat(), (height + bounds.height())/2.0f))
	paint.color = progressTextColor.getColorForState(drawableState, progressTextColor.defaultColor)
	canvas.drawText(text, 0.0f, 0.0f, paint)

	canvas.restoreToCount(saveCount)

	// draw the other half of the view
	// ..
}

As you can see I'm using Canvas.drawText() for text drawing. This approach gives me more flexibility than asking TextView to draw itself. It's also much simpler - I have to manually measure and position the text.

This version is fine, but I don't like the manual work I have to do with text. It also doesn't support wrap_content.

ProgressTextView : View + StaticLayout + onMeasure

TextView for text measuring, positioning and drawing internally uses special layouts: StaticLayout, DynamicLayout and BoringLayout. StaticLayout is pretty easy to use and works fine when you have to draw some text manually.

var textSize
    get() = paint.textSize
    set(value) {
        paint.textSize = value
        requestLayout()
    }

var text
    get() = _text
    set(value) {
        _text = value
        requestLayout()
    }

Setting text or text size can change the text layout, so I'm calling View.requestLayout(). It will cause the view to measure itself, layout and recreate the text layout. The most interesting part of this view is in onMeasure().

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)
    var width = paddingLeft + paddingRight
    var height = paddingTop + paddingBottom

    // ..
}

You can read more about measuring here.

I'm using MeasureSpec helpers to get modes and sizes. I'm interested in handling padding, so I'm grabbing that as well.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // ..

    if (widthMode == MeasureSpec.EXACTLY) {
        width = widthSize
    } else {
        layout = StaticLayout(text, paint, Integer.MAX_VALUE, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false)
        width += (0 until layout!!.lineCount).map { layout!!.getLineWidth(it) }.max()!!.toInt()

        width = max(width, suggestedMinimumWidth)
        if (widthMode == MeasureSpec.AT_MOST)
            width = min(widthSize, width)
    }

    // ..
}

Width measuring is pretty simple. If the view has its width set to match_parent, it gets MeasureSpec.EXACTLY and I have to respect that. If not, I'm measuring the text and trying to fit it into constraints.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // ..

    if (heightMode == MeasureSpec.EXACTLY) {
        height = heightSize
    } else {
        layout = StaticLayout(text, paint, width, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false)
        height += layout!!.height

        height = max(height, suggestedMinimumHeight)
        if (heightMode == MeasureSpec.AT_MOST)
            height = min(height, heightSize)
    }

    setMeasuredDimension(width, height)
}

View's height depends on the measured width. Android Studio warns about allocations in onMeasure() (layout is allocated twice), but TextView makes a new layout in onMeasure() as well, so I guess that this is fine.

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
    super.onLayout(changed, left, top, right, bottom)
    layout = StaticLayout(text, paint, width, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false)
}

I'm making a new layout once again in onLayout() because measured width and height are only a suggestion and the view can get another size eventually.

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)

    // draw half of the view using clipping
    var saveCount = canvas.save()
    canvas.clipRect(0.0f, 0.0f, progress * width, height.toFloat())

    paint.color = progressColor.getColorForState(drawableState, progressColor.defaultColor)
    canvas.drawRect(0.0f, 0.0f, progress * width, height.toFloat(), paint)

    layout?.let {
        canvas.translate(paddingLeft.toFloat(), max(paddingTop.toFloat(), (height - it.height) / 2.0f))
        paint.color = progressTextColor.getColorForState(drawableState, progressTextColor.defaultColor)
        it.draw(canvas)
    }

    canvas.restoreToCount(saveCount)


    // draw the other half of the view
    // ..
}

The drawing code is pretty simple. The text layout has all sizes measured and can draw itself nicely on the canvas. And the result once again.