How to make bulletproof bullet lists in TextView

November 19, 2018

hero

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:

img first

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
img old pie img old nougat img old 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);

openingdefines 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• ")
                })

This is how the app looks now img second

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

img third

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
img fourth pie img fourth nougat img fourth 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.