Expandable text with “read more” action in Android — not an easy task

At Wolt, user interfaces in consumer applications are highly valued. Not only does a good UI design deliver a modern and friendly look to the app, but it also boosts brand recognition. These two direct benefits can bring more indirect rewards to the product such as higher customer engagement and retention.

As mobile developers, we try our best to make our UI designers happy by fulfilling their artistic visions and pushing the app to its best form and functionality for customers. But to make those visions come true, we occasionally spend time scratching our heads hard as custom UI components aren’t always straight-forward to implement. An expandable text is one example. In our Wolt app, it looks like this: 

Figure 1. An example of an expandable text

Why it can get tricky to add expandable text in your app

When a piece of text is too long, the designer reasonably asks if it’s possible to truncate the text by having a certain line count limitation. In addition, an expand-action (i.e. “read more”) should be shown at the end of the last line. When the user taps the text, it expands with an animation to show the full content.

The idea is simple, but on the Android side, the implementation isn’t easy.

Let’s break down the problem: why is this difficult to implement? The first question is where do we truncate the text so that adding expand-action will make the final text fit nicely into the limited line count. The second thing is how to measure the height of the view when it’s collapsed or expanded to form its size change animation. This blog focuses on the first problem.

Before jumping into the problem, let’s agree on the terms that are used in this blog because they might be confusing:

TermExplanationExample
Original textThe text that is in original form without being truncatedThis is a long text that should be truncated at one point
Expand actionA highlighted piece of text, usually at the end of a long text, that indicates the full text will be shown if it’s clickedread more
Truncated textThe text that is truncated at some pointThis is a long… 
Final displayed textThe truncated text with the expand action that will be displayed to end userThis is a long…read more

We're hiring. Check out our open roles from here.

So, where or how to truncate the text…?

Luckily, the Android framework provides a powerful function to get an ellipsized text given its original form, the paint from the TextView, the text’s total width when it is written in one line, and the truncate position:

TextUtils.ellipsize(originalText, paint, totalWidth, END) (1)

3 out of 4 parameters (original text, paint object, truncate position) can be found quite easily. Only the total width is quite tricky to calculate.  

Figure 2. Visualization of the first approach to calculate total width of the truncated text

A naive approach would be multiplying the width of a line and the limited line count to take the total width of the final text and deduct the expand-action width. Assuming the expand-action width is always less than a line width (I cheated to make the problem simpler and easier to follow :p) , it can be calculated using the following function:

val expandActionWidth = paint.measureText(expandAction) (2)

Please refer to Figure 2 above to understand the idea better.

Applying the function (1) and (2), we get the truncated text. All we need to do is append the expand-action to it and set the final displayed text to the TextView. The code will look somewhat like this (this is onMeasure function of the TextView’s subclass):

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
   // 1. get the view's given width
   val givenWidth = MeasureSpec.getSize(widthMeasureSpec)
   // 2. remove space for horizontal paddings and drawables
   val lineWidth = givenWidth - compoundPaddingStart - compoundPaddingEnd
   // 3. w = lw * l - ew
   val expandActionWidth = paint.measureText(expandAction)
   val limitedLineCount = 3
   val truncatedTextWidth = lineWidth * limitedLineCount - expandActionWidth
   val truncatedText = TextUtils.ellipsize(originalText, paint, truncatedTextWidth, END)
   // 4. Append expand action to truncated text
   val finalDisplayedText = "$truncatedText$expandAction"
   text = finalDisplayedText
   super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

Notice that I have 3 as the limited line count. Let’s see how this looks.

Figure 3. Actual result of the first approach

This is wrong 🤔 🤔 🤔  Why do we have four lines showing instead of three? And why doesn’t the expand action appear at the end of the line?

Remember the needed width for the third parameter of function (1) is the total width when the text is written in one line. By doing some nitpicking, we can see the problem:

Figure 4. Explanation for first approach’s problem

There are some white spaces at the end of some lines because the next word doesn’t fit into the previous line, thus, causing a line break. If we write the text in one line, we never include those spaces in the text. As they are included in the current calculation, the width is larger than what we want, it results in an extra line. This calculation needs to be more accurate. Here comes a better way.

The more accurate calculations

Figure 5. Visualization of second approach to calculate total width of the truncated text

Because each line has its own width, we calculate the total width of the truncated text by summing the width of each line and deducting the expand-action width from that sum. This looks more precise mathematically. But is there any way to measure the width of a single line?

Fortunately, Android has a class called StaticLayout. This class provides developers with all text measurement capabilities and more. So now, here is the code with detailed explanations.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
   // 1. get the view's given width
   val givenWidth = MeasureSpec.getSize(widthMeasureSpec)
   // 2. remove space for horizontal paddings and drawables
   val maximumLineWidth = givenWidth - compoundPaddingStart - compoundPaddingEnd
   // 3. Create static layout
   val limitedLineCount = 3
   val staticLayout = StaticLayout.Builder
       // provide the original text & the maximum line width
       .obtain(originalText, 0, originalText.length, paint, maximumLineWidth)
       // provide limited line count
       .setMaxLines(limitedLineCount) 
       // provide all other text configurations
       .setIncludePad(false) 
       .setEllipsize(END)
       .setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
       .build()
   // 4. Calculate text using given formula
   val sumOfLw = (0 until staticLayout.lineCount).sumOf { staticLayout.getLineWidth(it).toInt() }
   val expandActionWidth = paint.measureText(expandAction)
   val truncatedTextWidth = sumOfLw - expandActionWidth
   val truncatedText = TextUtils.ellipsize(originalText, paint, truncatedTextWidth, END)
   // 5. Append expand action to truncated text
   val finalDisplayedText = "$truncatedText$expandAction"
   text = finalDisplayedText
   super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

For SDK 22 or lower, you need to use the deprecated constructor of StaticLayout instead of its Builder’s static method `obtain` to retrieve an instance of this class.

The previous lineWidth becomes maximumLineWidth because the actual line width doesn’t need to take white spaces into account. Providing all information to StaticLayout, we can then get each line width by calling getLineWidth on the layout instance. The code looks a bit longer, but the result is satisfying.

Figure 6. Actual result of the second approach

Final thoughts and learnings

It has been a fun experience learning about this topic. When working with text, always try to open your eyes as wide as possible, a tiny mismeasurement can lead to undesired behavior. The second approach works for now, but working with text is always tricky and full of surprises. Also, the approach in this blog didn’t take edge cases into account. For example, if the text is too short and doesn’t require truncation, we don’t really need to append the expand action at the end. However, I hope it gives you an idea how to solve this problem.

This solution may break when changing the text wrapping style or even switching to a new writing system such as Arabic. If it happens, don’t panic, try to understand how the writing system is different from others and fine tune the calculations even more. Maybe the fix has been there all along in some of the existing APIs. In the end, the second approach may not be the last approach, and it’s always our job to find better solutions.

Apart from this correct rendering work, expanding and collapsing animations are also a key feature for this component. If you’re wondering how this motion part is done, I shamelessly attach my Github repository for this custom view.


If you want to work with us and create meaningful products, I proudly present Wolt’s jobs page.

We're hiring. Check out our open roles from here.