Drawing a rounded corner background on text
Let’s say that we need to draw a rounded corner background on text, supporting the following cases:
- Set the background on one line text
- Set the background on text over two or multiple lines
- Set the background on right-to-left text
How can we implement this? Read on to find out, or jump directly to the sample code.
To span or not to span? This is the question!
In previous articles we’ve covered styling sections of text (even internationalized text). The solution involved using either framework or custom spans. While spans are a great solution in many cases, they do have some limitations that make them unsuitable for our problem. Appearance spans, like BackgroundColorSpan
, give us access to the TextPaint
, allowing us to change elements like the background color of text, but are only able to draw a solid color and can’t control elements like the corner radius.
We need to draw a drawable together with the text. We can implement a custom ReplacementSpan
to draw the background and the text ourselves. However ReplacementSpans
cannot flow into the next line, therefore we will not be able to support a multi-line background. They would rather look like Chip, the Material Design component, where every element must fit on a single line.
Spans work at the TextPaint
level, not the layout level. Therefore, they can’t know about the line number the text starts and ends on, or about the paragraph direction (left-to-right or right-to-left)
Solution: custom TextView
Depending on the position of the text, we need to draw four different drawables as text backgrounds:
- Text fits on one line: we only need one drawable
- Text fits on 2 lines: we need drawables for the start and end of the text
- Text spans multiple lines: we need drawables for the start, middle and end of the text.
To position the background, we need to:
- Determine whether the text spans multiple lines
- Find the start and end lines
- Find the start and end offset depending on the paragraph direction
All of these can be computed based on the text Layout. To render the background behind the text we need access to the Canvas
. A custom TextView
has access to all of the information necessary to position the drawables and render them.
Our solution involves splitting our problem into 4 parts and creating classes dealing with them individually:
- Marking the position of the background is done in the XML resources with
Annotation
spans and then, in the code, we compute the positions in theTextRoundedBgHelper
- Providing the background drawables as attributes of the TextView — implemented in
TextRoundedBgAttributeReader
- Rendering the drawables depending on whether the text runs across one or multiple lines —
TextRoundedBgRenderer
interface and its implementations:SingleLineRenderer
andMultiLineRenderer
- Supporting custom drawing on a
TextView
—RoundedBgTextView
, a class that extendsAppCompatTextView
, reads the attributes with the help ofTextRoundedBgAttributeReader
, overridesonDraw
where it usesTextRoundedBgHelper
to draw the background.
Finding out where the background should be drawn
We specify parts of the text that should have a background by using Annotation
spans in our string resources. Find out more about working with Annotation spans from this article.
We created a TextRoundedBgHelper
class that:
- Enables us to position the background based on the text directionality: left-to-right or right-to-left
- Renders the background, based on the drawables and the horizontal and vertical padding
In the TextRoundedBgHelper.draw
method, for every Annotation
span found in the text, we get the start and end index of the span, find the line number for each and then compute the start and end character offset (within the line). Then, we use the TextRoundedBgRenderer
implementations to render the background.
fun draw(canvas: Canvas, text: Spanned, layout: Layout) {
// ideally the calculations here should be cached since
// they are not cheap. However, proper
// invalidation of the cache is required whenever
// anything related to text has changed.
val spans = text.getSpans(0, text.length, Annotation::class.java)
spans.forEach { span ->
if (span.value.equals("rounded")) {
val spanStart = text.getSpanStart(span)
val spanEnd = text.getSpanEnd(span)
val startLine = layout.getLineForOffset(spanStart)
val endLine = layout.getLineForOffset(spanEnd)
// start can be on the left or on the right depending
// on the language direction.
val startOffset = (layout.getPrimaryHorizontal(spanStart)
+ -1 * layout.getParagraphDirection(startLine) * horizontalPadding).toInt()
// end can be on the left or on the right depending
// on the language direction.
val endOffset = (layout.getPrimaryHorizontal(spanEnd)
+ layout.getParagraphDirection(endLine) * horizontalPadding).toInt()
val renderer = if (startLine == endLine) singleLineRenderer else multiLineRenderer
renderer.draw(canvas, layout, startLine, endLine, startOffset, endOffset)
}
}
}
Provide drawables as attributes
To easily supply drawables for different TextViews
in our app, we define 4 custom attributes corresponding to the drawables and 2 attributes for the horizontal and vertical padding. We created a TextRoundedBgAttributeReader
class that reads these attributes from the xml layout.
Render the background drawable(s)
Once we have the drawables we need to draw them. For that, we need to know:
- The start and end line for the background
- The character offset where the background should start and end at.
We created an abstract class TextRoundedBgRenderer
that knows how to compute the top and the bottom offset of the line, but exposes an abstract draw
function:
abstract fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
)
The draw
function will have different implementations depending on whether our text spans a single line or multiple lines. Both of the implementations work on the same principle: based on the line top and bottom, set the bounds of the drawable and render it on the canvas.
The single line implementation only needs to draw one drawable.
The multi-line implementation needs to draw the start and the end of line drawables on the first, and last line respectively, then for each line in the middle, draw the middle line drawable.
Supporting custom drawing on a TextView
We extend AppCompatTextView
and override onDraw
to call TextRoundedBgHelper.draw
before letting the TextView
draw the text.
Caveat: Our sample makes all the calculations for every TextView.onDraw
method call. If you’re planning on integrating this implementation in your app, we strongly suggest to modify it and cache the calculations done in TextRoundedBgHelper
. Then, make sure you invalidate the cache whenever anything related to text, like text color, size or other properties, has changed.
The Android text APIs allow you a great deal of freedom to perform styling. Simple styling is possible with text attributes, styles, themes and spans. Complex custom styling is achievable by taking control of the onDraw
method and making decisions on what and how to draw on the Canvas
. Check out our sample for all implementation details.
What kind of text styling did you have to do that required custom TextView
implementations? What kind of issues did you encounter? Tell us in the comments!