Android multiline TextView with accurate width

Max Diland
6 min readJul 23, 2020

TextView is the vital widget in Android. It supports tons of capabilities related to a text and also other things like compound drawables, graphical backgrounds, font auto-sizing, break strategies, ellipsize, spannables, etc

But the major feature of the TextView is to render a text itself and seems this feature works sometimes badly or to be more accurate a bit clumsy. The case happens when a TextView needs to render some long text which takes more than one line.

I added green background to demonstrate the real dimensions of the TextView
I added a green background to demonstrate the real dimensions of the TextView

Why does this happen? Why such a weird gap appears?

In short, the TextView thinks: well, there is not enough the maximum allowed by parent width to render the entire text so I will be maximally wide and multi-line. And because for the “Incomprehensibilities” word, there is no room on the first line it goes to the second line leaving quite huge gap on the right side.

One day a designer provides a prototype to be implemented:

  • an icon should precede a text
  • the text should be aligned to view start
  • entire construction should be centered on the screen

But because TextView is multiline and takes maximum allowed by parent width the result will be like this:

Designer is not satisfied, QAs report bugs. Developer tries to fix it but no luck. Making the text centered is also looking badly.

Google and StackOverflow help to find people who suffer due to the same issue but there is no robust solution so far.

Let’s dive into TextView internals

The decision to be maximally wide was wrong and the reason why a TextView makes such a decision is android.text.Layout. The subclasses of this class are used internally in the TextView and actually not TextView itself but Layout is responsible for text-related features. And actually, TextView delegates drawing on the canvas to Layout. When TextView measures itself it takes into consideration the width provided by Layout. Layout has methods: getWidth() returns the whole width which includes a useless gap whereas getLineWidth(int line) returns specified line width. So during TextView measuring instead of relying on getWidth() better to define the largest line width using getLineWidth(int line) and rely on it.

It’s time to code

Let’s extend AppCompatTextView (to be compatible :) ) and override onMeasure:

override fun onMeasure(
widthMeasureSpec: Int,
heightMeasureSpec: Int
) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
if (layout == null || layout.lineCount < 2) return

val maxLineWidth = ceil(getMaxLineWidth(layout)).toInt()
val uselessPaddingWidth = layout.width - maxLineWidth

val width = measuredWidth - uselessPaddingWidth
val height = measuredHeight
setMeasuredDimension(width, height)
}

private fun getMaxLineWidth(layout: Layout): Float {
return (0 until layout.lineCount)
.map { layout.getLineWidth(it) }
.max()
?: 0.0f
}

We also should keep in mind that text may also be right-aligned, centered or RTL at all. So it not enough just to override onMeasure but it is also needed to affect the drawing process.

Because the TextView in order to support all it’s features has pretty long and complex onDraw method, I decided that it will take too much time to write my own onDraw with desired alterations. But all ingenious is simple! I came up with an idea to pass shifted canvas to the original onDraw method. Because I am able to control the shift I am able to control the text drawing start coordinate. Only one thing is important here is just to calculate an appropriate shifting depending on the text alignment.

Illustrative examples of the idea (do clicks on the examples):

Left aligned text
Right aligned text
Centered text

Using such a workaround we loose compound drawables drawing, background drawing features (they will work but everything will be shifted so those capabilities are unusable) although actually it is not a problem. We could achieve the same result by wrapping an ImageView and extended TextView into some ViewGroup (as most of android beginners do :).

Few words about text alignments, LTR, RTL and Canvas

Canvas’ 0;0 coordinate is always on the top-left side. So even RTL texts get drawn from left to right from the Canvas point of view. That’s why we can summarize that for us it does not matter whether a text is RTL or LTR. For us is important whether it is left-aligned or centered or right-aligned and depending on this make appropriate canvas shifting before drawing. For mixed alignment, I decided not to do anything. So I introduced an enum:

private enum class ExplicitLayoutAlignment {
LEFT, CENTER, RIGHT, MIXED
}

… and wrote a function that determines the layout explicit alignment.

private fun Layout.getExplicitAlignment(): ExplicitLayoutAlignment {
...
}

Everything is ready to be drawn!

I did override onDraw and… Here came up with another issue to fight with.

Nice to meet you Canvas.clipRect()!

This method defines the boundaries within which the drawing will be done. Everything outside the bounds will not be drawn. The sad here is that inside the original onDraw the TextView sets Canvas.clipRect() so even if I’d set Canvas.clipRect() before calling the super.onDraw() it would not bring any effect. So I dived into original onDraw and explored what affects the calculation of the Canvas.clipRect() arguments and… Compound drawables and their paddings may help! Anyway, the solution does not support compound drawable feature. By controlling the compoundPaddingRight I control the clipRect width! That’s exactly what I need! So I override getCompoundPaddingRight and in order not repeat myself I introduced drawTranslatedHorizontally() function:

private var extraPaddingRight: Int? = nullprivate fun drawTranslatedHorizontally(
canvas: Canvas,
xTranslation: Int,
drawingAction: (Canvas) -> Unit
) {
extraPaddingRight = xTranslation
canvas.save()
canvas.translate(xTranslation.toFloat(), 0f)
drawingAction.invoke(canvas)
extraPaddingRight = null
canvas.restore()
}

override fun getCompoundPaddingRight(): Int {
return extraPaddingRight ?: super.getCompoundPaddingRight()
}

And finally onDraw!

override fun onDraw(canvas: Canvas) {
if (layout == null || layout.lineCount < 2) return super.onDraw(canvas)

val explicitLayoutAlignment = layout.getExplicitAlignment()
if (explicitLayoutAlignment == ExplicitLayoutAlignment.MIXED) return super.onDraw(canvas)

val layoutWidth = layout.width
val maxLineWidth = ceil(getMaxLineWidth(layout)).toInt()

if (layoutWidth == maxLineWidth) return super.onDraw(canvas)

when (explicitLayoutAlignment) {
ExplicitLayoutAlignment.RIGHT -> {
drawTranslatedHorizontally(
canvas,
-1 * (layoutWidth - maxLineWidth)
) { super.onDraw(it) }
return
}

ExplicitLayoutAlignment.CENTER -> {
drawTranslatedHorizontally(
canvas,
-1 * (layoutWidth - maxLineWidth) / 2
) { super.onDraw(it) }
return
}

else -> return super.onDraw(canvas)
}
}

As you see I do not shift canvas before drawing in the following cases:

  • text is single-line
  • if the largest line width equal to Layout width
  • if there are lines with different alignment
  • if the text is left-aligned

And that’s actually it!

Summary

The solution is pretty simple and looks like it does not depend on the appcompat/androidx library version. The entire glist is here.

In our Plus500 android team, we develop our app supporting more than 30 languages including RTL languages like Hebrew and Arabic, supporting any screen sizes, tablets, and accessibility features like increased system UI scale and system font scale. That’s why each and every TextView is considered as potentially short and at the same time as potentially extremely long so they may take much more space than we expect. The solution in our case is really essential because there are lots of cases when the layout is built around texts and accurate TextView measurements are required.

I hope I saved somebody’s time and the implementation and article were useful and interesting. If you have any other ideas to improve the implementation or to achieve the same behavior without introducing a custom view welcome to share your thoughts. Cheers!

--

--