Making Nested Lists with Android Spannables in Kotlin

Daphne Liu
Sep 7, 2019 · 9 min read

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 tag, or an unordered (bulleted) list with the tag. The list data is then populated within each 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, 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 and methods.

In this example, 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?

is a function that takes in a HTML string, then replaces HTML tags with Android spans to be displayed. For example, will convert HTML tags like <b> into a bold span and into an italic span.

We’ll add support for our list HTML tags: , , and across all API levels. Older devices didn’t support these tags, whereas API 24 introduced support for and 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 , 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 doesn’t recognize an HTML tag, it uses a Html.TagHandler instance, where we’ll write all of our logic.


We’ll be writing , 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 and , the handleTag method in the class gets called. We’re going to override 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, is a Stack containing , 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, and .

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 .

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 and append new line by calling the helper function . 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 is . 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 method. needs to know where to put the span using and 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 , 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 method.

First, we insert the invisible marker span in . 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 to both the start and end parameters in .

Once we create a span in , we’ll be able to find it again later when we reach . Then, will use the marker to calculate where the start is. passes in as to “mark” the opening tag location of a list item inside an element.

is a class defined inside of , 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 . One to represent bulleted list items, and one to represent numbered list items. For now, we’ll just work with .

Now that is complete, let’s move on to . We start off by figuring out where the marker is located.

In , we call the 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 . 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 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 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. has two methods, and .

is where you return the size of the padding you want. renders the leading margin, where we’ll draw our bullet and number.

In the previous example, we used , or the default implementation of . 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 , a class that overrides the getLeadingMargin and drawLeadingMargin method. The few parameters in we use are , which is what you draw to, , what you draw with, , the current position of the margin, and , the invisible line that letters sit on.

is simple and just returns the margin width passed in from the constructor.

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 .

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.

is designed to support nesting, so having multiple spans applied indents the text further. Then, in drawLeadingMargin, you can read the 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 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 by the to get the correct value. We’ll add indentation as another parameter in .

Then, we pass in indentation as a parameter to from of .

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 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 , the inner list item will be indented once.

This is the end result of what our code looks like.


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 :)

The Startup

Get smarter at building your thing. Join The Startup’s +787K followers.

Sign up for Top 10 Stories

By The Startup

Get smarter at building your thing. Subscribe to receive The Startup's top 10 most read stories — delivered straight into your inbox, once a week. Take a look.

By signing up, you will create a Medium account if you don’t already have one. Review our Privacy Policy for more information about our privacy practices.

Check your inbox
Medium sent you an email at to complete your subscription.

Daphne Liu

Written by

CS @ UBC | Intern @ Lyft, Yelp, Shopify | |

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +787K followers.

Daphne Liu

Written by

CS @ UBC | Intern @ Lyft, Yelp, Shopify | |

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +787K followers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store