Written by Damian Petla
Android Developer
Published September 21, 2015

Rich TextView styling with spans for Android

I was recently inspired by Lisa Wray and her presentation at the Droidcon NYC conference – Beautiful Typography on AndroidI am going to show you how to use spans to enrich the TextView component. It’s based on a true story 🙂

My project Omni, that I am working on at Schibsted Tech Polska, requires some rich styling. Being a news app there is a lot of text and images to handle.

To make it work as our designer wished, I had to implement a custom component and take care of measurement, layout and drawing. The component wraps text around images, uses 3 different custom fonts, multiple colors, draws colored dots, etc.

I will focus here on a simpler use case where I was able to use spans instead. Below I have marked my custom component with a red rectangle. Instead of custom view I am going to use single TextView.

omni_text_ui

SPANS, WHAT IS THAT?

If you ask a regular Android developer about spans, you will probably hear: What is that? 

It is not because we love plain texts. It is because it is not well documented, there is a lack of tutorials, trainings and so on.

When I looked at the Android API searching for spans, I found many classes that include the span word, but I couldn’t get a clear idea about how this stuff works.

Fortunately, thanks to Lisa and other materials, I was able to start playing with spans and realised that it is very easy and powerful.

I am not going to explain all details about spans and possible scenarios. I just want you to have a basic understanding and show how it could be used. It is up to you to expand your knowledge and become a Spans expert.

So, think of a single span as an attribute that can be applied to a single character or a paragraph. These “attributes” define the text formatting (metrics/size/appearance). When you apply a span to a part of the text, you have to specify a range of indexes (start and end of that subtext). You can apply multiple spans to the same range of text.

TO-DO

Let us start splitting the text into 3 parts:

  • colored dot (CenteredImageSpan)
  • vignette with light gray color, 13sp size and custom font Benton -Medium (CustomTypefaceSpan, TextAppearanceSpan)
  • article text with black color, 15sp size and custom font Benton – Regular (use TextView defaults)

Note that the code presented below is written in Kotlin. It is my primary language now, but it’s not that different from Java. So any Android developer should be able to understand that code.

COLORED DOT

ImageSpan – is a very useful span that replaces text with a specified image. That was my first choice for colored dot. Unfortunately, it is aligned only to the bottom or the baseline of the line and I needed to center my dot vertically. So I decided to write my own span. Looking at some StackOverflow answers I considered extending ImageSpan. However, that seemed to be too complex for such a simple task. ImageSpan only loads drawables from different locations. More interesting are parents of that class such as DynamicDrawableSpan and ReplacementSpan.

DynamicDrawableSpan – creates a weak reference to a once loaded drawable. This is an optimisation for drawables loaded from anywhere else than the memory. It also calculates the span size and draws it.

Since I load the drawables from the memory, I don’t need caching. I therefore decided to override ReplacementSpan directly and copy the getSize() and draw() methods from DynamicDrawableSpan.

class CenteredImageSpan(val drawable: Drawable, val gap: Int) : ReplacementSpan() {

    init {
        drawable.setBounds(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight())
    }

    override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
        val b = drawable
        canvas.save()

        var transY = bottom - (b.getBounds().bottom / 2)
        val fm = paint.getFontMetricsInt()
        transY -= fm.descent - (fm.ascent / 2)

        canvas.translate(x, transY.toFloat())
        b.draw(canvas)
        canvas.restore()
    }

    override fun getSize(paint: Paint?, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
        val d = drawable
        val rect = d.getBounds()

        if (fm != null) {
            fm.ascent = -rect.bottom
            fm.descent = 0

            fm.top = fm.ascent
            fm.bottom = 0
        }

        return rect.right + gap
    }

}

The getSize() method is almost not changed at all, I just added a gap parameter so I can make some extra space between the colored dot and the rest of text.

The draw() method is changed a bit to move colored dot up enough to be centered vertically

VIGNETTE

To be able to apply a custom font from the assets, you have to provide your own span. This is very easy . Thanks again to Lisa Wray for providing the code! The implementation was super easy,  but I needed to know which class should be extended.

class CustomTypefaceSpan(val typeface: Typeface) : MetricAffectingSpan() {
    override fun updateMeasureState(p: TextPaint) {
        p.setTypeface(typeface)
    }

    override fun updateDrawState(tp: TextPaint) {
        tp.setTypeface(typeface)
    }

}

Lisa explains that class best in her presentation.

To specify a size and color we can use the built in class TextAppearanceSpan. First we create the new style.

<style name="VignetteTextAppearance">
    <item name="android:textColor">@color/vignette_color</item>
    <item name="android:textSize">@dimen/article_vignette_size</item>
</style>

Then we can use a class constructor to create a new span

TextAppearanceSpan(context, R.style.VignetteTextAppearance)

ARTICLE TEXT

In the case article text we don’t need to specify any spans. We can set the TextView properties either in XML or programatically.

<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools"
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          android:lineSpacingExtra="@dimen/article_text_line_extra_spacing"
          android:paddingBottom="@dimen/article_list_item_margin_small"
          android:paddingLeft="@dimen/article_list_item_margin_large"
          android:paddingRight="@dimen/article_list_item_margin_large"
          android:paddingTop="@dimen/article_list_item_margin_small"
          android:textColor="@color/omni_primary_text_color"
          android:textIsSelectable="true"
          android:textSize="@dimen/article_text_size"
          tools:text="@string/mock_article_body"/>
textView.setTypeface(mRegularTypeface)

PUT IT ALL TOGETHER

To make it all work we need SpannableString that handles spans. For our convenience we can use the builder class SpannableStringBuilder.

val centeredImageSpan = CenteredImageSpan(dotDrawable, 10)
val vignetteTypefaceSpan = CustomTypefaceSpan(mMediumTypeface)
val vignetteTextAppearance = TextAppearanceSpan(context, R.style.VignetteTextAppearance)
val spanBuilder = SpannableStringBuilder()
    .append(" ", centeredImageSpan)
    .append(vignetteText, vignetteTypefaceSpan, vignetteTextAppearance)
    .append(articleText)
textView.setTypeface(mRegularTypeface)
textView.setText(spanBuilder)

Because there is no append() method that takes multiple spans, I created my own:

public fun SpannableStringBuilder.append(text: CharSequence, vararg what: Any): SpannableStringBuilder {
    val start = length()
    append(text)
    val end = length()
    for (any in what) {
        setSpan(any, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    }
    return this
}

That’s it. End of story 🙂

Written by Damian Petla
Android Developer
Published September 21, 2015