Android multiline TextView with accurate width
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.
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):
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!