Over the past two years, I have regularly come across articles and libraries that attempt to improve how underline text decorations are rendered on the web. The same problem exist on Android: underline text decorations cross descenders. Compare how Android draws underlined text (top) and how it could be drawn instead (bottom):
While I wholeheartedly approve of these efforts, I have never been fond of the solutions made publicly available. The current state of the art — admittedly forced upon developers by the limitations of CSS — seems to rely on drawing linear gradients and multiple shadows (I have seen up to 12!). These solutions have the undeniable quality of working but the idea of drawing so many shadows, even without blurring them, makes the graphics programmer in me cringe. They also only work on solid color backgrounds.
On a whim, I set out to find other solutions this afternoon that would satisfy the following requirements:
- Work on older versions of Android
- Use only standard View and Canvas APIs
- Do not require overdraw or expensive shadows
- Work on any background, not just solid colors
- Do not rely on the ordering of operations in the rendering pipeline (text drawn before/after the underline should not matter)
I do have two solutions to offer and that I have made available on GitHub. One solution works on API level 19 and up and the other one works on API level 1 and up. Or at least it should work on API level 1 and up, I have not exactly tried. I trust the documentation.
You can observe and compare the two methods, called path and region, in the following screenshot:
How does it work?
The idea behind these implementations is eerily similar to the CSS approach mentioned earlier. We have an underline represented by a single straight line and all we need to do is make room for the descenders…
API level 19 (better known as KitKat) introduced a fantastic new API for paths manipulation call path ops. This API allows you for instance to build the intersection of two paths or to subtract one path from another.
Using this API, crafting our underlines becomes trivial. The first step is to get the outline of our text:
mPaint.getTextPath(mText, 0, mText.length(), 0.0f, 0.0f, mOutline);
Note that the resulting path can be used to render the original text using a fill style. We are instead going to use it for further operations.
The next step is to clip the outline with the rectangle representing the underline. This step is not entirely necessary but avoids artifacts and other approximations that could arise in the next step. To do so, we simply use an intersection path operation:
The outline path now only contains the descender bits that cross the underline:
All that is left to do is subtract the descender bits from the underline. Before doing so, we must expand the size of the original text to create gaps between the descenders and the underline. This can be achieved by stroking our clipped outline and creating a new fill path:
The stroke width determines how much space you want to leave between a descender and the underline.
The last step is to subtract the stroked, clipped outline from the underline rectangle using another path operation:
The final underline path can be drawn using a fill paint:
Regions are an efficient way to represent non-rectangular areas of the screen. You can imagine a region as a collection of rectangles aligned with the pixel boundaries of the render buffer. Regions can be used as a rasterized representation of a path. This means that if we transform a path into a region, we obtain the collection of pixels coordinates that would be affected by the path if it was drawn.
What makes regions particularly interesting, is that they offer operations similar to path operations. Two regions can be intersected, subtracted, etc. More importantly, regions have been part of the Android API since the very first version.
The region implementation is almost identical to the path implementation. The major difference lies in when and how the outline path is clipped.
Region underlineRegion = new Region(underlineRect);// Create a region for the text outline and clip
// it with the underline
Region outlineRegion = new Region();
outlineRegion.setPath(mOutline, underlineRegion);// Extract the resulting region's path, we now have a clipped
// copy of the text outline
outlineRegion.getBoundaryPath(mOutline);// Stroke the clipped text and get the result as a fill path
mStroke.getFillPath(mOutline, strokedOutline);// Create a region from the clipped stroked outline
outlineRegion = new Region();
outlineRegion.setPath(strokedOutline, new Region(mBounds));// Subtracts the clipped, stroked outline region from the underline
underlineRegion.op(outlineRegion, Region.Op.DIFFERENCE);// Create a path from the underline region
Differences between the two approaches
Due to the nature of paths and regions, there is a subtle difference between the two implementations. Because path operations work only on curves, they preserve the slant of the descenders when we subtract them from the underline. This creates gaps that run parallel to the curve slopes. This may or may not be the desired effect.
Regions on the other hand operate on whole pixels and will create clean vertical cuts through the underline (as long as your underline is thin enough). Here is a comparison between the two implementations:
Should I use this in production?
Before you try to use these techniques in your application, be aware that I have not done any performance measures at this time. Please remember that this exercise was mostly a fun programming challenge. The code provided does not try to position the underline text decoration properly depending on the font size. It also does not vary the width of the gaps based on the font size. There might also be issues that are font dependent as I have only tried the effect with some of Android’s default typefaces. Let’s call these issues exercises left to the readers.
If you were to try and use this code in your application, and I must admit I’d love to see an implementation for spans, I would encourage you to at least cache the final fill path. Since they depend only on the typeface, font size and string, a cache should be fairly trivial to implement.
In addition, the two implementations described in this article are understandably restricted by the public SDK APIs. I have a few ideas on how this effect could be achieved more efficiently if it were to be implemented directly into the Android framework.
The region variant could for instance be optimized by rendering the region itself, without going back to a path (which can cause software rasterization and GPU texture updates). Regions are internally represented as collections of rectangles and it would be trivial for the rendering pipeline to draw a series of lines or rectangles instead of rasterizing a path.
Do you want to know more about text on Android? Learn how Android’s hardware accelerated font renderer works.
Get the source of the demo on GitHub.