Skip to content

Commit

Permalink
Add method for creating AnnotatedString from HTML tagged string
Browse files Browse the repository at this point in the history
This method uses HtmlCompat.fromHtml under the hood. Note that unlike getText(resourceId), it doesn't handle custom annotation tags <annotation> and therefore needs to be handled separately.
At the same time, getText(resourceId) doesn't support formatting.

The method will be used like so: getString(R.string.example).parseAsHtml()

In the future I'm planning to extend parseAsHtml with links styling and links click handler objects. Also note that original HtmlCompat.fromHtml accepts flags which we should consider exposing in the future.

Test: demo
Test: added tests AnnotatedStringFromHtmlTest
Relnote: "Added `parseAsHtml` method for styled strings: it allows to convert a string marked with HTML tags into AnnotatedString. Note that not all tags are supported, for example you won't be able to display bullet lists yet."
Change-Id: I84d3d1881805e964cea940eb1c68a5bba16f6416
  • Loading branch information
Anastasia Soboleva committed Mar 18, 2024
1 parent 824114c commit 932545d
Show file tree
Hide file tree
Showing 11 changed files with 469 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import androidx.compose.foundation.demos.text2.TextFieldReceiveContentDemo
import androidx.compose.foundation.samples.BasicTextFieldUndoSample
import androidx.compose.integration.demos.common.ComposableDemo
import androidx.compose.integration.demos.common.DemoCategory
import androidx.compose.ui.text.samples.AnnotatedStringFromHtml

val TextDemos = DemoCategory(
"Text",
Expand Down Expand Up @@ -213,5 +214,6 @@ val TextDemos = DemoCategory(
)
),
ComposableDemo("Text Pointer Icon") { TextPointerIconDemo() },
ComposableDemo("Html") { AnnotatedStringFromHtml() }
)
)
4 changes: 4 additions & 0 deletions compose/ui/ui-text/api/current.txt
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ package androidx.compose.ui.text {
@SuppressCompatibility @kotlin.RequiresOptIn(message="This API is experimental and is likely to change in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalTextApi {
}

public final class Html_androidKt {
method public static androidx.compose.ui.text.AnnotatedString parseAsHtml(String);
}

@SuppressCompatibility @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR, message="This is internal API that may change frequently and without warning.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY}) public @interface InternalTextApi {
}

Expand Down
4 changes: 4 additions & 0 deletions compose/ui/ui-text/api/restricted_current.txt
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ package androidx.compose.ui.text {
@SuppressCompatibility @kotlin.RequiresOptIn(message="This API is experimental and is likely to change in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalTextApi {
}

public final class Html_androidKt {
method public static androidx.compose.ui.text.AnnotatedString parseAsHtml(String);
}

@SuppressCompatibility @kotlin.RequiresOptIn(level=kotlin.RequiresOptIn.Level.ERROR, message="This is internal API that may change frequently and without warning.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.FUNCTION, kotlin.annotation.AnnotationTarget.PROPERTY}) public @interface InternalTextApi {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.text.samples

import androidx.annotation.Sampled
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.parseAsHtml

@Composable
@Sampled
fun AnnotatedStringFromHtml() {
// First, download a string as a plain text using one of the resources' methods. At this stage
// you will be handling plurals and formatted strings in needed. Moreover, the string will be
// resolved with respect to the current locale and available translations.
val string = stringResource(id = R.string.example)

// Next, convert a string marked with HTML tags into AnnotatedString to be displayed by Text
val styledAnnotatedString = string.parseAsHtml()

BasicText(styledAnnotatedString)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2024 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

<resources>
<string name="example" translatable="false">
&lt;b>bold&lt;/b>
&lt;i>italic&lt;/i>
&lt;big>big&lt;/big>
&lt;small>small&lt;/small>
&lt;font face="monospace">monospace&lt;/font>
&lt;font face="serif">serif&lt;/font>
&lt;font face="sans_serif">sans_serif&lt;/font>
&lt;font face="cursive">cursive&lt;/font>
&lt;font color="#00ff00">green&lt;/font>
&lt;tt>monospace&lt;/tt>
&lt;sup>superscript&lt;/sup>
&lt;strike>strikethrough&lt;/strike>
&lt;sub>subscript&lt;/sub>
&lt;u>underline&lt;/u>
&lt;span style="background-color:#ff0000">span&lt;/span>
&lt;p dir="rtl">right to left&lt;/p>
&lt;p dir="ltr">left to right&lt;/p>
I am &lt;div>div&lt;/div> element.&lt;br>
&lt;a href="https://developer.android.com">Link&lt;/a>
</string>
</resources>
20 changes: 20 additions & 0 deletions compose/ui/ui-text/src/androidInstrumentedTest/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2024 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="androidx.activity.ComponentActivity" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.text

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.test.junit4.createComposeRule
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.em
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
class AnnotatedStringFromHtmlTest {
@get:Rule
val rule = createComposeRule()

@Test
// pre-N block-level elements were separated with two new lines
@SdkSuppress(minSdkVersion = 24)
fun buildAnnotatedString_fromHtml() {
rule.setContent {
val expected = buildAnnotatedString {
fun add(block: () -> Unit) {
block()
append("a")
pop()
append(" ")
}
fun addStyle(style: SpanStyle) {
add { pushStyle(style) }
}

add { pushLink(LinkAnnotation.Url("https://example.com")) }
addStyle(SpanStyle(fontWeight = FontWeight.Bold))
addStyle(SpanStyle(fontSize = 1.25.em))
append("\na\n") // <div>
addStyle(SpanStyle(fontFamily = FontFamily.Serif))
addStyle(SpanStyle(color = Color.Green))
addStyle(SpanStyle(fontStyle = FontStyle.Italic))
append("\na\n") // <p>
addStyle(SpanStyle(textDecoration = TextDecoration.LineThrough))
addStyle(SpanStyle(fontSize = 0.8.em))
addStyle(SpanStyle(background = Color.Red))
addStyle(SpanStyle(baselineShift = BaselineShift.Subscript))
addStyle(SpanStyle(baselineShift = BaselineShift.Superscript))
addStyle(SpanStyle(fontFamily = FontFamily.Monospace))
addStyle(SpanStyle(textDecoration = TextDecoration.Underline))
}

val actual = stringResource(androidx.compose.ui.text.test.R.string.html).parseAsHtml()

assertThat(actual.text).isEqualTo(expected.text)
assertThat(actual.spanStyles).containsExactlyElementsIn(expected.spanStyles).inOrder()
assertThat(actual.paragraphStyles)
.containsExactlyElementsIn(expected.paragraphStyles)
.inOrder()
assertThat(actual.getStringAnnotations(0, actual.length))
.containsExactlyElementsIn(expected.getStringAnnotations(0, expected.length))
.inOrder()
assertThat(actual.getLinkAnnotations(0, actual.length))
.containsExactlyElementsIn(expected.getLinkAnnotations(0, expected.length))
.inOrder()
}
}

@Test
fun formattedString_withStyling() {
rule.setContent {
val actual = stringResource(
androidx.compose.ui.text.test.R.string.formatting,
"computer"
).parseAsHtml()
assertThat(actual.text).isEqualTo("Hello, computer!")
assertThat(actual.spanStyles).containsExactly(
AnnotatedString.Range(SpanStyle(fontWeight = FontWeight.Bold), 7, 15)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2024 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

<resources>
<string name="html" translatable="false">
&lt;a href="https://example.com">a&lt;/a>
&lt;b>a&lt;/b>
&lt;big>a&lt;/big>
&lt;div>a&lt;/div>
&lt;font face="serif">a&lt;/font>
&lt;font color="#00ff00">a&lt;/font>
&lt;i>a&lt;/i>
&lt;p>a&lt;/p>
&lt;s>a&lt;/s>
&lt;small>a&lt;/small>
&lt;span style="background-color:red">a&lt;/span>
&lt;sub>a&lt;/sub>
&lt;sup>a&lt;/sup>
&lt;tt>a&lt;/tt>
&lt;u>a&lt;/u>
</string>
<string name="formatting">Hello, &lt;b>%s&lt;/b>!</string>
</resources>
Loading

0 comments on commit 932545d

Please sign in to comment.