at it's worst
Color support for XML string resources in Android
So you read somewhere that Android supports <font>
tags to color string
resources, when used with Resources#getText()
? Well, yeah, except…
… there’s no uniform way across SDK platform versions to represent “this text should be red” with just a String resource.
Following is a summary and excerpt of the code handling XML font colors in Android. I tried to make it easier to digest than just raw code. The codes did not change between the listed versions unless otherwise noted. The sources come from
android.content.res.StringBlock
: parses tags and attributescom.android.internal.util.XmlUtils
: parses numbers as colorsandroid.graphics.Color
: color handling
(They’re all available in android-sdk/sources folder, but to see them in IDEA you have use the Navigate > File action instead of Navigate > Class because they’re @hide
.)
API Level 10, 14—17
- Attribute names
fgcolor
(viaXmlUtils.convertValueToUnsignedInt -> parseLong
)- Base prefixes
0
(octal),0x
(hexadecimal),#
(hexadecimal)- Number formats
Bd*
,B-d*
(B
is the base prefix above,d*
is any resonable number of digits in that base)- Color range
- full ARGB
- Exceptions
null
: no effect (guarded inStringBlock
)- empty string: crash
@system_color_res
: crashNumberFormatException
- named color: crash
NumberFormatException
public static final int
convertValueToUnsignedInt(String value, int defaultValue)
{
if (null == value)
return defaultValue;
return parseUnsignedIntAttribute(value);
}
public static final int
parseUnsignedIntAttribute(CharSequence charSeq)
{
String value = charSeq.toString();
long bits;
int index = 0;
int len = value.length();
int base = 10;
if ('0' == value.charAt(index)) {
// Quick check for zero by itself
if (index == (len - 1))
return 0;
char c = value.charAt(index + 1);
if ('x' == c || 'X' == c) { // check for hex
index += 2;
base = 16;
} else { // check for octal
index++;
base = 8;
}
} else if ('#' == value.charAt(index)) {
index++;
base = 16;
}
return (int) Long.parseLong(value.substring(index), base);
}
API Level 18—22
- Attribute names
fgcolor
,color
(viagetColor -> Color.getHtmlColor -> XmlUtils.convertValueToInt -> parseInt
)- Base prefixes
0
(octal),0x
(hexadecimal),#
(hexadecimal)- Number formats
Bd*
,-Bd*
,B-d*
,-B+d*
,-B-d*
(B
is the base prefix above,d*
is any resonable number of digits in that base)- Color range
- partial ARGB (
alpha <= 0x7f
) - Exceptions
null
: no effect (guarded inStringBlock
)- empty string: 0xff000000 (= BLACK)
@system_color_res
: works- named color: works
(except it may be problematic on API Level 18: the explicitLocale.ROOT
is missing fromtoLowerCase
. For example, this causescolor="LIME"
to be interpreted aslıme
intr-TR
(Turkish) locale; notice there is no dot on thei
!) - invalid named color: -1 (= 0xFFFFFFFF = WHITE)
- invalid format: -1 (= 0xFFFFFFFF = WHITE)
- numeric color above 0x7fffffff: -1 (= 0xFFFFFFFF = WHITE)
private static CharacterStyle getColor(String color, boolean foreground) {
int c = 0xff000000;
if (!TextUtils.isEmpty(color)) {
if (color.startsWith("@")) {
Resources res = Resources.getSystem();
String name = color.substring(1);
int colorRes = res.getIdentifier(name, "color", "android");
if (colorRes != 0) {
ColorStateList colors = res.getColorStateList(colorRes);
if (foreground) {
return new TextAppearanceSpan(null, 0, 0, colors, null);
} else {
c = colors.getDefaultColor();
}
}
} else {
c = Color.getHtmlColor(color);
}
}
if (foreground) {
return new ForegroundColorSpan(c);
} else {
return new BackgroundColorSpan(c);
}
}
public static int getHtmlColor(String color) {
Integer i = sColorNameMap.get(color.toLowerCase(Locale.ROOT));
if (i != null) {
return i;
} else {
try {
return XmlUtils.convertValueToInt(color, -1);
} catch (NumberFormatException nfe) {
return -1;
}
}
}
public static final int
convertValueToInt(CharSequence charSeq, int defaultValue)
{
if (null == charSeq)
return defaultValue;
String nm = charSeq.toString();
// XXX This code is copied from Integer.decode() so we don't
// have to instantiate an Integer!
int value;
int sign = 1;
int index = 0;
int len = nm.length();
int base = 10;
if ('-' == nm.charAt(0)) {
sign = -1;
index++;
}
if ('0' == nm.charAt(index)) {
// Quick check for a zero by itself
if (index == (len - 1))
return 0;
char c = nm.charAt(index + 1);
if ('x' == c || 'X' == c) {
index += 2;
base = 16;
} else {
index++;
base = 8;
}
}
else if ('#' == nm.charAt(index))
{
index++;
base = 16;
}
return Integer.parseInt(nm.substring(index), base) * sign;
}
API Level 23—28
- Attribute names
fgcolor
,color
(viagetColor -> Color.parseColor -> parseLong
)- Base prefixes
#
(hexadecimal)- Number formats
#xxxxxx
,#xxxxxxxx
(#
is literally the hashmark,x
is hexadecimal digit, strictly 6 or 8 digits)- Color range
- full ARGB
- Exceptions
null
: no effect (guarded inStringBlock
)- empty string: 0xff000000 (= BLACK)
@system_color_res
: works- named color: works
- invalid named color: BLACK (= 0xFF000000)
- invalid format: BLACK (= 0xFF000000)
private static CharacterStyle getColor(String color, boolean foreground) {
int c = 0xff000000;
if (!TextUtils.isEmpty(color)) {
if (color.startsWith("@")) {
Resources res = Resources.getSystem();
String name = color.substring(1);
int colorRes = res.getIdentifier(name, "color", "android");
if (colorRes != 0) {
ColorStateList colors = res.getColorStateList(colorRes, null);
if (foreground) {
return new TextAppearanceSpan(null, 0, 0, colors, null);
} else {
c = colors.getDefaultColor();
}
}
} else {
try {
c = Color.parseColor(color);
} catch (IllegalArgumentException e) {
c = Color.BLACK;
}
}
}
if (foreground) {
return new ForegroundColorSpan(c);
} else {
return new BackgroundColorSpan(c);
}
}
public static int parseColor(String colorString) {
if (colorString.charAt(0) == '#') {
// Use a long to avoid rollovers on #ffXXXXXX
long color = Long.parseLong(colorString.substring(1), 16);
if (colorString.length() == 7) {
// Set the alpha value
color |= 0x00000000ff000000;
} else if (colorString.length() != 9) {
throw new IllegalArgumentException("Unknown color");
}
return (int)color;
} else {
Integer color = sColorNameMap.get(colorString.toLowerCase(Locale.ROOT));
if (color != null) {
return color;
}
}
throw new IllegalArgumentException("Unknown color");
}
Special cases
- HTC One M8 4.4.2 where named colors crash with
NumberFormatException: Invalid long: "cyan"
(sample size is 1, and I’m not sure if it’s a custom ROM or not)
Solution?
Forget colors! But seriously, after days of investigation I found no built-in way of doing it. For full support we have to write code that builds/fixes the ForegroundColorSpan
s’ alpha values or our own parser. At first I thought it’ll work with color="@res_name"
, but that format only supports color resources from the framework’s resources, no custom ones.
Here are some partial solutions that does’t require (much) code. Most of the hacks I tried were based on the fact that color
is parsed after fgcolor
in the code of StringBlock
and CharSequence
’s spans are created in the order that allows color
to win on newer platforms. If you don’t like multiple attributes, you could also nest <font>
tags, then the outermost wins.
<font fgcolor="#FFFF0000" color="red">
- If you want to use a named color you’re in luck! Your designers won’t be happy, but you CAN actually do this. The above solutions work on all 3 variants of code: the old platforms understand
fgcolor=ARGB
correctly, the new platforms support named colors. Note: this will probably crash on special cases. <font fgcolor="#FFFF0000" color="-#00010000">
- If you don’t care about new platforms… (Who does that?!), then you can use this to support anything below API 23. Old platforms understand
fgcolor=ARGB
correctly, and the not-so-old ones support the negative hack. Note: on new platforms the color will be black, so be careful if you have a dark theme. <font fgcolor="#7FFF0000">
- If you want only alpha < 128 you’re in luck, because all platforms uniformly support
fgcolor=ARGB
in that range. Html.fromHtml("<font color=\"#FF0000\">text</font>")
- If you only want opaque colors and willing to write some minor code, you can use HTML (string can be stored as
<![CDATA[
in an XML string resource). But this only supportsRGB
colors, no alpha channel. Works on all platforms. - …
- Please share if you find more minimal-code workarounds, I’ll add it here!