Making Nested Lists with Android Spannables in Kotlin
How do you display a nested list on Android? That sounds simple. After all, it’s just one button in Microsoft Word right? It’s a little bit more than that. It turned out that implementing a nested list on Android requires using low-level string rendering manipulation.
During my summer internship at Shopify, I rewrote their HTML to Android TextView parser. My goals were to address concerns such as inconsistent appearances across different API levels and the absence of padding in front of lists. When I was researching online, I found a helpful blog on how to do this for unordered (bulleted) lists. However, I needed to also support ordered (numbered) lists with nested capabilities.
So, I dived deep into the Android TextView documentation and iterated through multiple versions of my code. I came up with an implementation that renders nested list data for both ordered and unordered list consistently across all API levels.
Since there’s currently no single source of information, I decided to compile everything I learned into a demo project. I will walk through the code step-by-step in this blog post. :)
HTML Tags to Android TextView
Our lists are expressed through HTML. We can either have an ordered (numbered) list with the <ol>
tag, or an unordered (bulleted) list with the <ul>
tag. The list data is then populated within each <li>
tag. Here’s an example of how to create a list:
This is what the unordered list would look like:
How does this HTML markup get converted into a TextView? The Android SDK has a built-in solution through the HtmlCompat.fromHtml method. fromHtml takes in an HTML string as a parameter, and returns a displayable type of character sequence called “Spanned”.
This would create a Spanned “Hello World!”
What is Spanned?
Brace yourself, the following explanation sounds like lots of “Span, Span, and Spans”, but re-read this a few times and it would click. ;)
“Span” is a markup objects used to format characters inside a string. For example, StyleSpan(BOLD)
is a span that makes characters bold. Spannable is a string that can contain spans. You add and remove spans applied to the Spannable, through the setSpan
and removeSpan
methods.
In this example, setSpan
applies this span to the string starting at position 0 and ending at position of 5. This corresponds to the location of our “Hello” characters. Spannables are mutable and extend Spanned, an immutable interface that holds spans.
We need to create a span that contains either a bullet or number, with text margins applied to each list item. We’ll use the HtmlCompat.fromHtml method to help us insert the spans in the right places.
What is fromHtml?
fromHtml
is a function that takes in a HTML string, then replaces HTML tags with Android spans to be displayed. For example, fromHtml
will convert HTML tags like <b> into a bold span (StyleSpan(BOLD))
and <i>
into an italic span.
We’ll add support for our list HTML tags: <li>
, <ul>
, and <ol>
across all API levels. Older devices didn’t support these tags, whereas API 24 introduced support for <li>
and <ul>
tag but still doesn’t allow customization.
To ensure that the appearances of list items are consistent for API level 24 and below, we’ll be using HtmlCompat, the backward compatible version of Html
, to handle different API levels. We can also take control by overriding the built-in list tag handling. We can do this by replacing the HTML tags with our own custom tags. When fromHtml
doesn’t recognize an HTML tag, it uses a Html.TagHandler instance, where we’ll write all of our logic.
Html.TagHandler
We’ll be writing ListTagHandler()
, a class that implements Html.TagHandler. Html.TagHandler is an interface with a single method: handleTag, which is called when the parser fails to interpret a HTML tag. Since our HTML now has unknown tags such as <ordered>
and </ordered>
, the handleTag method in the ListTagHandler()
class gets called. We’re going to override handleTag
and tailor it to our use case.
Now, what data structure should we use to track the HTML tags? We chose a stack because we want to know what the most recent opening tag was. When we reach an opening tag, we push into the stack. When we reach a closing tag, we pop its corresponding opening tag from the top of the stack.
In the example code, lists
is a Stack containing ListTags
, an interface we’re defining to capture shared methods for the numbered and bulleted lists. As shown in the previous example, setSpan needs to know the start and end positions. Since this applies to both types of lists, we will be adding to the ListTag interface two methods, openItem
and closeItem
.
There are details we need to address to make the list look as expected. The first problem is to ensure each item on a new line. Here’s how we do it for an unordered list, which implements ListTag
.
Problem 1: Putting each list item on a new line
Currently, list items are clustered in one paragraph. To get each item to appear on a separate line, we’ll simply insert newline characters into the text.
As you can see, both openItem
and closeItem
append new line by calling the helper function appendNewLine
. This is because we want the first line of list item to start on a separate line, and other content that comes after the list to also be on a separate line. We avoid appending multiple newline characters in a row by first checking if the last character in text
is \n
. Now every list item is on a different line.
Problem 2: Figuring out where to put bullet / number
As we mentioned before, we’re inserting a span for each list item through the setSpan
method. setSpan
needs to know where to put the span using start
and end
indexes. However, we don’t know what both values are at the same time. openItem is too early and we don’t know where the closing tag is. But if we try to insert the span in closeItem
, we don’t know where the span started anymore.
The solution is to set an invisible span as a marker. This marker will be used to find the location of the opening tag inside the closeItem
method.
First, we insert the invisible marker span in openItem
. By setting the start and the end location to the same spot, the span will have a width of 0 and has no effect. This is done in the start helper method, which passes currentPositon
to both the start and end parameters in setSpan
.
Once we create a span in openItem
, we’ll be able to find it again later when we reach closeItem
. Then, closeItem
will use the marker to calculate where the start is. openItem
passes in BulletListItem()
as to “mark” the opening tag location of a list item inside an <ul>
element.
BulletListItem
is a class defined inside of Mark
, an interface we define to represent invisible spans. Since these are just markers and not used for styling, one line of class declaration is adequate.
We have two classes that implement Mark
. One to represent bulleted list items, and one to represent numbered list items. For now, we’ll just work with BulletListItem
.
Now that openItem
is complete, let’s move on to closeItem
. We start off by figuring out where the marker is located.
In closeItem
, we call the getLast
method to obtain the last span representing an opening tag. This is similar to peeking at the top of a stack.
As long at there’s a least one matching span, we’ll use it to find the opening tag location. Once that location is found we’ll pop the marker using removeSpan
. Note that getSpanStart is a built-in method.
We now know the start position. Since we’re in closeItem, we can easily get the end position.
Finally, we’ll set the styling span from the opening tag position to the closing tag position.
We can encapsulate all these functionalities in a helper method setSpanFromMark
to be shared by both unordered and ordered lists.
The implementation for ordered list is very similar, except that we have a variable for the index of the list item and increment every line. We’ll store the index inside the invisible marker and read from it later on.
Problem 3: Actually drawing the bullet/number
Now that setSpan
knows where to draw the bullet point, we need to do the drawing before every list item. How can we customize drawing a bullet point or a number, and how can we add indentation to the list items?
Android offers LeadingMarginSpan, a built-in interface to specify a the leading margin before any paragraphs. Think of a paragraph as a block of text or a list of items, this is styling for the margin on the left side of the paragraph. LeadingMarginSpan
has two methods, getLeadingMargin
and drawLeadingMargin
.
getLeadingMargin
is where you return the size of the padding you want. drawLeadingMargin
renders the leading margin, where we’ll draw our bullet and number.
In the previous example, we used LeadingMarginSpan.Standard(GAP_WIDTH)
, or the default implementation of LeadingMarginSpan
. This allows us to add padding in front of list items, but bullet point or number won’t be rendered.
This is why we created TextLeadingMarginSpan
, a class that overrides the getLeadingMargin and drawLeadingMargin method. The few parameters in drawLeadingMarker
we use are c: Canvas
, which is what you draw to, p: Paint
, what you draw with, x: Int
, the current position of the margin, and baseline: Int
, the invisible line that letters sit on.
getLeadingMargin
is simple and just returns the margin width passed in from the constructor.
drawLeadingMargin
draws a string on the canvas using paint at the x position with the baseline. This lets us render arbitrary text inside the margin itself. We can use this to display a bullet character (•)
or number character (1., 2., 3., …)
.
We only want to draw a bullet at the very first line of the list item. Lines that come after this might include newlines that already existed in the HTML, or even nested list items. We compare the current start index with the starting index of the span itself, and see if they match. If they do, then we’re at the very first line.
Here’s what it looks like with bullet and number drawn.
Problem 4: Nesting the list items
We’re almost there! The last thing we need to do is to nest the list. We have most of the infrastructure in place, but just need to take into account the indentation level.
LeadingMarginSpan
is designed to support nesting, so having multiple spans applied indents the text further. Then, in drawLeadingMargin, you can read the x
value corresponding to the indentation to draw the bullet and number characters. However, Android has some existing bugs.
Depending on the device and API version, the value of x
might always be 0 no matter what. This is the case for the API 28 emulator I’m using, but not on an API 23 emulator. This is why all the list items are aligned to the left at position 0 in the above image.
To obtain the “true x”, we need to compute it ourselves. We can multiply the marginWidth
by the indentation
to get the correct value. We’ll add indentation as another parameter in TextLeadingMarginSpan
.
Then, we pass in indentation as a parameter to TextLeadingMarginSpan
from of closeItem
.
Finally, we pass in the value for the indentation level inside of handleTag. Indentation corresponds with the number of parent tags seen when we reach a list item. As a result, we can use lists.size — 1
as the indentation value.
Nesting will only occur a list item has at least two parent lists. For example, when there are two items in lists
, the inner list item will be indented once.
This is the end result of what our code looks like.
Conclusion:
This blog has broken down how to convert HTML markup into Android TextView, draw and add proper styling for both unordered and ordered lists. I mainly focused on the unordered list as an example, but the ordered lists share the same code and utilize the same principles. All the code in the blog is in the demo project, so feel free to clone it or leave a comment. Thanks for reading my blog :)