2

Background

Suppose I use SpannableStringBuilder to append multiple stuff into it, and one of them is string that I format from the strings.xml file, which has a span inside:

SpannableStringBuilder stringBuilder = new SpannableStringBuilder ();
stringBuilder.append(...)...

final SpannableString span = new SpannableString(...);
span.setSpan(new BackgroundColorSpan(0xff990000), ...,...,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
stringBuilder.append(getString(R.string.string_to_format, span));

stringBuilder.append(...)...
textView.setText(stringBuilder);

The problem

Sadly, formatting such a string removes the span itself, so in my case, there won't be any text with a background color.

This happens on the line of the "getString".

What I've tried

If I just append the span alone (without "getString"), it works fine.

I also tried to investigate Html.fromHtml, but it doesn't seem to support a background color for text anyway.

The question

Is it possible to format a string that has a span, yet still have the span within?

More specifically, the input is a string A from the strings.xml file, which only has a placeholder (no special HTML tags), and another string B that is supposed to replace the placeholder at runtime. The string B should have a highlight for a partial text of itself.

In my case, the highlighted text is a something to search for within string B.

android developer
  • 114,585
  • 152
  • 739
  • 1,270

3 Answers3

2

OK, I've found an answer to my special end case, but I'd still like to know if there are better ways.

Here's what I did:

String stringToSearchAt=...
String query=...
int queryIdx = stringToSearchAt.toLowerCase().indexOf(query);
stringToSearchAt= stringToSearchAt.substring(0,  queryIdx + query.length()) + "<bc/>" + stringToSearchAt.substring(queryIdx + query.length());
final String formattedStr=getString(..., stringToSearchAt);
stringBuilder.append(Html.fromHtml(formattedStr, null, new TagHandler() {
                                    int start;

                                    @Override
                                    public void handleTag(final boolean opening, final String tag, Editable output, final XMLReader xmlReader) {
                                        switch (tag) {
                                            case "bc":
                                                if (!opening)
                                                    start = output.length() - query.length();
                                                break;
                                            case "html":
                                                if (!opening)
                                                    output.setSpan(new BackgroundColorSpan(0xff00bbaa), start, start + query.length(), 0);
                                        }
                                    }
                                }));

This is only good for my case, but in the case of general formatting, this won't suffice.

android developer
  • 114,585
  • 152
  • 739
  • 1,270
1

format a spanned string may be impossible, because it still use String.format() to format a String finilly, it's a Java API, and Span is Android API.

But I think you can use html string instead. Look at this document Styling with HTML markup.

for example:

String str = "Hi <strong><font color=\"#00bbaa\">%s</font></strong>, Welcome to <em><font color=\"#FF4081\">%s</font></em>";
String text = String.format(str, "Lucy", "Android");
Spanned spanned = Html.fromHtml(text);
// after Html.fromHtml(), you can still change the Span
SpannableString spannableString = new SpannableString(spanned);
spannableString.setSpan(new BackgroundColorSpan(0xff990000), 0, 2, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
textView.setText(spannableString);

the result

enter image description here

if you want to put the string in the string.xml, you may need to change '<' to '<', '%s' to '%1$s'.

<string name="from_offical">Hello &lt;strong>&lt;font color="#00bbaa">%1$s&lt;/font>&lt;/strong>, Welcome to &lt;em>&lt;font color="#00bbaa">%2$s&lt;/font>&lt;/em></string>
Harlan
  • 847
  • 5
  • 15
  • My use case is to search text within a given string, mark the found text, and add it all into a formatted string (from strings.xml file). before and after (as I've shown in the code above), I append other stuff (just strings for now) How can I do it? – android developer Jul 09 '16 at 07:43
  • It seems no conflict with my answer. After format, it is still a String, just a few more html tags, treat as a common String – Harlan Jul 09 '16 at 08:00
  • Can you please demonstrate? example : the string within the strings.xml file is "hello %s" . instead of the "%s" , there would be "world", and I search for "or" . The result should be "hello world", where only "or" would be highlighted – android developer Jul 09 '16 at 08:43
  • change to string to 'hello world' in java code, I think this is easy to be done. – Harlan Jul 09 '16 at 08:49
  • I think what you wrote is incorrect, but I think this is: change the string that will be inserted, to be one with HTML tags, and then format, and then use Html.fromHtml. – android developer Jul 09 '16 at 10:05
  • Sorry, my english is poor, but I think you have got the point. – Harlan Jul 09 '16 at 10:21
  • Isn't Html.fromHtml less efficient than creating the spanned objects by myself? – android developer Jul 09 '16 at 10:36
  • What about this solution: https://github.com/george-steel/android-utils/blob/master/src/org/oshkimaadziig/george/androidutils/SpanFormatter.java ? – android developer Jul 09 '16 at 10:37
  • I don't think handle the format syntax with so few code is reliable, because the syntax is very complex. – Harlan Jul 09 '16 at 11:24
  • What's "em" , and how come you don't close the "font" tag? – android developer Jul 09 '16 at 12:06
  • The text in "em" tag will be styled as italic. Sorry I forgot to close the "font" tag – Harlan Jul 09 '16 at 12:47
  • How did you do the text highlighting using HTML tags? Also, the input should be a string without any tags. Only placeholders. the text that will replace the placeholder will get a partial highlighting inside of it. – android developer Jul 09 '16 at 15:25
0

This is an old question, but I am posting a more general solution than the accepted answer.

Reference is made to the following string resources:

<string name="string_1"><b>Bolded</b> <abc>Not bolded</abc></string>  
<string name="string_2"><font bgcolor="red">Background red</font> No background color.</string>  
<string name="string_3">The <b>capital</b> of %1 is %2\n%2 is the capital of %1.</string>

Android stores string resource tags separately from strings. Tags will always be consumed when read into an app.

var s1 = getString(R.string.string_1)  
var s2 = getText(R.string.string_1)

s1 placed in a TextView will show "Bolded Not bolded" while s2 in a TextView will show "Bolded Not bolded". The tag "<abc>" has no interpretation, so it is lost.

If the background color is known at compile time then we can do the following:

textView.text = getText(R.string.string_2)

will display:

enter image description here

Of some interest is that while the font tag is supported by the Android framework and the HTML translation class (Html .java), the Html.java implementation does not support the bgcolor attribute, so the following

var s4 = "<font bgcolor=\"red\">Background red</font> No background color."  
textView.text = HtmlCompat.fromHtml(s4, FROM_HTML_MODE_LEGACY)

will not display the background color.

If the formatting is indeterminate at compile time, then we must do a little more work. Replacing string arguments with spanned text using getString(string_id, varargs) fails as the OP notes. What is an alternative?

One way is to read a string in with placeholders intact.

getString(R.string.string_3) will produce the string "The capital of %1 is %2\n%2 is the capital of %1.". We could then search for "%1", "%2", etc. and make the replacements with spanned text. In this case, the placeholder identifiers could be any unique set of characters.

It may be better, however, to use getText(R.string.string_3) which will interpret any HTML codes supported by the framework.

The following code shows hot to make substitutions of spanned text into string_3. The spanned text that will be substituted simply has the first letter highlighted.

textView.text = SpanFormatter.getText(this, R.string.string_3, { Int -> getArg(Int) })

private fun getArg(argNum: Int) =
    when (argNum) {
        1 -> { // Get the country with a highlighted first character.
            SpannableString("France").apply {
                setSpan(
                    BackgroundColorSpan(0x55FF0000),
                    0,
                    1,
                    SpannedString.SPAN_EXCLUSIVE_EXCLUSIVE
                )
            }
        }

        2 -> { // Get the capital city with a highlighted first character.
            SpannableString("Paris").apply {
                setSpan(
                    BackgroundColorSpan(0x550000FF),
                    0,
                    1,
                    SpannedString.SPAN_EXCLUSIVE_EXCLUSIVE
                )
            }
        }

        else -> throw IllegalArgumentException("$argNum is a bad argument number.")
    }
    

SpanFormatter.kt

object SpanFormatter {
    private const val END_OF_STRING = -1
    private const val SPAN_FLAGS = SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE

    fun getText(
        context: Context,
        @StringRes stringId: Int,
        argFactory: (Int) -> CharSequence,
        argStartChar: Char = '%'
    ) = getText(context.getText(stringId), argFactory, argStartChar)

    fun getText(
        cs: CharSequence,
        argFactory: (Int) -> CharSequence,
        argStartChar: Char = '%'
    ): CharSequence {
        // Mark all areas of substitution with an ArgNum span.
        val sb = SpannableStringBuilder(cs)
        var pos = sb.indexOf(argStartChar, 0)
        while (pos != END_OF_STRING) {
            var argEnd = pos + 1
            while (argEnd < sb.length && sb[argEnd].isDigit()) ++argEnd
            if (argEnd - pos > 1) {
                val argnum = sb.substring(pos + 1, argEnd).toInt()
                check(argnum > 0) {
                    "Incorrect argument number (%d) which must greater than zero.\nString: %s".format(
                        argnum
                    )
                }
                sb.setSpan(ArgMark(argnum), pos, argEnd, SPAN_FLAGS)
            }
            pos = sb.indexOf(argStartChar, argEnd)
        }
        // Replace all ArgMark spans with the appropriate substitution text.
        val argMarkSpans = sb.getSpans<ArgMark>(0, sb.length)
        argMarkSpans.forEach { argMarkSpan ->
            val start = sb.getSpanStart(argMarkSpan)
            val end = sb.getSpanEnd(argMarkSpan)
            sb.replace(start, end, argFactory(argMarkSpan.argNum))
            sb.removeSpan(argMarkSpan)
        }
        return sb
    }

    private data class ArgMark(val argNum: Int)
}

The foregoing displays:

enter image description here

And a simpler way without the use of the marking spans which aren't really needed:

SpanFormatter.kt

object SpanFormatter {
    private const val END_OF_STRING = -1

    fun getText(
        context: Context,
        @StringRes stringId: Int,
        argFactory: (Int) -> CharSequence,
        argStartChar: Char = '%'
    ) = getText(context.getText(stringId), argFactory, argStartChar)

    fun getText(
        cs: CharSequence,
        argFactory: (Int) -> CharSequence,
        argStartChar: Char = '%'
    ): CharSequence {
        val sb = SpannableStringBuilder(cs)
        var argStart = sb.indexOf(argStartChar, 0)
        while (argStart != END_OF_STRING) {
            var argEnd = argStart + 1
            while (argEnd < sb.length && sb[argEnd].isDigit()) ++argEnd
            if (argEnd - argStart > 1) {
                val argNum = sb.substring(argStart + 1, argEnd).toInt()
                argFactory(argNum).apply {
                    sb.replace(argStart, argEnd, this)
                    argEnd = argStart + length
                }
            }
            argStart = sb.indexOf(argStartChar, argEnd)
        }
        return sb
    }
}
Cheticamp
  • 61,413
  • 10
  • 78
  • 131