29

Suppose you have the following string:

String s = "The cold hand reaches for the %1$s %2$s Ellesse's";
String old = "old"; 
String tan = "tan"; 
String formatted = String.format(s,old,tan); //"The cold hand reaches for the old tan Ellesse's"

Suppose you want to end up with this string, but also have a particular Span set for any word replaced by String.format.

For instance, we also want to do the following:

Spannable spannable = new SpannableString(formatted);
spannable.setSpan(new StrikethroughSpan(), oldStart, oldStart+old.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(new ForegroundColorSpan(Color.BLUE), tanStart, tanStart+tan.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

Is there a robust way of getting to know the start indices of old and tan?

Note that just searching for 'old' returns the 'old' in 'cold', so that won't work.

What will work, I guess, is searching for %[0-9]$s beforehand, and calculating the offsets to account for the replacements in String.format. This seems like a headache though, I suspect there might be a method like String.format that is more informative about the specifics of its formatting. Well, is there?

ramaral
  • 6,009
  • 4
  • 30
  • 53
Maarten
  • 5,899
  • 6
  • 47
  • 80
  • At least for the color, you could try the replacement string `tan` and use spannable=Html.fromHtml(formatted). `old` doesn't render, though. – Maarten Jan 05 '14 at 18:15
  • 3
    This is almost certainly doable with [TextUtils.expandTemplate](https://developer.android.com/reference/android/text/TextUtils.html#expandTemplate(java.lang.CharSequence,%20java.lang.CharSequence...)). – Eric Cochran Oct 22 '16 at 23:18

5 Answers5

17

I have created a version of String.format that works with spannables. Download it and use it just like the normal version. In your case you would put the spans around the format specifiers (possibly using strings.xml). In the output, they would be around whatever those specifiers were replaced with.

George Steel
  • 657
  • 5
  • 7
  • I tried it with index 1 (%1$s) for only one argument, like in Android Developer's example, but my app crashed so i changed it to 0 and it worked. So there's a little inconsistency. http://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling – SnapDragon Dec 09 '14 at 20:07
  • 2
    @SnapDragon I just pushed a new commit with a fix. Thanks for finding the bug. – George Steel Dec 19 '14 at 22:17
15

Using Spannables like that is a headache -- this is probably the most straightforward way around:

String s = "The cold hand reaches for the %1$s %2$s Ellesse's";
String old = "<font color=\"blue\">old</font>"; 
String tan = "<strike>tan</strike>"; 
String formatted = String.format(s,old,tan); //The cold hand reaches for the <font color="blue">old</font> <strike>tan</strike> Ellesse's

Spannable spannable = Html.fromHtml(formatted);

Problem: this does not put in a StrikethroughSpan. To make the StrikethroughSpan, we borrow a custom TagHandler from this question.

Spannable spannable = Html.fromHtml(text,null,new MyHtmlTagHandler());

MyTagHandler:

public class MyHtmlTagHandler implements Html.TagHandler {
    public void handleTag(boolean opening, String tag, Editable output,
                          XMLReader xmlReader) {
        if (tag.equalsIgnoreCase("strike") || tag.equals("s")) {
            processStrike(opening, output);
        }
    }
    private void processStrike(boolean opening, Editable output) {
        int len = output.length();
        if (opening) {
            output.setSpan(new StrikethroughSpan(), len, len, Spannable.SPAN_MARK_MARK);
        } else {
            Object obj = getLast(output, StrikethroughSpan.class);
            int where = output.getSpanStart(obj);
            output.removeSpan(obj);
            if (where != len) {
                output.setSpan(new StrikethroughSpan(), where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        }
    }

    private Object getLast(Editable text, Class kind) {
        Object[] objs = text.getSpans(0, text.length(), kind);
        if (objs.length == 0) {
            return null;
        } else {
            for (int i = objs.length; i > 0; i--) {
                if (text.getSpanFlags(objs[i - 1]) == Spannable.SPAN_MARK_MARK) {
                    return objs[i - 1];
                }
            }
            return null;
        }
    }
}
Community
  • 1
  • 1
Maarten
  • 5,899
  • 6
  • 47
  • 80
5

I made simple Kotlin extension function that should solve String.format spanned issue:

fun Context.getStringSpanned(@StringRes resId: Int, vararg formatArgs: Any): Spanned {
    var lastArgIndex = 0
    val spannableStringBuilder = SpannableStringBuilder(getString(resId, *formatArgs))
    for (arg in formatArgs) {
        val argString = arg.toString()
        lastArgIndex = spannableStringBuilder.indexOf(argString, lastArgIndex)
        if (lastArgIndex != -1) {
            (arg as? CharSequence)?.let {
                spannableStringBuilder.replace(lastArgIndex, lastArgIndex + argString.length, it)
            }
            lastArgIndex += argString.length
        }
    }

    return spannableStringBuilder
}
Kriczer
  • 467
  • 2
  • 12
Pawel Cala
  • 686
  • 9
  • 14
  • 2
    not sure this solution is right because if you have multiple spannables in your arguments with different colors for example but with the same text, then they might not be in the right order because you are looking for the text content to replace by the spannable. furthermore another edge case can appear if in the string you have a sub-string with the same content as one of text of the spannable, then your function will replace at the wrong position. see one of my solution below for a better direction to take in my opinion. – Raphael C Apr 28 '20 at 08:06
1

I needed to replace %s placeholders in a String by a set of Spannables, and didn't find anything satisfying enough, so I implemented my own formatter as a kotlin String extension. Hopes it helps anyone.

fun String.formatSpannable(vararg spans: CharSequence?): Spannable {
    val result = SpannableStringBuilder()
    when {
        spans.size != this.split("%s").size - 1 ->
            Log.e("formatSpannable",
                    "cannot format '$this' with ${spans.size} arguments")
        !this.contains("%s") -> result.append(this)
        else -> {
            var str = this
            var spanIndex = 0
            while (str.contains("%s")) {
                val preStr = str.substring(0, str.indexOf("%s"))
                result.append(preStr)
                result.append(spans[spanIndex] ?: "")
                str = str.substring(str.indexOf("%s") + 2)
                spanIndex++
            }
            if (str.isNotEmpty()) {
                result.append(str)
            }
        }
    }
    return result
}

and then usage as follows

"hello %s kotlin %s world".formatSpannable(span0, span1)
Raphael C
  • 1,778
  • 1
  • 18
  • 17
0

I had the same issue pop up, but found a really great solution here: Android: How to combine Spannable.setSpan with String.format?

Check out the solution offered by @george-steel. He created a custom version of String.format which preserves spans.

Example use:

Spanned toDisplay = SpanFormatter.format(getText(R.string.foo), bar, baz, quux);
Chuck Taylor
  • 158
  • 12