How to make bulletproof bullet lists in TextView
November 19, 2018
When there is a task ahead of you to display HTML in one of the screens, you try to avoid a WebView
as much as possible
(or at least if you are like me). WebView
is heavyweight, takes time to load, the content doesn't look native
(without additional work) and you must overcome several obstacles if you want to make it right.
I'm not saying that there is no place for WebView
, but if you want to display for example product description,
which is a simple HTML with formatted text, maybe couple of images and bullet lists, there is a better option
with Android's SDK built-in solution via Html.fromHtml
method.
This method accepts HTML text as a String and returns Spanned
with converted HTML tags
into Android built-in Spannables
like ForegroundColorSpan
,
StyleSpan
or ImageSpan
.
In API 24 the new flags
parameter was added, which controls lining between blocks of texts.
Suppose that you want to display this HTML in a TextView
with Html.fromHtml
method
<div>
<div>
First paragraph with a little of text before actual star of this demo - the list.
</div>
<ul>
<li>First option that is relatively short</li>
<li>Second option that contains as much info as the first one, but is quite long due to its representative nature</li>
<li>Three makes a crowd</li>
</ul>
<div>
Something at the end so its not so sad down here
</div>
</div>
The TextView
will have some lineSpacingExtra
so it’s more comfortable to read.
If you’ll try to run the app on your dev phone (newest Pixel with the newest Android for sure) the result is as you would expect it:
So you move on. But then the QA team will try that on different devices with different Android versions and something starts to smell here.
Pie | Nougat | Lollipop |
---|---|---|
Pie looks ok, Nougat looks somehow ok, but the bullets are not centered.
With Lollipop bullets are missing completely and the whole <ul>
block is merged in one paragraph.
The Lollipop is messed up because up until API 24, there was not a support for HTML
lists tags and they were just removed from the processing. Nougat is
messed up because of a bug in BulletSpan
class that was there up until API 28 when
the whole class was re-written. You may see
here,
the bullet is centered between top
and bottom
parameters and bottom
contains also extra space below the text.
Fixed version operates
with lineExtra
property of layout.
Let’s try to eliminate both inconveniences and make it look the same on all three platforms.
Html.TagHandler
Html.fromHtml
has override that accepts Html.TagHandler
instance.
This is an interface, that can intercept HTML text processing and evaluate unknown tags for a framework. On Lollipop
this is called for <ul>
and <li>
tags, from Nougat on it isn’t. The interface contains one method
/**
* This method will be called whenn the HTML parser encounters
* a tag that it does not know how to interpret.
*/
public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader);
opening
defines if the tag is closing or opening, tag
is the text representation of the tag, eg. li
, output is an Editable
instance with final text containing Spans
and xmlReader
is a reader of input HTML text.
The easiest solution is to replace opening li
tag with a unicode bullet character and some space.
The code can looks like this
Html.fromHtml(html, null,
Html.TagHandler { opening, tag, output, xmlReader ->
if (tag == "ul" && !opening) output.append("\n")
if (tag == "li" && opening) output.append("\n\t• ")
})
It’s not so bad but as you can see, the second line of the second option is not aligned with the beginning of the first line.
The better solution would be to mimic frameworks solution from Nougat above and create BulletSpan
, which handles the
drawing of leading margin and draws a bullet as a circle instead of single character.
override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) {
if (tag == "li" && opening) {
output.setSpan(Bullet(), output.length, output.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
}
if (tag == "li" && !opening) {
output.append("\n\n")
val lastMark = output.getSpans(0, output.length, Bullet::class.java).lastOrNull()
lastMark?.let {
val start = output.getSpanStart(it)
output.removeSpan(it)
if (start != output.length) {
output.setSpan(BulletSpan(), start, output.length, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
}
}
}
}
with this solution, it now looks like
which is exactly the same as Nougat version.
Line height problem
The solution to the second problem shares the same idea as the first one — copy framework behavior.
BulletSpan
was re-written in Pie but it doesn’t use any new api that can’t be used on older platforms.
So I’ve copied the class, converted it to Kotlin and removed internal/not needed stuff. Final ImprovedBulletSpan
is
here.
Then we have to remove old BulletSpans
and replace them with our new one. We can also modify bullet’s radius
(which was hardcoded in pixels until Pie), gap width between the bullet and the text and the color of the bullet.
@Suppress("DEPRECATION")
val htmlSpannable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Html.fromHtml(html, Html.FROM_HTML_MODE_LEGACY)
} else {
Html.fromHtml(html, null, LiTagHandler())
}
val spannableBuilder = SpannableStringBuilder(htmlSpannable)
val bulletSpans = spannableBuilder.getSpans(0, spannableBuilder.length, BulletSpan::class.java)
bulletSpans.forEach {
val start = spannableBuilder.getSpanStart(it)
val end = spannableBuilder.getSpanEnd(it)
spannableBuilder.removeSpan(it)
spannableBuilder.setSpan(
ImprovedBulletSpan(bulletRadius = dip(3), gapWidth = dip(8)),
start,
end,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
}
txt_html.text = spannableBuilder
with this code, this is how the app looks on all three platforms
Pie | Nougat | Lollipop |
---|---|---|
Conclusion
Formatting HTML text can be pretty easy without the need of WebView
, however one
has to think about platform inconsistencies to make it look the same everywhere. These were only two problems but
luckily we have the solution for both of them. I’ve created demo repository
showcasing everything that was written here so take a look and leave a comment.
TagHandler
used here doesn’t have all capabilities as its framework version in Nougat. It can’t handle nested lists or ordered lists. It was not a purpose of this article to implement it, however both features can be done.