Concatenating arbitrary Text Spans in Android

Styling text spans with kotlin extensions

TJ Dahunsi

Dec 12 2018 · 6 mins

Categories:

Spans and Spannable character sequences in Android are a lovely concept. They provide a layer of abstraction over any Android widget that displays text, which allows you to have complex formatting in your text without you having to resort to using a WebView with tedious HTML or multiple concatenated TextViews, FontTextViews, or what have you.

Last year, Florina wrote an excellent blog post detailing the internals of the Span API in Android including which spans to use and when, and how to create different kinds of spans within any single Charsequence. Unfortunately, the API for creating the aforementioned spans can be a bit tedious. They require knowledge of the length of each span, and the specificities of each span flag which are simultaneously verbose and confusing in their nomenclature, a rather extraordinary feat. Try saying the following 10 times fast: SPAN_INCLUSIVE_EXCLUSIVE SPAN_INCLUSIVE_INCLUSIVE SPAN_EXCLUSIVE_EXCLUSIVE SPAN_EXCLUSIVE_INCLUSIVE.

In my particular case, I wanted to create a “Terms and Conditions” link with separate clickable spans for terms and conditions respectively. After painstakingly using the old API, I realized that counting the length of each span would not scale well for internationalization of text so I sought a way to build a more scalable solution.

Enter the SpanBuilder, a utility class that enables the concatenation of any Charsequence with another, while maintaining the distinct spans and characteristics of all individual spans joined. Its basic operating principle is based around the following methods gleaned from the Android developer documentation:

/** * Returns a CharSequence that concatenates the specified array of CharSequence * objects and then applies a list of zero or more tags to the entire range. * * @param content an array of character sequences to apply a style to * @param tags the styled span objects to apply to the content * such as android.text.style.StyleSpan * */ private static CharSequence applyStyles(CharSequence[] content, Object[] tags) { SpannableStringBuilder text = new SpannableStringBuilder(); openTags(text, tags); for (CharSequence item : content) { text.append(item); } closeTags(text, tags); return text; } /** * Iterates over an array of tags and applies them to the beginning of the specified * Spannable object so that future text appended to the text will have the styling * applied to it. Do not call this method directly. */ private static void openTags(Spannable text, Object[] tags) { for (Object tag : tags) { text.setSpan(tag, 0, 0, Spannable.SPAN_MARK_MARK); } } /** * "Closes" the specified tags on a Spannable by updating the spans to be * endpoint-exclusive so that future text appended to the end will not take * on the same styling. Do not call this method directly. */ private static void closeTags(Spannable text, Object[] tags) { int len = text.length(); for (Object tag : tags) { if (len > 0) { text.setSpan(tag, 0, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } else { text.removeSpan(tag); } } }

The above makes it easier to open and closes different spans without worrying about what flags to use. The second piece of the puzzle however is the ability to concatenate spans, similar to the String.format method, replete with object arguments of Integers, Strings, or even other spans.

After scouring the Android’s docs and StackOverflow, I came upon some very handy utility methods, applied the Builder Pattern to them and came up with my very own SpanBuilder class.

The SpanBuilder lets you create a simple span from any Charsequence and prepend any Charsequence (that may be spanned itself) while preserving formatting. It also provides a similar String.format method for spanned Strings as well. For that, I defer to George Steel’s excellent implementation:

package org.oshkimaadziig.george.androidutils; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.SpannedString; import android.text.Spannable; /** * Provides {@link String#format} style functions that work with {@link Spanned} strings and preserve formatting. * * @author George T. Steel * */ public class SpanFormatter { public static final Pattern FORMAT_SEQUENCE = Pattern.compile("%([0-9]+\\$|<?)([^a-zA-z%]*)([[a-zA-Z%]&&[^tT]]|[tT][a-zA-Z])"); private SpanFormatter(){} /** * Version of {@link String#format(String, Object...)} that works on {@link Spanned} strings to preserve rich text formatting. * Both the {@code format} as well as any {@code %s args} can be Spanned and will have their formatting preserved. * Due to the way {@link Spannable}s work, any argument's spans will can only be included <b>once</b> in the result. * Any duplicates will appear as text only. * * @param format the format string (see {@link java.util.Formatter#format}) * @param args * the list of arguments passed to the formatter. If there are * more arguments than required by {@code format}, * additional arguments are ignored. * @return the formatted string (with spans). */ public static SpannedString format(CharSequence format, Object... args) { return format(Locale.getDefault(), format, args); } /** * Version of {@link String#format(Locale, String, Object...)} that works on {@link Spanned} strings to preserve rich text formatting. * Both the {@code format} as well as any {@code %s args} can be Spanned and will have their formatting preserved. * Due to the way {@link Spannable}s work, any argument's spans will can only be included <b>once</b> in the result. * Any duplicates will appear as text only. * * @param locale * the locale to apply; {@code null} value means no localization. * @param format the format string (see {@link java.util.Formatter#format}) * @param args * the list of arguments passed to the formatter. * @return the formatted string (with spans). * @see String#format(Locale, String, Object...) */ public static SpannedString format(Locale locale, CharSequence format, Object... args){ SpannableStringBuilder out = new SpannableStringBuilder(format); int i = 0; int argAt = -1; while (i < out.length()){ Matcher m = FORMAT_SEQUENCE.matcher(out); if (!m.find(i)) break; i=m.start(); int exprEnd = m.end(); String argTerm = m.group(1); String modTerm = m.group(2); String typeTerm = m.group(3); CharSequence cookedArg; if (typeTerm.equals("%")){ cookedArg = "%"; }else if (typeTerm.equals("n")){ cookedArg = "\n"; }else{ int argIdx = 0; if (argTerm.equals("")) argIdx = ++argAt; else if (argTerm.equals("<")) argIdx = argAt; else argIdx = Integer.parseInt(argTerm.substring(0, argTerm.length() - 1)) -1; Object argItem = args[argIdx]; if (typeTerm.equals("s") && argItem instanceof Spanned){ cookedArg = (Spanned) argItem; }else{ cookedArg = String.format(locale, "%"+modTerm+typeTerm, argItem); } } out.replace(i, exprEnd, cookedArg); i += cookedArg.length(); } return new SpannedString(out); }

With these two crucial pieces, it becomes easy to use the builder pattern to create various combinations of distinct spans.

In the following example, the entire text below is comprised of a single Charsequence comprised of multiple spans; the implementation follows shortly. The best part of the api is that it works for any Charsequence, independent of Spannable implementation. This works especially well with Emojis on Android and the emoji support library. This lets you avoid inheriting from the EmojiTextView and letting the support library transform the text from your API to a charsequence, whilst converting any emoji characters to whatever implementation is most appropriate for your API level using the EmojiCompat.get().process(“source string”) method.

A block of text consisting of a single Charsequence A block of text consisting of a single Charsequence

@Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_spanbuilder, container, false); TextView textView = rootView.findViewById(R.id.text); Context context = textView.getContext(); CharSequence text = SpanBuilder.of("This is a regular span") .prependSpace() .prepend(".") .prepend(1) .appendNewLine() .append(2) .append(".") .appendSpace() .append(SpanBuilder.of("This is a colored span") .color(context, R.color.colorPrimaryDark) .build()) .appendNewLine() .append(3) .append(".") .appendSpace() .append(SpanBuilder.of("This is an italicized span") .italic() .build()) .appendNewLine() .append(4) .append(".") .appendSpace() .append(SpanBuilder.of("This is an underlined span") .underline() .build()) .appendNewLine() .append(5) .append(".") .appendSpace() .append(SpanBuilder.of("This is a bold span") .bold() .build()) .appendNewLine() .append(6) .append(".") .appendSpace() .append(SpanBuilder.of("This is a resized span") .resize(1.2F) .build()) .appendNewLine() .append(7) .append(".") .appendSpace() .append(SpanBuilder.of("This is a clickable span") .click(textView, () -> Snackbar.make(textView, "Clicked text!", Snackbar.LENGTH_SHORT).show()) .build()) .appendNewLine() .build(); textView.setText(text); return rootView; }

This example is a bit contrived, as each line may be better served as an ItemView in a RecyclerView, nonetheless it shows the ease of use of the API.

The SpanBuilder class is available in my Android bootstrap library in the following repository which also includes the example above in the SpanBuilderFragment.

There you have it! String formatting in Android made just that more pleasant. Big thanks to Lola for helping me edit and I’d like to wish you all a happy new year!

,