Status Update
Comments
si...@google.com <si...@google.com> #2
si...@google.com <si...@google.com>
si...@google.com <si...@google.com> #3
see
no...@google.com <no...@google.com> #4
no...@google.com <no...@google.com> #5
I experiment a way of supporting general CharacterStyle span by recording TextPaint function calls, but it turned out that this is extremely hard and we cannot fully support all CharacterStyle use cases.
Experiment CL:
I talked with Siyamed about the goal of this issue and we got following consensus:
-
Important: Provide a way of markup styling in resources for making AnnotatedString. This will rely on android.text.Annotation, not existing <b> in resources: We may introduce new mechanism of markup but this will not rely on framework Spans, e.g. AbsoluteSizeSpan.
https://developer.android.com/reference/android/text/Annotation -
Less Important: On the other hand, the existing apps uses existing <b> markups in string resource and nice to support of converting them to AnnotatedString. This implementation uses only the public APIs, so easy to reconstruct the AnnotatedString from existing Span. No need to go over the custom CharacterStyle.
https://source.corp.google.com/android/frameworks/base/core/java/android/content/res/StringBlock.java;cl=master
no...@google.com <no...@google.com> #6
And also we may want to convert existing textAppearance to TextStyle if the attributes are public.
si...@google.com <si...@google.com>
si...@google.com <si...@google.com>
tc...@google.com <tc...@google.com> #7
[Deleted User] <[Deleted User]> #8
This was the solution I landed on for this problem, it works for my limited use-case of needing to support <b> and <u> tags in strings.xml, in both strings and string placeholders, hence the addition of the getText method that takes a varag of params.
Would be glad if something like this was part of the Compose UI framework
This is the code that finally worked for me
strings.xml
<string name="launch_awaiting_instructions">Contact <b>our</b> team on %1$s to activate.</string>
<string name="support_contact_phone_number"><b>555 555 555</b> Opt <b>3</b></string>
Kotlin code
fun Spanned.toHtmlWithoutParagraphs(): String {
return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)
.substringAfter("<p dir=\"ltr\">").substringBeforeLast("</p>")
}
fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence {
val escapedArgs = args.map {
if (it is Spanned) it.toHtmlWithoutParagraphs() else it
}.toTypedArray()
val resource = SpannedString(getText(id))
val htmlResource = resource.toHtmlWithoutParagraphs()
val formattedHtml = String.format(htmlResource, *escapedArgs)
return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
Using this I was able to render styled text on Android with styled placeholders too
Output
Contact <b>our</b> team on <b>555 555 555</b> Opt <b>3</b> to activate.
I was then able to expand on this solution to create the following Compose methods.
Jetpack Compose UI
@Composable
fun annotatedStringResource(@StringRes id: Int, vararg formatArgs: Any): AnnotatedString {
val resources = LocalContext.current.resources
return remember(id) {
val text = resources.getText(id, *formatArgs)
spannableStringToAnnotatedString(text)
}
}
@Composable
fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
val resources = LocalContext.current.resources
return remember(id) {
val text = resources.getText(id)
spannableStringToAnnotatedString(text)
}
}
private fun spannableStringToAnnotatedString(text: CharSequence): AnnotatedString {
return if (text is Spanned) {
val spanStyles = mutableListOf<AnnotatedString.Range<SpanStyle>>()
spanStyles.addAll(text.getSpans(0, text.length, UnderlineSpan::class.java).map {
AnnotatedString.Range(
SpanStyle(textDecoration = TextDecoration.Underline),
text.getSpanStart(it),
text.getSpanEnd(it)
)
})
spanStyles.addAll(text.getSpans(0, text.length, StyleSpan::class.java).map {
AnnotatedString.Range(
SpanStyle(fontWeight = FontWeight.Bold),
text.getSpanStart(it),
text.getSpanEnd(it)
)
})
AnnotatedString(text.toString(), spanStyles = spanStyles)
} else {
AnnotatedString(text.toString())
}
}
dr...@gmail.com <dr...@gmail.com> #9
Expanding on the previous poster's code, I've also confirmed that italics, strikethroughs, superscript, subscript, typeface spans, and color spans work the same way.
The only one I'm struggling with is the relative size span, which comes from <big>
and <small>
tags: TextGeometricTransform
allows scaling the Compose UI text on the x-axis, but not on the y-axis.
re...@gmail.com <re...@gmail.com> #10
re...@gmail.com <re...@gmail.com> #11
Based on some PR's that I found and some other solutions that some people have posted, this is what a came up with:
The only one I'm struggling with is the relative size span, which comes from <big> and <small> tags: TextGeometricTransform allows scaling the Compose UI text on the x-axis, but not on the y-axis.
There is no need to use TextGeometricTransform
, we can use SpanStyle(fontSize = it.sizeChange.em)
instead for RelativeSizeSpan
.
I could not figure out how to apply BulletSpan
.
@Composable
@ReadOnlyComposable
private fun resources(): Resources {
LocalConfiguration.current
return LocalContext.current.resources
}
fun Spanned.toHtmlWithoutParagraphs(): String {
return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)
.substringAfter("<p dir=\"ltr\">").substringBeforeLast("</p>")
}
fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence {
val escapedArgs = args.map {
if (it is Spanned) it.toHtmlWithoutParagraphs() else it
}.toTypedArray()
val resource = SpannedString(getText(id))
val htmlResource = resource.toHtmlWithoutParagraphs()
val formattedHtml = String.format(htmlResource, *escapedArgs)
return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
@Composable
fun annotatedStringResource(@StringRes id: Int, vararg formatArgs: Any): AnnotatedString {
val resources = resources()
val density = LocalDensity.current
return remember(id, formatArgs) {
val text = resources.getText(id, *formatArgs)
spannableStringToAnnotatedString(text, density)
}
}
@Composable
fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
val resources = resources()
val density = LocalDensity.current
return remember(id) {
val text = resources.getText(id)
spannableStringToAnnotatedString(text, density)
}
}
private fun spannableStringToAnnotatedString(
text: CharSequence,
density: Density
): AnnotatedString {
return if (text is Spanned) {
with(density) {
buildAnnotatedString {
append((text.toString()))
text.getSpans(0, text.length, Any::class.java).forEach {
val start = text.getSpanStart(it)
val end = text.getSpanEnd(it)
when (it) {
is StyleSpan -> when (it.style) {
Typeface.NORMAL -> addStyle(
SpanStyle(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
),
start,
end
)
Typeface.BOLD -> addStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Normal
),
start,
end
)
Typeface.ITALIC -> addStyle(
SpanStyle(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Italic
),
start,
end
)
Typeface.BOLD_ITALIC -> addStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic
),
start,
end
)
}
is TypefaceSpan -> addStyle(
SpanStyle(
fontFamily = when (it.family) {
FontFamily.SansSerif.name -> FontFamily.SansSerif
FontFamily.Serif.name -> FontFamily.Serif
FontFamily.Monospace.name -> FontFamily.Monospace
FontFamily.Cursive.name -> FontFamily.Cursive
else -> FontFamily.Default
}
),
start,
end
)
is BulletSpan -> {
Log.d("StringResources", "BulletSpan not supported yet")
addStyle(SpanStyle(), start, end)
}
is AbsoluteSizeSpan -> addStyle(
SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()),
start,
end
)
is RelativeSizeSpan -> addStyle(
SpanStyle(fontSize = it.sizeChange.em),
start,
end
)
is StrikethroughSpan -> addStyle(
SpanStyle(textDecoration = TextDecoration.LineThrough),
start,
end
)
is UnderlineSpan -> addStyle(
SpanStyle(textDecoration = TextDecoration.Underline),
start,
end
)
is SuperscriptSpan -> addStyle(
SpanStyle(baselineShift = BaselineShift.Superscript),
start,
end
)
is SubscriptSpan -> addStyle(
SpanStyle(baselineShift = BaselineShift.Subscript),
start,
end
)
is ForegroundColorSpan -> addStyle(
SpanStyle(color = Color(it.foregroundColor)),
start,
end
)
else -> addStyle(SpanStyle(), start, end)
}
}
}
}
} else {
AnnotatedString(text.toString())
}
}
so...@google.com <so...@google.com>
so...@google.com <so...@google.com>
ub...@gmail.com <ub...@gmail.com> #12
Internationalization concerns should be front and center in work on this issue. I consider this the main benefit of styled resource strings.
It boils down to this (in layman's terms): different languages require different parts of a sentence, or even different parts of a paragraph, to appear in different places.
Something like AnnotatedString looks incompatible with internationalization, as it cannot be readily translated with the above concern in mind.
Pointing this out to ensure it is not lost in the conversation.
rh...@gmail.com <rh...@gmail.com> #13
Here is the same solution as above but with the import statements included. Had a difficult identifying the right imports since there are many ambiguous classes shared with different Android libraries. Figured someone else could also benefit
import android.content.res.Resources
import android.graphics.Typeface
import android.text.Spanned
import android.text.SpannedString
import android.text.style.*
import android.util.Log
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.core.text.HtmlCompat
@Composable
@ReadOnlyComposable
private fun resources(): Resources {
LocalConfiguration.current
return LocalContext.current.resources
}
fun Spanned.toHtmlWithoutParagraphs(): String {
return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE)
.substringAfter("<p dir=\"ltr\">").substringBeforeLast("</p>")
}
fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence {
val escapedArgs = args.map {
if (it is Spanned) it.toHtmlWithoutParagraphs() else it
}.toTypedArray()
val resource = SpannedString(getText(id))
val htmlResource = resource.toHtmlWithoutParagraphs()
val formattedHtml = String.format(htmlResource, *escapedArgs)
return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY)
}
@Composable
fun annotatedStringResource(@StringRes id: Int, vararg formatArgs: Any): AnnotatedString {
val resources = resources()
val density = LocalDensity.current
return remember(id, formatArgs) {
val text = resources.getText(id, *formatArgs)
spannableStringToAnnotatedString(text, density)
}
}
@Composable
fun annotatedStringResource(@StringRes id: Int): AnnotatedString {
val resources = resources()
val density = LocalDensity.current
return remember(id) {
val text = resources.getText(id)
spannableStringToAnnotatedString(text, density)
}
}
private fun spannableStringToAnnotatedString(
text: CharSequence,
density: Density
): AnnotatedString {
return if (text is Spanned) {
with(density) {
buildAnnotatedString {
append((text.toString()))
text.getSpans(0, text.length, Any::class.java).forEach {
val start = text.getSpanStart(it)
val end = text.getSpanEnd(it)
when (it) {
is StyleSpan -> when (it.style) {
Typeface.NORMAL -> addStyle(
SpanStyle(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
),
start,
end
)
Typeface.BOLD -> addStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Normal
),
start,
end
)
Typeface.ITALIC -> addStyle(
SpanStyle(
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Italic
),
start,
end
)
Typeface.BOLD_ITALIC -> addStyle(
SpanStyle(
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Italic
),
start,
end
)
}
is TypefaceSpan -> addStyle(
SpanStyle(
fontFamily = when (it.family) {
FontFamily.SansSerif.name -> FontFamily.SansSerif
FontFamily.Serif.name -> FontFamily.Serif
FontFamily.Monospace.name -> FontFamily.Monospace
FontFamily.Cursive.name -> FontFamily.Cursive
else -> FontFamily.Default
}
),
start,
end
)
is BulletSpan -> {
Log.d("StringResources", "BulletSpan not supported yet")
addStyle(SpanStyle(), start, end)
}
is AbsoluteSizeSpan -> addStyle(
SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()),
start,
end
)
is RelativeSizeSpan -> addStyle(
SpanStyle(fontSize = it.sizeChange.em),
start,
end
)
is StrikethroughSpan -> addStyle(
SpanStyle(textDecoration = TextDecoration.LineThrough),
start,
end
)
is UnderlineSpan -> addStyle(
SpanStyle(textDecoration = TextDecoration.Underline),
start,
end
)
is SuperscriptSpan -> addStyle(
SpanStyle(baselineShift = BaselineShift.Superscript),
start,
end
)
is SubscriptSpan -> addStyle(
SpanStyle(baselineShift = BaselineShift.Subscript),
start,
end
)
is ForegroundColorSpan -> addStyle(
SpanStyle(color = Color(it.foregroundColor)),
start,
end
)
else -> addStyle(SpanStyle(), start, end)
}
}
}
}
} else {
AnnotatedString(text.toString())
}
}
rh...@gmail.com <rh...@gmail.com> #14
Solution works great here, any reason why this can't be added to the compose library?
kl...@google.com <kl...@google.com> #15
For future reference, I took the code proposed in this issue and copied it into the library in
si...@google.com <si...@google.com>
kl...@google.com <kl...@google.com> #16
Filed
lo...@gmail.com <lo...@gmail.com> #17
ar...@gmail.com <ar...@gmail.com> #18
This is the part compose ui felt left behind now.
si...@gmail.com <si...@gmail.com> #19
Here is a quick example of how to handle variable arguments in string resources almost like we are already used to, without the need to do the weird HTML legacy parsing:
Let's say you have this string resource, with formatting, and a dynamic variable who
:
<string name="hello">Hello, <b><annotation variable="who" typography="large" color="main">who</annotation></b>!</string>
Using these methods, where buildSpannedStringWithArgs()
will replace the Annotation
spans with their corresponding inputs:
@Composable
public fun annotatedStringResource(@StringRes id: Int, args: PersistentMap<String, String>): AnnotatedString {
val resources = resources()
val density = LocalDensity.current
val colors = LocalColors.current
val typography = LocalTypography.current
return remember(id, values) {
resources
.buildSpannedStringWithArgs(id, args)
.asAnnotatedString(density, colors, typography)
}
}
private fun Resources.buildSpannedStringWithArgs(
@StringRes id: Int,
args: PersistentMap<String, String>,
): SpannedString = buildSpannedString {
append(getText(id))
getSpans(0, length, Annotation::class.java)
.filterIsInstance<Annotation>()
.filter { it.key == "variable" }
.forEach { replace(getSpanStart(it), getSpanEnd(it), args.getValue(it.value)) }
}
private fun CharSequence.asAnnotatedString(density: Density, colors: MyColors, typography: MyTypography): AnnotatedString {
if (this !is Spanned) return AnnotatedString(this.toString())
return buildAnnotatedString {
append(this@asAnnotatedString.toString())
getSpans(0, length, Any::class.java).forEach {
val start = getSpanStart(it)
val end = getSpanEnd(it)
buildWithSpan(it, start, end, density, colors, typography)
}
}
}
private fun AnnotatedString.Builder.buildWithSpan(
span: Any,
start: Int, end: Int,
density: Density, colors: MyColors, typography: MyTypography,
) = when (it) {
is StyleSpan -> /* ... */
is TypefaceSpan -> /* ... */
is BulletSpan -> /* ... */
/* ... */
}
It can then be called in a Composable
like this (see result in screenshot):
@Preview
@Composable
private fun AnnotatedStringResourcePreview() {
PreviewTheme {
Text(
text = annotatedStringResource(
R.string.hello,
persistentMapOf("who" to "Bob"),
),
)
}
}
my...@gmail.com <my...@gmail.com> #20
Any update on this?
di...@yotoplay.com <di...@yotoplay.com> #21
so...@google.com <so...@google.com> #22
We've added a String.parseAsHtml
which supports basic tags similar to Html.fromHtml
and Annotation
. It will be available in 1.7.0-alpha06 release. Note that bullet lists are not yet supported.
Resolving this ticket, the rest of work is tracked in
ma...@marcardar.com <ma...@marcardar.com> #23
On a related note, is an equivalent to QuoteSpan
on the radar?
Description
No description yet.