"
32 |
33 | private const val FLAGS = HtmlCompat.FROM_HTML_MODE_LEGACY or
34 | HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST or
35 | HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST_ITEM
36 |
37 | private val imageGetter = Html.ImageGetter {
38 | GradientDrawable().apply {
39 | colors = intArrayOf(AndroidColor.RED, AndroidColor.BLUE)
40 | cornerRadius = 16f
41 | setBounds(0, 0, 80, 80)
42 | }
43 | }
44 |
45 | fun getSampleHtml(): ContentAnnotatedString {
46 | return getAndroidSampleHtml()
47 | .asAnnotatedString(
48 | linkColor = Color.Blue
49 | ).trim() as ContentAnnotatedString
50 | }
51 |
52 | fun getAndroidSampleHtml(): Spanned {
53 | return HtmlCompat.fromHtml(
54 | HTML_TEXT,
55 | FLAGS,
56 | imageGetter,
57 | null
58 | ).replaceSpans(
59 | AndroidQuoteSpan::class.java
60 | ) {
61 | QuoteSpan(
62 | lineColor = AndroidColor.MAGENTA,
63 | lineStripeWidth = 12,
64 | paragraphGapWidth = 20,
65 | )
66 | }.replaceSpans(
67 | AndroidBulletSpan::class.java
68 | ) {
69 | BulletSpan(
70 | bulletColor = AndroidColor.MAGENTA,
71 | radius = 16f,
72 | paragraphGapWidth = 20,
73 | strokeWidth = 4f
74 | )
75 | }.trim() as Spanned
76 | }
--------------------------------------------------------------------------------
/maven/publish.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'maven-publish'
2 | apply plugin: 'signing'
3 |
4 | task sourcesJar(type: Jar) {
5 | from android.sourceSets.main.java.srcDirs
6 | archiveClassifier = 'sources'
7 | }
8 |
9 | task javadoc(type: Javadoc) {
10 | source = android.sourceSets.main.java.srcDirs
11 | classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
12 | }
13 |
14 | afterEvaluate {
15 | javadoc.classpath += files(android.libraryVariants.collect { variant ->
16 | variant.javaCompileProvider.get().classpath.files
17 | })
18 | }
19 |
20 | task javadocJar(type: Jar, dependsOn: javadoc) {
21 | archiveClassifier = 'javadoc'
22 | from javadoc.destinationDir
23 | }
24 |
25 | artifacts {
26 | archives javadocJar
27 | archives sourcesJar
28 | }
29 |
30 | signing {
31 | useInMemoryPgpKeys(
32 | rootProject.ext["signing.keyId"],
33 | rootProject.ext["signing.key"],
34 | rootProject.ext["signing.password"],
35 | )
36 | sign publishing.publications
37 | }
38 |
39 | tasks.withType(Javadoc) {
40 | options.addStringOption('Xdoclint:none', '-quiet')
41 | options.addStringOption('encoding', 'UTF-8')
42 | options.addStringOption('charSet', 'UTF-8')
43 | }
44 |
45 | File deploy = project.rootProject.file("maven/deploy.settings")
46 | def artifact = new Properties()
47 | artifact.load(new FileInputStream(deploy))
48 |
49 | version = artifact.version
50 | group = artifact.groupId
51 |
52 | afterEvaluate {
53 | publishing {
54 | publications {
55 | release(MavenPublication) {
56 | groupId artifact.groupId
57 | artifactId artifact.id
58 | version artifact.version
59 | from components.release
60 |
61 | pom {
62 | name = artifact.id
63 | packaging = 'aar'
64 | description = artifact.description
65 | url = artifact.siteUrl
66 |
67 | scm {
68 | connection = artifact.gitUrl
69 | developerConnection = artifact.gitUrl
70 | url = artifact.siteUrl
71 | }
72 |
73 | licenses {
74 | license {
75 | name = 'The Apache License, Version 2.0'
76 | url = 'http://www.apache.org/licenses/LICENSE-2.0.txt'
77 | }
78 | }
79 |
80 | developers {
81 | developer {
82 | id = 'Aghajari'
83 | name = 'AmirHossein Aghajari'
84 | email = 'amirhossein.aghajari.82@gmail.com'
85 | }
86 | }
87 | }
88 | }
89 | }
90 | }
91 | }
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/Dimensions.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.content.res.Resources
4 | import android.graphics.drawable.Drawable
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.graphics.ImageBitmap
7 | import androidx.compose.ui.graphics.asImageBitmap
8 | import androidx.compose.ui.platform.LocalDensity
9 | import androidx.compose.ui.text.TextLayoutResult
10 | import androidx.compose.ui.unit.TextUnit
11 | import androidx.compose.ui.unit.sp
12 | import androidx.core.graphics.drawable.toBitmap
13 | import androidx.core.util.TypedValueCompat
14 | import kotlin.math.abs
15 | import kotlin.math.min
16 |
17 | internal val localScaledDensity: Float
18 | @Composable
19 | get() = with(LocalDensity.current) { density * fontScale }
20 |
21 | internal val density: Float
22 | get() = Resources.getSystem().displayMetrics.density
23 |
24 | internal fun Int.dpToPx(): Float {
25 | return this * density
26 | }
27 |
28 | internal fun Float.pxToSp(): TextUnit {
29 | return try {
30 | TypedValueCompat.pxToSp(
31 | this,
32 | Resources.getSystem().displayMetrics
33 | ).sp
34 | } catch (_: Throwable) {
35 | // This might happen on compose previews
36 | @Suppress("DEPRECATION")
37 | (this / Resources.getSystem().displayMetrics.scaledDensity).sp
38 | }
39 | }
40 |
41 | internal fun Int.pxToSp(): TextUnit {
42 | return toFloat().pxToSp()
43 | }
44 |
45 | @Composable
46 | internal fun Int.localePxToSp(): TextUnit {
47 | return (this / localScaledDensity).sp
48 | }
49 |
50 | internal fun Drawable.toImageBitmap(): ImageBitmap {
51 | return toBitmap(
52 | width = if (minimumWidth > 0) {
53 | minimumWidth
54 | } else {
55 | abs(bounds.width())
56 | },
57 | height = if (minimumHeight > 0) {
58 | minimumHeight
59 | } else {
60 | abs(bounds.height())
61 | }
62 | ).asImageBitmap()
63 | }
64 |
65 | /**
66 | * Returns the line number on which the specified text offset appears within
67 | * a TextLayoutResult, considering the visibility constraints imposed by maxLines.
68 | *
69 | * @param offset a character offset
70 | * @param checkIfDelimited indicates whether to check if the offset is within a line delimiter.
71 | * @return the line number associated with the specified offset, adjusted for maxLines constraints.
72 | * If checkIfDelimited is true and the offset is not within a line delimiter, -1 is returned.
73 | */
74 | internal fun TextLayoutResult.getLineForOffsetInBounds(
75 | offset: Int,
76 | checkIfDelimited: Boolean = false
77 | ): Int {
78 | return try {
79 | min(getLineForOffset(offset), lineCount - 1)
80 | } catch (ignore: Exception) {
81 | if (checkIfDelimited) {
82 | -1
83 | } else {
84 | lineCount - 1
85 | }
86 | }
87 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/aghajari/compose/test/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.test
2 |
3 | import android.os.Bundle
4 | import android.text.method.LinkMovementMethod
5 | import android.widget.TextView
6 | import androidx.activity.ComponentActivity
7 | import androidx.activity.compose.setContent
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.rememberScrollState
13 | import androidx.compose.foundation.verticalScroll
14 | import androidx.compose.material3.Divider
15 | import androidx.compose.runtime.Composable
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.graphics.Color
18 | import androidx.compose.ui.tooling.preview.Preview
19 | import androidx.compose.ui.unit.dp
20 | import androidx.compose.ui.unit.sp
21 | import androidx.compose.ui.viewinterop.AndroidView
22 | import com.aghajari.compose.test.ui.theme.SpannedToAnnotatedStringTheme
23 | import com.aghajari.compose.text.AnnotatedText
24 | import android.graphics.Color as AndroidColor
25 |
26 | class MainActivity : ComponentActivity() {
27 | override fun onCreate(savedInstanceState: Bundle?) {
28 | super.onCreate(savedInstanceState)
29 | setContent {
30 | SpannedToAnnotatedStringTheme {
31 | Column(
32 | modifier = Modifier
33 | .fillMaxSize()
34 | .padding(
35 | horizontal = 24.dp,
36 | vertical = 16.dp
37 | )
38 | .verticalScroll(rememberScrollState())
39 | ) {
40 | SampleAnnotatedText()
41 | Divider(Modifier.padding(vertical = 16.dp))
42 | SampleAndroidText()
43 | Divider(Modifier.padding(vertical = 16.dp))
44 | }
45 | }
46 | }
47 | }
48 | }
49 |
50 | @Composable
51 | fun SampleAnnotatedText(modifier: Modifier = Modifier) {
52 | AnnotatedText(
53 | text = getSampleHtml(),
54 | color = Color.Black,
55 | fontSize = 16.sp,
56 | modifier = modifier.fillMaxWidth()
57 | )
58 | }
59 |
60 | @Composable
61 | fun SampleAndroidText(modifier: Modifier = Modifier) {
62 | AndroidView(
63 | factory = {
64 | TextView(it).apply {
65 | text = getAndroidSampleHtml()
66 | setTextColor(AndroidColor.BLACK)
67 | setLinkTextColor(AndroidColor.BLUE)
68 | textSize = 16f
69 | movementMethod = LinkMovementMethod()
70 | }
71 | },
72 | modifier = modifier.fillMaxWidth()
73 | )
74 | }
75 |
76 | @Preview(showBackground = true)
77 | @Composable
78 | fun Preview() {
79 | SpannedToAnnotatedStringTheme {
80 | SampleAnnotatedText()
81 | }
82 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/TextAppearance.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.content.res.Resources
4 | import android.graphics.Typeface
5 | import android.os.Build
6 | import android.text.style.TextAppearanceSpan
7 | import androidx.compose.ui.geometry.Offset
8 | import androidx.compose.ui.graphics.Color
9 | import androidx.compose.ui.graphics.Shadow
10 | import androidx.compose.ui.text.SpanStyle
11 | import androidx.compose.ui.text.font.FontFamily
12 | import androidx.compose.ui.text.font.FontStyle
13 | import androidx.compose.ui.text.font.FontWeight
14 | import androidx.compose.ui.text.intl.LocaleList
15 | import androidx.compose.ui.unit.TextUnit
16 | import androidx.compose.ui.unit.sp
17 |
18 | /**
19 | * Converts [TextAppearanceSpan] to a [SpanStyle].
20 | */
21 | internal fun TextAppearanceSpan.toSpanStyle(): SpanStyle {
22 | var mColor: Color = Color.Unspecified
23 | var mFontSize: TextUnit = TextUnit.Unspecified
24 | var mFontWeight: FontWeight? = null
25 | var mFontStyle: FontStyle? = null
26 | var mFontFamily: FontFamily? = null
27 | var mLocaleList: LocaleList? = null
28 | var mFontFeatureSettings: String? = null
29 | var mShadow: Shadow? = null
30 |
31 | if (family.isNullOrEmpty().not()) {
32 | mFontFamily = getFontFamily(family)
33 | }
34 |
35 | when (textStyle) {
36 | Typeface.ITALIC -> mFontStyle = FontStyle.Italic
37 | Typeface.NORMAL -> mFontStyle = FontStyle.Normal
38 | Typeface.BOLD -> mFontWeight = FontWeight.Bold
39 | Typeface.BOLD_ITALIC -> {
40 | mFontStyle = FontStyle.Italic
41 | mFontWeight = FontWeight.Bold
42 | }
43 | }
44 |
45 | if (textSize != -1) {
46 | val displayMetrics = Resources.getSystem().displayMetrics
47 | mFontSize = (textSize / displayMetrics.scaledDensity).sp
48 | }
49 |
50 | if (textColor != null) {
51 | mColor = Color(textColor.defaultColor)
52 | }
53 |
54 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
55 | mFontFeatureSettings = fontFeatureSettings
56 |
57 | if (typeface != null) {
58 | mFontFamily = getFontFamily(typeface)
59 | }
60 |
61 | if (isAcceptableWeight(textFontWeight)) {
62 | mFontWeight = FontWeight(textFontWeight)
63 | }
64 |
65 | if (textLocales != null) {
66 | mLocaleList = LocaleList(requireNotNull(textLocales).toLanguageTags())
67 | }
68 |
69 | if (shadowColor != 0) {
70 | mShadow = Shadow(
71 | color = Color(shadowColor),
72 | offset = Offset(shadowDx, shadowDy),
73 | blurRadius = shadowRadius
74 | )
75 | }
76 | }
77 |
78 | return SpanStyle(
79 | color = mColor,
80 | fontSize = mFontSize,
81 | fontWeight = mFontWeight,
82 | fontStyle = mFontStyle,
83 | fontFamily = mFontFamily,
84 | fontFeatureSettings = mFontFeatureSettings,
85 | localeList = mLocaleList,
86 | shadow = mShadow
87 | )
88 | }
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/URLHelper.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.content.ActivityNotFoundException
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.net.Uri
7 | import android.provider.Browser
8 | import android.text.style.URLSpan
9 | import android.util.Log
10 | import androidx.compose.ui.text.AnnotatedString
11 |
12 | /**
13 | * Callback that is executed when users click the url text.
14 | */
15 | internal fun ContentAnnotatedString.toURLClickable(
16 | onURLClick: (String) -> Unit
17 | ): (Int) -> Unit {
18 | return { offset ->
19 | getURLs(offset, offset)
20 | .firstOrNull()?.let {
21 | onURLClick(it.item)
22 | }
23 | }
24 | }
25 |
26 | /**
27 | * Default Implementation of URL click callback.
28 | * Will try to open the url, by launching an an Activity with an
29 | * [android.content.Intent.ACTION_VIEW] intent.
30 | *
31 | * @see toURLClickable
32 | * @see URLSpan.onClick
33 | */
34 | internal fun defaultOnURLClick(
35 | context: Context
36 | ): (String) -> Unit {
37 | return {
38 | Intent(Intent.ACTION_VIEW, Uri.parse(it)).apply {
39 | putExtra(Browser.EXTRA_APPLICATION_ID, context.packageName)
40 | try {
41 | context.startActivity(this)
42 | } catch (e: ActivityNotFoundException) {
43 | Log.w(
44 | "AnnotatedText",
45 | "Activity was not found for intent, $this"
46 | )
47 | }
48 | }
49 | }
50 | }
51 |
52 | // TODO: Use [AnnotatedString.Builder.addUrlAnnotation]
53 | // whenever it exits the experimental mode
54 | /**
55 | * Marks the given range as url.
56 | */
57 | internal fun AnnotatedString.Builder.addURL(
58 | urlSpan: URLSpan,
59 | range: IntRange
60 | ): Boolean {
61 | addStringAnnotation(
62 | tag = URL_TAG,
63 | annotation = urlSpan.url,
64 | start = range.first,
65 | end = range.last
66 | )
67 | return true
68 | }
69 |
70 | /**
71 | * Returns URLs attached on this AnnotatedString.
72 | *
73 | * @param start the start of the query range, inclusive.
74 | * @param end the end of the query range, exclusive.
75 | * @return a list of URLs stored in [AnnotatedString.Range].
76 | * Notice that All annotations that intersect with the range
77 | * [start, end) will be returned. When [start] is bigger than
78 | * [end], an empty list will be returned.
79 | */
80 | internal fun AnnotatedString.getURLs(
81 | start: Int,
82 | end: Int
83 | ): List> {
84 | return getStringAnnotations(URL_TAG, start, end)
85 | }
86 |
87 | /**
88 | * Returns true if [getURLs] with the same parameters
89 | * would return a non-empty list
90 | */
91 | internal fun AnnotatedString.hasURL(
92 | start: Int,
93 | end: Int
94 | ): Boolean {
95 | return hasStringAnnotations(URL_TAG, start, end)
96 | }
97 |
98 | /**
99 | * The annotation tag used to distinguish urls.
100 | */
101 | private const val URL_TAG = "com.aghajari.compose.text.urlAnnotation"
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/LineBackgroundParagraph.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.graphics.Canvas
4 | import android.graphics.Paint
5 | import android.os.Build
6 | import android.os.Parcel
7 | import android.os.Parcelable
8 | import android.text.ParcelableSpan
9 | import androidx.compose.ui.geometry.Offset
10 | import androidx.compose.ui.geometry.Size
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.graphics.drawscope.DrawScope
13 | import androidx.compose.ui.graphics.isSpecified
14 | import android.text.style.LineBackgroundSpan as AndroidLineBackgroundSpan
15 |
16 | /**
17 | * Implementation of [ParagraphContentDrawer] to
18 | * render the background color of the lines to which the span is attached.
19 | *
20 | * @see ParagraphContentDrawer
21 | * @see LineBackgroundSpan
22 | */
23 | private class LineBackgroundContentDrawer(
24 | val color: Color,
25 | ) : ParagraphContentDrawer {
26 |
27 | override fun onDraw(
28 | drawScope: DrawScope,
29 | layoutInfo: ParagraphLayoutInfo
30 | ) {
31 | drawScope.drawRect(
32 | color = color,
33 | topLeft = Offset(0f, layoutInfo.top),
34 | size = Size(
35 | width = layoutInfo.result.size.width.toFloat(),
36 | height = layoutInfo.height
37 | )
38 | )
39 | }
40 | }
41 |
42 | internal fun AndroidLineBackgroundSpan.asParagraphContent(
43 | range: IntRange
44 | ): ParagraphContent {
45 | val color = if (this is LineBackgroundSpan) {
46 | Color(this.color)
47 | } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
48 | this is AndroidLineBackgroundSpan.Standard
49 | ) {
50 | Color(this.color)
51 | } else {
52 | Color.Unspecified
53 | }
54 |
55 | return ParagraphContent(
56 | start = range.first,
57 | end = range.last,
58 | drawer = if (color.isSpecified) {
59 | LineBackgroundContentDrawer(color)
60 | } else null
61 | )
62 | }
63 |
64 | /**
65 | * @see AndroidLineBackgroundSpan
66 | */
67 | class LineBackgroundSpan(
68 | val color: Int = -0xffff01,
69 | ) : AndroidLineBackgroundSpan, ParcelableSpan {
70 |
71 | private constructor(parcel: Parcel) : this(
72 | color = parcel.readInt()
73 | )
74 |
75 | override fun writeToParcel(dest: Parcel, flags: Int) {
76 | dest.writeInt(color)
77 | }
78 |
79 | override fun describeContents(): Int = 0
80 | override fun getSpanTypeId(): Int = 27
81 |
82 | override fun drawBackground(
83 | canvas: Canvas, paint: Paint,
84 | left: Int, right: Int,
85 | top: Int, baseline: Int, bottom: Int,
86 | text: CharSequence, start: Int, end: Int,
87 | lineNumber: Int
88 | ) {
89 | val originColor = paint.color
90 | paint.color = color
91 | canvas.drawRect(
92 | left.toFloat(),
93 | top.toFloat(),
94 | right.toFloat(),
95 | bottom.toFloat(),
96 | paint
97 | )
98 | paint.color = originColor
99 | }
100 |
101 | companion object {
102 | @Suppress("unused")
103 | @JvmField
104 | val CREATOR = object : Parcelable.Creator {
105 | override fun createFromParcel(parcel: Parcel) = LineBackgroundSpan(parcel)
106 | override fun newArray(size: Int) = arrayOfNulls(size)
107 | }
108 | }
109 | }
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/ParagraphContent.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.text.Layout
4 | import android.text.style.AlignmentSpan
5 | import android.text.style.LeadingMarginSpan
6 | import androidx.compose.ui.graphics.drawscope.DrawScope
7 | import androidx.compose.ui.text.TextLayoutResult
8 | import androidx.compose.ui.text.style.ResolvedTextDirection
9 | import androidx.compose.ui.text.style.TextAlign
10 |
11 | /**
12 | * A Callback to render the leading margin.
13 | */
14 | fun interface ParagraphContentDrawer {
15 |
16 | fun onDraw(
17 | drawScope: DrawScope,
18 | layoutInfo: ParagraphLayoutInfo
19 | )
20 | }
21 |
22 | /**
23 | * A paragraph style affecting the leading margin.
24 | *
25 | * ParagraphContents should be attached from the first
26 | * character to the last character of a single paragraph.
27 | */
28 | class ParagraphContent(
29 | val start: Int,
30 | val end: Int,
31 | val firstLeadingMargin: Int? = null,
32 | val restLeadingMargin: Int? = null,
33 | val drawer: ParagraphContentDrawer? = null,
34 | val alignment: TextAlign? = null,
35 | val lineHeight: Int? = null
36 | )
37 |
38 | /**
39 | * Checks if the given [ParagraphContent] represents a drawer-only configuration.
40 | *
41 | * A drawer-only configuration implies that only the drawer content is present in the paragraph,
42 | * and all other styling properties such as leading margins, alignment, and line height are null.
43 | * Note that a drawer-only [ParagraphContent] won't add a new ParagraphStyle to an AnnotatedString.
44 | */
45 | internal fun ParagraphContent.isDrawerOnly(): Boolean {
46 | return drawer != null &&
47 | firstLeadingMargin == null &&
48 | restLeadingMargin == null &&
49 | alignment == null &&
50 | lineHeight == null
51 | }
52 |
53 | /**
54 | * The data class which holds paragraph area and text layout result.
55 | */
56 | @Suppress("unused")
57 | class ParagraphLayoutInfo(
58 | val result: TextLayoutResult,
59 | val startLine: Int,
60 | val endLine: Int,
61 | val x: Float,
62 | val top: Float,
63 | val bottom: Float,
64 | val direction: ResolvedTextDirection
65 | ) {
66 |
67 | val height: Float
68 | get() = bottom - top
69 |
70 | internal val dirSign: Int
71 | get() = if (direction == ResolvedTextDirection.Ltr) {
72 | +1
73 | } else {
74 | -1
75 | }
76 | }
77 |
78 | internal fun LeadingMarginSpan.asParagraphContent(
79 | range: IntRange,
80 | drawer: ParagraphContentDrawer
81 | ): ParagraphContent {
82 | return ParagraphContent(
83 | firstLeadingMargin = getLeadingMargin(true),
84 | restLeadingMargin = getLeadingMargin(false),
85 | start = range.first,
86 | end = range.last,
87 | drawer = drawer
88 | )
89 | }
90 |
91 | internal fun LeadingMarginSpan.Standard.asParagraphContent(
92 | range: IntRange
93 | ): ParagraphContent {
94 | return asParagraphContent(
95 | range = range,
96 | drawer = { _, _ -> }
97 | )
98 | }
99 |
100 | internal fun AlignmentSpan.asParagraphContent(
101 | range: IntRange
102 | ): ParagraphContent {
103 | return ParagraphContent(
104 | start = range.first,
105 | end = range.last,
106 | alignment = when (requireNotNull(alignment)) {
107 | Layout.Alignment.ALIGN_NORMAL -> TextAlign.Start
108 | Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End
109 | Layout.Alignment.ALIGN_CENTER -> TextAlign.Center
110 | }
111 | )
112 | }
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/IconParagraph.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.annotation.SuppressLint
4 | import android.graphics.Bitmap
5 | import android.graphics.drawable.Drawable
6 | import android.os.Build
7 | import android.text.style.IconMarginSpan as AndroidIconMarginSpan
8 | import android.text.style.DrawableMarginSpan as AndroidDrawableMarginSpan
9 | import androidx.compose.ui.geometry.Offset
10 | import androidx.compose.ui.graphics.ImageBitmap
11 | import androidx.compose.ui.graphics.asImageBitmap
12 | import androidx.compose.ui.graphics.drawscope.DrawScope
13 | import androidx.compose.ui.text.style.ResolvedTextDirection
14 |
15 | /**
16 | * Implementation of an [ParagraphContentDrawer] to
17 | * render an icon at start of the paragraph.
18 | */
19 | private class IconParagraphContentDrawer(
20 | val image: ImageBitmap?
21 | ) : ParagraphContentDrawer {
22 |
23 | override fun onDraw(
24 | drawScope: DrawScope,
25 | layoutInfo: ParagraphLayoutInfo
26 | ) {
27 | if (image == null) return
28 |
29 | drawScope.drawImage(
30 | image = image,
31 | topLeft = Offset(
32 | x = if (layoutInfo.direction == ResolvedTextDirection.Rtl) {
33 | layoutInfo.x - image.width
34 | } else {
35 | layoutInfo.x
36 | },
37 | y = layoutInfo.top
38 | )
39 | )
40 | }
41 | }
42 |
43 | internal fun AndroidIconMarginSpan.asParagraphContent(
44 | range: IntRange
45 | ): ParagraphContent {
46 | return asParagraphContent(
47 | range = range,
48 | drawer = IconParagraphContentDrawer(
49 | image = getImage()
50 | )
51 | )
52 | }
53 |
54 | internal fun AndroidDrawableMarginSpan.asParagraphContent(
55 | range: IntRange
56 | ): ParagraphContent {
57 | return asParagraphContent(
58 | range = range,
59 | drawer = IconParagraphContentDrawer(
60 | image = getImage()
61 | )
62 | )
63 | }
64 |
65 | @SuppressLint("PrivateApi")
66 | private fun AndroidIconMarginSpan.getImage(): ImageBitmap? {
67 | return try {
68 | if (this is IconMarginSpan) {
69 | bitmap.asImageBitmap()
70 | } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
71 | bitmap.asImageBitmap()
72 | } else {
73 | javaClass.getDeclaredField("mBitmap")
74 | .run {
75 | isAccessible = true
76 | return (get(this@getImage) as? Bitmap)
77 | ?.asImageBitmap()
78 | }
79 | }
80 | } catch (ignore: Exception) {
81 | null
82 | }
83 | }
84 |
85 | @SuppressLint("PrivateApi")
86 | private fun AndroidDrawableMarginSpan.getImage(): ImageBitmap? {
87 | return try {
88 | if (this is DrawableMarginSpan) {
89 | drawable.toImageBitmap()
90 | } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
91 | drawable.toImageBitmap()
92 | } else {
93 | javaClass.getDeclaredField("mDrawable")
94 | .run {
95 | isAccessible = true
96 | return (get(this@getImage) as? Drawable)
97 | ?.toImageBitmap()
98 | }
99 | }
100 | } catch (ignore: Exception) {
101 | null
102 | }
103 | }
104 |
105 | class IconMarginSpan(
106 | @JvmField val bitmap: Bitmap,
107 | padding: Int = 0
108 | ) : AndroidIconMarginSpan(bitmap, padding)
109 |
110 | class DrawableMarginSpan(
111 | @JvmField val drawable: Drawable,
112 | padding: Int = 0
113 | ) : AndroidDrawableMarginSpan(drawable, padding)
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/QuoteParagraph.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.graphics.Canvas
4 | import android.graphics.Paint
5 | import android.os.Build
6 | import android.os.Parcel
7 | import android.os.Parcelable
8 | import android.text.Layout
9 | import androidx.compose.ui.geometry.Offset
10 | import androidx.compose.ui.geometry.Size
11 | import androidx.compose.ui.graphics.Color
12 | import androidx.compose.ui.graphics.drawscope.DrawScope
13 | import android.text.style.QuoteSpan as AndroidQuoteSpan
14 |
15 | /**
16 | * Implementation of [ParagraphContentDrawer] to
17 | * render the quote vertical stripe.
18 | *
19 | * @see ParagraphContentDrawer
20 | * @see QuoteSpan
21 | */
22 | private class QuoteParagraphContentDrawer(
23 | val color: Color,
24 | val stripeWidth: Int
25 | ) : ParagraphContentDrawer {
26 |
27 | override fun onDraw(
28 | drawScope: DrawScope,
29 | layoutInfo: ParagraphLayoutInfo
30 | ) {
31 | drawScope.drawRect(
32 | color = color,
33 | topLeft = Offset(layoutInfo.x, layoutInfo.top),
34 | size = Size(
35 | width = layoutInfo.dirSign * stripeWidth.toFloat(),
36 | height = layoutInfo.height
37 | )
38 | )
39 | }
40 | }
41 |
42 | internal fun AndroidQuoteSpan.asParagraphContent(
43 | range: IntRange
44 | ): ParagraphContent {
45 | return if (this is QuoteSpan) {
46 | asParagraphContent(
47 | range = range,
48 | drawer = QuoteParagraphContentDrawer(
49 | color = Color(lineColor),
50 | stripeWidth = lineStripeWidth,
51 | )
52 | )
53 | } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
54 | asParagraphContent(
55 | range = range,
56 | drawer = QuoteParagraphContentDrawer(
57 | color = Color(color),
58 | stripeWidth = stripeWidth,
59 | )
60 | )
61 | } else {
62 | asParagraphContent(
63 | range = range,
64 | drawer = QuoteParagraphContentDrawer(
65 | color = Color(color),
66 | stripeWidth = 2,
67 | )
68 | )
69 | }
70 | }
71 |
72 | /**
73 | * A span which styles paragraphs by adding a vertical stripe
74 | * at the beginning of the text (respecting layout direction).
75 | *
76 | * @see AndroidQuoteSpan
77 | */
78 | class QuoteSpan(
79 | val lineColor: Int = -0xffff01,
80 | val lineStripeWidth: Int = 2,
81 | private val paragraphGapWidth: Int = 2
82 | ) : AndroidQuoteSpan() {
83 |
84 | private constructor(parcel: Parcel) : this(
85 | lineColor = parcel.readInt(),
86 | lineStripeWidth = parcel.readInt(),
87 | paragraphGapWidth = parcel.readInt()
88 | )
89 |
90 | override fun writeToParcel(dest: Parcel, flags: Int) {
91 | dest.writeInt(lineColor)
92 | dest.writeInt(lineStripeWidth)
93 | dest.writeInt(paragraphGapWidth)
94 | }
95 |
96 | override fun getColor() = lineColor
97 | override fun getStripeWidth() = lineStripeWidth
98 | override fun getGapWidth() = paragraphGapWidth
99 |
100 | override fun getLeadingMargin(first: Boolean): Int {
101 | return lineStripeWidth + paragraphGapWidth
102 | }
103 |
104 | override fun drawLeadingMargin(
105 | c: Canvas, p: Paint, x: Int, dir: Int,
106 | top: Int, baseline: Int, bottom: Int,
107 | text: CharSequence, start: Int, end: Int,
108 | first: Boolean, layout: Layout
109 | ) {
110 | val style = p.style
111 | val color = p.color
112 | p.style = Paint.Style.FILL
113 | p.color = lineColor
114 | c.drawRect(
115 | x.toFloat(),
116 | top.toFloat(),
117 | (x + dir * lineStripeWidth).toFloat(),
118 | bottom.toFloat(),
119 | p
120 | )
121 | p.style = style
122 | p.color = color
123 | }
124 |
125 | companion object {
126 | @Suppress("unused")
127 | @JvmField
128 | val CREATOR = object : Parcelable.Creator {
129 | override fun createFromParcel(parcel: Parcel) = QuoteSpan(parcel)
130 | override fun newArray(size: Int) = arrayOfNulls(size)
131 | }
132 | }
133 | }
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/FontHelper.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.content.Context
4 | import android.graphics.Typeface
5 | import android.os.Build
6 | import android.text.style.StyleSpan
7 | import android.text.style.TypefaceSpan
8 | import androidx.compose.ui.text.font.AndroidFont
9 | import androidx.compose.ui.text.font.FontFamily
10 | import androidx.compose.ui.text.font.FontLoadingStrategy
11 | import androidx.compose.ui.text.font.FontStyle
12 | import androidx.compose.ui.text.font.FontVariation
13 | import androidx.compose.ui.text.font.FontWeight
14 | import androidx.compose.ui.text.font.toFontFamily
15 | import kotlin.math.max
16 | import kotlin.math.min
17 |
18 | /**
19 | * @return [FontFamily] of the given [TypefaceSpan].
20 | */
21 | internal fun TypefaceSpan.asFontFamily(): FontFamily? {
22 | return if (family != null) {
23 | getFontFamily(family)
24 | } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
25 | getFontFamily(typeface)
26 | } else {
27 | null
28 | }
29 | }
30 |
31 | /**
32 | * @return [FontFamily] of the given family name.
33 | */
34 | internal fun getFontFamily(family: String?): FontFamily? {
35 | return when (family?.lowercase()) {
36 | FontFamily.SansSerif.name -> FontFamily.SansSerif
37 | FontFamily.Serif.name -> FontFamily.Serif
38 | FontFamily.Monospace.name -> FontFamily.Monospace
39 | FontFamily.Cursive.name -> FontFamily.Cursive
40 | else -> if (family != null) {
41 | Typeface.create(family, Typeface.NORMAL)
42 | .toFontFamily()
43 | } else null
44 | }
45 | }
46 |
47 | /**
48 | * @return [FontFamily] of the given [Typeface].
49 | */
50 | internal fun getFontFamily(typeface: Typeface?): FontFamily? {
51 | return when (typeface) {
52 | Typeface.SANS_SERIF -> FontFamily.SansSerif
53 | Typeface.SERIF -> FontFamily.Serif
54 | Typeface.MONOSPACE -> FontFamily.Monospace
55 | else -> typeface?.toFontFamily()
56 | }
57 | }
58 |
59 | private class TypefaceAsFont(
60 | typeface: Typeface
61 | ) : AndroidFont(
62 | FontLoadingStrategy.OptionalLocal,
63 | ReadyTypefaceLoader(typeface),
64 | FontVariation.Settings()
65 | ) {
66 |
67 | override val style: FontStyle =
68 | if (typeface.isItalic) {
69 | FontStyle.Italic
70 | } else {
71 | FontStyle.Normal
72 | }
73 |
74 | override val weight: FontWeight =
75 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
76 | FontWeight(typeface.weight)
77 | } else if (typeface.isBold) {
78 | FontWeight.Bold
79 | } else {
80 | FontWeight.Normal
81 | }
82 | }
83 |
84 | private class ReadyTypefaceLoader(
85 | val typeface: Typeface
86 | ) : AndroidFont.TypefaceLoader {
87 |
88 | override suspend fun awaitLoad(
89 | context: Context,
90 | font: AndroidFont
91 | ): Typeface {
92 | return typeface
93 | }
94 |
95 | override fun loadBlocking(
96 | context: Context,
97 | font: AndroidFont
98 | ): Typeface {
99 | return typeface
100 | }
101 | }
102 |
103 | private fun Typeface.toFontFamily(): FontFamily {
104 | return TypefaceAsFont(this).toFontFamily()
105 | }
106 |
107 | /**
108 | * @return new [FontWeight] adjusted with the given [StyleSpan].
109 | */
110 | internal fun FontWeight?.adjust(styleSpan: StyleSpan): FontWeight? {
111 | val fw = when (styleSpan.style) {
112 | Typeface.BOLD,
113 | Typeface.BOLD_ITALIC -> FontWeight.Bold
114 | Typeface.NORMAL -> FontWeight.Normal
115 | else -> this
116 | }
117 |
118 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
119 | && isAcceptableWeight(styleSpan.fontWeightAdjustment)
120 | ) {
121 | val def = fw?.weight ?: FontWeight.Normal.weight
122 | return FontWeight(
123 | max(
124 | min(
125 | def + styleSpan.fontWeightAdjustment,
126 | MAX_FONT_WEIGHT
127 | ),
128 | MIN_FONT_WEIGHT
129 | )
130 | )
131 | }
132 | return fw
133 | }
134 |
135 | /**
136 | * @return True if weight is in range of [1, 1000]
137 | */
138 | internal fun isAcceptableWeight(weight: Int): Boolean {
139 | return weight in MIN_FONT_WEIGHT..MAX_FONT_WEIGHT
140 | }
141 |
142 | private const val MIN_FONT_WEIGHT = 1
143 | private const val MAX_FONT_WEIGHT = 1000
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AnnotatedText
2 | [](http://developer.android.com/index.html)
3 | [](https://android-arsenal.com/api?level=21)
4 | [](https://search.maven.org/artifact/io.github.aghajari/AnnotatedText/1.0.3/aar)
5 | [](https://gitter.im/Aghajari/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
6 |
7 | A Jetpack Compose library to fully convert Android's [Spanned](https://developer.android.com/reference/android/text/Spanned) into [AnnotatedString](https://developer.android.com/reference/kotlin/androidx/compose/ui/text/AnnotatedString).
8 |
9 | You can use this library to display [Html.fromHtml(String)](https://developer.android.com/reference/android/text/Html) on Compose [Text](https://developer.android.com/jetpack/compose/text),
10 | Exactly similar to what was previously displayed on Android [TextView](https://developer.android.com/reference/android/widget/TextView).
11 |
12 | **Compose Text** Vs **Android TextView**
13 |
14 |
15 | ## Supported Spans
16 | **AnnotatedText** supports all default android spans both [CharacterStyle](https://developer.android.com/reference/android/text/style/CharacterStyle) and [ParagraphStyle](https://developer.android.com/reference/android/text/style/ParagraphStyle) spans.
17 |
18 | **CharacterStyle** spans:
19 | - URLSpan
20 | - ImageSpan
21 | - AbsoluteSizeSpan
22 | - RelativeSizeSpan
23 | - BackgroundColorSpan
24 | - ForegroundColorSpan
25 | - StrikethroughSpan
26 | - UnderlineSpan
27 | - StyleSpan
28 | - SubscriptSpan
29 | - SuperscriptSpan
30 | - ScaleXSpan
31 | - SkewXSpan
32 | - LocaleSpan
33 | - TextAppearanceSpan
34 | - TypefaceSpan
35 |
36 | **ParagraphStyle** spans:
37 | - QuoteSpan
38 | - BulletSpan
39 | - IconMarginSpan
40 | - DrawableMarginSpan
41 | - LeadingMarginSpan.Standard
42 | - AlignmentSpan (v1.0.4)
43 | - LineBackgroundSpan (v1.0.4)
44 | - LineHeightSpan (v1.0.4)
45 |
46 | ## Installation
47 |
48 | **AnnotatedText** is available in `mavenCentral()`
49 |
50 | Gradle
51 | ```gradle
52 | implementation 'io.github.aghajari:AnnotatedText:1.0.3'
53 | ```
54 |
55 | Maven
56 | ```xml
57 |
58 | io.github.aghajari
59 | AnnotatedText
60 | 1.0.3
61 | pom
62 |
63 | ```
64 |
65 | ## Usage
66 |
67 | ### Spanned asAnnotatedString
68 | ```kotlin
69 | val spanned = buildSpannedString {
70 | append("Hello ")
71 | inSpans(UnderlineSpan()) {
72 | append("World!")
73 | }
74 | }
75 |
76 | AnnotatedText(
77 | text = spanned.asAnnotatedString(),
78 | ...
79 | )
80 | ```
81 |
82 | ### From HTML
83 | ```kotlin
84 | AnnotatedText(
85 | text = "Hello World!".fromHtml(),
86 | ...
87 | )
88 | ```
89 |
90 | ### URL onClick
91 | ```kotlin
92 | AnnotatedText(
93 | text = "Link to GitHub".fromHtml(linkColor = Color.Blue),
94 | onURLClick = { url ->
95 | println(url)
96 | }
97 | )
98 | ```
99 |
100 | ### Custom SpanMapper
101 |
102 |
103 | ```kotlin
104 | val content = remember {
105 | "Welcome to my GitHub".fromHtml(
106 | spanMappers = mapOf(
107 | URLSpan::class to {
108 | linkColor = Color.Red
109 | textDecoration = TextDecoration.LineThrough
110 | }
111 | )
112 | )
113 | }
114 |
115 | AnnotatedText(
116 | text = content,
117 | ...
118 | )
119 | ```
120 |
121 | ### Default Compose Text + Modifier
122 | ```kotlin
123 | val content = remember {
124 | "
Hello World!
".fromHtml()
125 | }
126 | val layoutResult = remember {
127 | mutableStateOf(null)
128 | }
129 |
130 | Text(
131 | text = content.annotatedString,
132 | inlineContent = content.getInlineContentMap(),
133 | onTextLayout = { layoutResult.value = it },
134 | modifier = Modifier
135 | .annotatedTextParagraphContents(content, layoutResult)
136 | .annotatedTextClickable(content, layoutResult) { url ->
137 | println(url)
138 | }
139 | )
140 | ```
141 |
142 | ## Author
143 | Amir Hossein Aghajari
144 |
145 | License
146 | =======
147 |
148 | Copyright 2023 Amir Hossein Aghajari
149 | Licensed under the Apache License, Version 2.0 (the "License");
150 | you may not use this file except in compliance with the License.
151 | You may obtain a copy of the License at
152 |
153 | http://www.apache.org/licenses/LICENSE-2.0
154 |
155 | Unless required by applicable law or agreed to in writing, software
156 | distributed under the License is distributed on an "AS IS" BASIS,
157 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
158 | See the License for the specific language governing permissions and
159 | limitations under the License.
160 |
161 |
162 |
166 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/BulletParagraph.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.graphics.Canvas
4 | import android.graphics.Paint
5 | import android.os.Build
6 | import android.os.Parcel
7 | import android.os.Parcelable
8 | import android.text.Layout
9 | import android.text.Spanned
10 | import android.text.style.BulletSpan as AndroidBulletSpan
11 | import androidx.compose.ui.geometry.Offset
12 | import androidx.compose.ui.graphics.Color
13 | import androidx.compose.ui.graphics.drawscope.DrawScope
14 | import androidx.compose.ui.graphics.drawscope.Fill
15 | import androidx.compose.ui.graphics.drawscope.Stroke
16 | import androidx.compose.ui.graphics.isUnspecified
17 | import kotlin.math.ceil
18 |
19 | /**
20 | * Implementation of [ParagraphContentDrawer] to
21 | * render the paragraph bullet points.
22 | *
23 | * @see ParagraphContentDrawer
24 | * @see BulletSpan
25 | */
26 | private class BulletParagraphContentDrawer(
27 | val color: Color,
28 | val bulletRadius: Float,
29 | val strokeWidth: Float = Float.NaN
30 | ) : ParagraphContentDrawer {
31 |
32 | override fun onDraw(
33 | drawScope: DrawScope,
34 | layoutInfo: ParagraphLayoutInfo
35 | ) {
36 | val startBottom = layoutInfo.result.getLineBottom(layoutInfo.startLine)
37 | val (radius, style) = if (strokeWidth.isNaN()) {
38 | bulletRadius to Fill
39 | } else {
40 | (bulletRadius - strokeWidth / 2f) to Stroke(strokeWidth)
41 | }
42 |
43 | drawScope.drawCircle(
44 | color = if (color.isUnspecified) {
45 | layoutInfo.result.layoutInput.style.color
46 | } else {
47 | color
48 | },
49 | center = Offset(
50 | x = layoutInfo.x + layoutInfo.dirSign * bulletRadius,
51 | y = (layoutInfo.top + startBottom) / 2f
52 | ),
53 | radius = radius,
54 | style = style
55 | )
56 | }
57 | }
58 |
59 | internal fun AndroidBulletSpan.asParagraphContent(
60 | range: IntRange
61 | ): ParagraphContent {
62 | return if (this is BulletSpan) {
63 | asParagraphContent(
64 | range = range,
65 | drawer = BulletParagraphContentDrawer(
66 | color = if (wantColor) {
67 | Color(bulletColor)
68 | } else {
69 | Color.Unspecified
70 | },
71 | bulletRadius = radius,
72 | strokeWidth = strokeWidth
73 | )
74 | )
75 | } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
76 | asParagraphContent(
77 | range = range,
78 | drawer = BulletParagraphContentDrawer(
79 | color = if (color != 0) {
80 | Color(color)
81 | } else {
82 | Color.Unspecified
83 | },
84 | bulletRadius = bulletRadius.toFloat(),
85 | )
86 | )
87 | } else {
88 | asParagraphContent(
89 | range = range,
90 | drawer = BulletParagraphContentDrawer(
91 | color = Color.Unspecified,
92 | bulletRadius = 4f,
93 | )
94 | )
95 | }
96 | }
97 |
98 | /**
99 | * A span which styles paragraphs as bullet points
100 | * (respecting layout direction)
101 | *
102 | * This span supports Stroke draw style mode.
103 | *
104 | * @see AndroidBulletSpan
105 | */
106 | class BulletSpan(
107 | val bulletColor: Int = 0,
108 | val radius: Float = 4f,
109 | val strokeWidth: Float = Float.NaN,
110 | private val paragraphGapWidth: Int = 2
111 | ) : AndroidBulletSpan() {
112 |
113 | internal val wantColor: Boolean
114 | get() = bulletColor != 0
115 |
116 | private constructor(parcel: Parcel) : this(
117 | bulletColor = parcel.readInt(),
118 | radius = parcel.readFloat(),
119 | strokeWidth = parcel.readFloat(),
120 | paragraphGapWidth = parcel.readInt()
121 | )
122 |
123 | override fun writeToParcel(dest: Parcel, flags: Int) {
124 | dest.writeInt(bulletColor)
125 | dest.writeFloat(radius)
126 | dest.writeFloat(strokeWidth)
127 | dest.writeInt(paragraphGapWidth)
128 | }
129 |
130 | override fun getGapWidth() = paragraphGapWidth
131 | override fun getBulletRadius() = radius.toInt()
132 | override fun getColor() = bulletColor
133 |
134 | override fun getLeadingMargin(first: Boolean): Int {
135 | return ceil(2 * radius + paragraphGapWidth).toInt()
136 | }
137 |
138 | override fun drawLeadingMargin(
139 | canvas: Canvas, paint: Paint, x: Int, dir: Int,
140 | top: Int, baseline: Int, bottom: Int,
141 | text: CharSequence, start: Int, end: Int,
142 | first: Boolean, layout: Layout?
143 | ) {
144 | if ((text as Spanned).getSpanStart(this) == start) {
145 | val style = paint.style
146 | var stroke = 0f
147 | var oldColor = 0
148 | val circleRadius: Float
149 | if (wantColor) {
150 | oldColor = paint.color
151 | paint.color = bulletColor
152 | }
153 | if (strokeWidth.isNaN()) {
154 | paint.style = Paint.Style.FILL
155 | circleRadius = radius
156 | } else {
157 | stroke = paint.strokeWidth
158 | paint.style = Paint.Style.STROKE
159 | paint.strokeWidth = strokeWidth
160 | circleRadius = radius - (strokeWidth / 2f)
161 | }
162 | canvas.drawCircle(
163 | x + dir * radius,
164 | (top + bottom) / 2f,
165 | circleRadius,
166 | paint
167 | )
168 | if (wantColor) {
169 | paint.color = oldColor
170 | }
171 | if (strokeWidth.isNaN().not()) {
172 | paint.strokeWidth = stroke
173 | }
174 | paint.style = style
175 | }
176 | }
177 |
178 | companion object {
179 | @Suppress("unused")
180 | @JvmField
181 | val CREATOR = object : Parcelable.Creator {
182 | override fun createFromParcel(parcel: Parcel) = BulletSpan(parcel)
183 | override fun newArray(size: Int) = arrayOfNulls(size)
184 | }
185 | }
186 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/ContentAnnotatedString.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import androidx.compose.runtime.Immutable
4 | import androidx.compose.runtime.Stable
5 | import androidx.compose.ui.text.AnnotatedString
6 |
7 | /**
8 | * The data structure of text with multiple styles.
9 | */
10 | @Immutable
11 | class ContentAnnotatedString(
12 | val annotatedString: AnnotatedString,
13 | val inlineContents: List,
14 | val paragraphContents: List,
15 | internal val hasUrl: Boolean =
16 | annotatedString.hasURL(0, annotatedString.length)
17 | ) : CharSequence {
18 |
19 | override val length: Int
20 | get() = annotatedString.length
21 |
22 | override operator fun get(index: Int): Char = annotatedString[index]
23 |
24 | /**
25 | * Return a substring for the [ContentAnnotatedString]
26 | * and include the styles in the range of
27 | * [startIndex] (inclusive) and [endIndex] (exclusive).
28 | *
29 | * @param startIndex the inclusive start offset of the range
30 | * @param endIndex the exclusive end offset of the range
31 | */
32 | override fun subSequence(startIndex: Int, endIndex: Int): ContentAnnotatedString {
33 | val subAnnotatedString = annotatedString.subSequence(startIndex, endIndex)
34 | return ContentAnnotatedString(
35 | annotatedString = subAnnotatedString,
36 | inlineContents = filterInlineContents(inlineContents, startIndex, endIndex),
37 | paragraphContents = filterParagraphContents(paragraphContents, startIndex, endIndex),
38 | hasUrl = hasURL(startIndex, endIndex)
39 | )
40 | }
41 |
42 | @Stable
43 | operator fun plus(other: ContentAnnotatedString): ContentAnnotatedString {
44 | return ContentAnnotatedString(
45 | annotatedString = annotatedString + other.annotatedString,
46 | inlineContents = inlineContents + other.inlineContents,
47 | paragraphContents = paragraphContents + other.paragraphContents,
48 | hasUrl = hasUrl or other.hasUrl
49 | )
50 | }
51 |
52 | /**
53 | * Returns URLs attached on this AnnotatedString.
54 | *
55 | * @param start the start of the query range, inclusive.
56 | * @param end the end of the query range, exclusive.
57 | * @return a list of URLs stored in [AnnotatedString.Range].
58 | * Notice that All annotations that intersect with the range
59 | * [start, end) will be returned. When [start] is bigger than
60 | * [end], an empty list will be returned.
61 | */
62 | fun getURLs(start: Int, end: Int): List> {
63 | return annotatedString.getURLs(start, end)
64 | }
65 |
66 | /**
67 | * Returns true if [getURLs] with the same parameters
68 | * would return a non-empty list
69 | */
70 | fun hasURL(start: Int, end: Int): Boolean {
71 | return annotatedString.hasURL(start, end)
72 | }
73 |
74 | override fun equals(other: Any?): Boolean {
75 | if (this === other) return true
76 | if (other !is ContentAnnotatedString) return false
77 | if (hasUrl != other.hasUrl) return false
78 | if (annotatedString != other.annotatedString) return false
79 | if (inlineContents != other.inlineContents) return false
80 | if (paragraphContents != other.paragraphContents) return false
81 | return true
82 | }
83 |
84 | override fun hashCode(): Int {
85 | var result = annotatedString.hashCode()
86 | result = 31 * result + inlineContents.hashCode()
87 | result = 31 * result + paragraphContents.hashCode()
88 | result = 31 * result + hasUrl.hashCode()
89 | return result
90 | }
91 |
92 | override fun toString(): String {
93 | return annotatedString.toString()
94 | }
95 | }
96 |
97 | /**
98 | * Filter the inline contents to include items only in the range
99 | * of [start] (inclusive) and [end] (exclusive).
100 | *
101 | * @param start the inclusive start offset of the text range
102 | * @param end the exclusive end offset of the text range
103 | */
104 | private fun filterInlineContents(
105 | contents: List,
106 | start: Int,
107 | end: Int
108 | ): List {
109 | require(start <= end) {
110 | "start ($start) should be less than or equal to end ($end)"
111 | }
112 |
113 | return contents.filter { intersect(start, end, it.start, it.end) }.map {
114 | InlineContent(
115 | span = it.span,
116 | id = it.id,
117 | start = maxOf(start, it.start) - start,
118 | end = minOf(end, it.end) - start,
119 | creator = it.creator
120 | )
121 | }.toList()
122 | }
123 |
124 | /**
125 | * Filter the paragraph contents to include items only in the range
126 | * of [start] (inclusive) and [end] (exclusive).
127 | *
128 | * @param start the inclusive start offset of the text range
129 | * @param end the exclusive end offset of the text range
130 | */
131 | private fun filterParagraphContents(
132 | contents: List,
133 | start: Int,
134 | end: Int
135 | ): List {
136 | require(start <= end) {
137 | "start ($start) should be less than or equal to end ($end)"
138 | }
139 |
140 | return contents.filter { intersect(start, end, it.start, it.end) }.map {
141 | ParagraphContent(
142 | firstLeadingMargin = it.firstLeadingMargin,
143 | restLeadingMargin = it.restLeadingMargin,
144 | start = maxOf(start, it.start) - start,
145 | end = minOf(end, it.end) - start,
146 | drawer = it.drawer
147 | )
148 | }.toList()
149 | }
150 |
151 | /**
152 | * Helper function that checks if the range [lStart, lEnd) intersects with the range
153 | * [rStart, rEnd).
154 | *
155 | * @return [lStart, lEnd) intersects with range [rStart, rEnd), vice versa.
156 | */
157 | private fun intersect(lStart: Int, lEnd: Int, rStart: Int, rEnd: Int) =
158 | maxOf(lStart, rStart) < minOf(lEnd, rEnd) ||
159 | contains(lStart, lEnd, rStart, rEnd) || contains(rStart, rEnd, lStart, lEnd)
160 |
161 | /**
162 | * Helper function that checks if the range [baseStart, baseEnd) contains the range
163 | * [targetStart, targetEnd).
164 | *
165 | * @return true if [baseStart, baseEnd) contains [targetStart, targetEnd), vice versa.
166 | * When [baseStart]==[baseEnd] it return true iff [targetStart]==[targetEnd]==[baseStart].
167 | */
168 | private fun contains(baseStart: Int, baseEnd: Int, targetStart: Int, targetEnd: Int) =
169 | (baseStart <= targetStart && targetEnd <= baseEnd) &&
170 | (baseEnd != targetEnd || (targetStart == targetEnd) == (baseStart == baseEnd))
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/ParagraphStyleSpans.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.text.Spanned
4 | import android.text.style.AlignmentSpan
5 | import android.text.style.BulletSpan
6 | import android.text.style.IconMarginSpan
7 | import android.text.style.DrawableMarginSpan
8 | import android.text.style.LeadingMarginSpan
9 | import android.text.style.LineBackgroundSpan
10 | import android.text.style.LineHeightSpan
11 | import android.text.style.QuoteSpan
12 | import android.text.style.ParagraphStyle as AndroidParagraphStyle
13 | import androidx.compose.ui.text.AnnotatedString
14 | import androidx.compose.ui.text.ParagraphStyle
15 | import java.lang.IllegalArgumentException
16 |
17 | /**
18 | * Adding [ParagraphStyle] to [AnnotatedString] causes the specified
19 | * range to be separated completely. As a result, the scope of
20 | * a paragraph becomes completely independent. The beginning of
21 | * the range will be the beginning of the paragraph and the end of
22 | * the range will be the end of the paragraph. Therefore,
23 | * in order not to display additional NewLines, we must first
24 | * rearrange the text by considering how ParagraphStyle works
25 | * and identify and remove the additional NewLines.
26 | *
27 | * For example this spanned:
28 | * ```
29 | * buildSpannedString {
30 | * append("Hello\n")
31 | * inSpans(BulletSpan()) {
32 | * append("Item\n")
33 | * }
34 | * append("Done")
35 | * }
36 | * ```
37 | * Will result in:
38 | * ```
39 | * Hello
40 | *
41 | * - Item
42 | *
43 | * Done
44 | * ```
45 | * This function will identify and remove the additional NewLines,
46 | * And the final result must be:
47 | * ```
48 | * Hello
49 | * - Item
50 | * Done
51 | * ```
52 | */
53 | internal fun Spanned.supportParagraphStyleSpans(): Spanned {
54 | val spans = getSpans(0, length, AndroidParagraphStyle::class.java)
55 | spans.sortBy { getSpanStart(it) }
56 |
57 | return if (spans.isNotEmpty()) {
58 | toBuilder().apply {
59 | var reserved = -1
60 | spans.forEach { span ->
61 | setSpan(
62 | span,
63 | getSpanStart(span),
64 | getSpanEnd(span),
65 | PARAGRAPH_CONTENT
66 | )
67 | }
68 | spans.forEach { span ->
69 | var start = getSpanStart(span)
70 | if (start < reserved) {
71 | if (span.isSupportedLeadingMarginSpan()) {
72 | removeSpan(span)
73 | }
74 | } else {
75 | var remove = when {
76 | span.isSupportedParagraphStyle().not() -> -1
77 | start == 0 -> 0
78 | start == reserved -> 0
79 | get(start - 1) == NEW_LINE && start == reserved + 1 -> {
80 | replace(start - 1, start, CRLF)
81 | start++
82 | 1
83 | }
84 | get(start - 1) == NEW_LINE -> 1
85 | else -> -1
86 | }
87 |
88 | var end = getSpanEnd(span)
89 | if (getOrNull(end - 1) != NEW_LINE) {
90 | subSequence(end, length).indexOfFirst {
91 | it == NEW_LINE
92 | }.let { indexOfNext ->
93 | end = if (indexOfNext == -1) {
94 | length
95 | } else {
96 | end + indexOfNext + 1
97 | }
98 | setSpan(span, start, end, PARAGRAPH_CONTENT)
99 | }
100 | }
101 |
102 | if (remove == -1) {
103 | if (span.supportsInternalBreakLine()) {
104 | removeSpan(span)
105 | val nextNewLine = subSequence(start, end - 1).indexOfFirst {
106 | it == NEW_LINE
107 | }
108 | if (nextNewLine != -1) {
109 | setSpan(
110 | span,
111 | nextNewLine + start + 1,
112 | end,
113 | PARAGRAPH_CONTENT
114 | )
115 | replace(start + nextNewLine, start + nextNewLine + 1, EMPTY)
116 | remove = 0
117 | }
118 | } else {
119 | removeSpan(span)
120 | }
121 | } else if (remove > 0) {
122 | replace(start - remove, start, EMPTY)
123 | remove = 0
124 | }
125 |
126 | if (remove == 0) {
127 | end = getSpanEnd(span)
128 |
129 | if (getOrNull(end - 1) == NEW_LINE) {
130 | if (length != end) {
131 | replace(end - 1, end, EMPTY)
132 | end--
133 | }
134 | }
135 | reserved = end
136 | }
137 | }
138 | }
139 | }
140 | } else {
141 | this
142 | }
143 | }
144 |
145 | private const val PARAGRAPH_CONTENT = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
146 | private const val NEW_LINE = '\n'
147 | private const val CRLF = "\r\n"
148 | private const val EMPTY = ""
149 |
150 | internal fun AndroidParagraphStyle.isSupportedLeadingMarginSpan(): Boolean {
151 | return this is QuoteSpan ||
152 | this is BulletSpan ||
153 | this is IconMarginSpan ||
154 | this is DrawableMarginSpan ||
155 | this is LeadingMarginSpan.Standard
156 | }
157 |
158 | internal fun AndroidParagraphStyle.isSupportedParagraphStyle(): Boolean {
159 | return isSupportedLeadingMarginSpan() ||
160 | this is AlignmentSpan ||
161 | this is LineBackgroundSpan ||
162 | this is LineHeightSpan
163 | }
164 |
165 | internal fun toParagraphContent(
166 | span: Any,
167 | range: IntRange
168 | ): ParagraphContent {
169 | return when (span) {
170 | is BulletSpan -> span.asParagraphContent(range)
171 | is QuoteSpan -> span.asParagraphContent(range)
172 | is IconMarginSpan -> span.asParagraphContent(range)
173 | is DrawableMarginSpan -> span.asParagraphContent(range)
174 | is LeadingMarginSpan.Standard -> span.asParagraphContent(range)
175 | is AlignmentSpan -> span.asParagraphContent(range)
176 | is LineBackgroundSpan -> span.asParagraphContent(range)
177 | is LineHeightSpan -> span.asParagraphContent(range)
178 | else -> throw IllegalArgumentException(
179 | "${span.javaClass.name} is not supported!"
180 | )
181 | }
182 | }
183 |
184 | private fun AndroidParagraphStyle.supportsInternalBreakLine(): Boolean {
185 | return isSupportedParagraphStyle() &&
186 | this !is BulletSpan
187 | }
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/ToAnnotatedString.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import android.text.Spanned
4 | import android.text.style.ImageSpan
5 | import android.text.style.ParagraphStyle as AndroidParagraphStyle
6 | import android.text.style.URLSpan
7 | import androidx.compose.ui.graphics.Color
8 | import androidx.compose.ui.text.SpanStyle
9 | import androidx.compose.ui.text.buildAnnotatedString
10 | import kotlin.math.max
11 | import kotlin.math.min
12 |
13 | /**
14 | * Create a [ContentAnnotatedString] from the given [Spanned].
15 | *
16 | * @param spanMappers the list of mappers to map an span to [SpanStyle].
17 | * The given span mappers will be replaced with default mappers,
18 | * Pass Null to use all default span mappers.
19 | * If you set a specific kind of Span equal to Null,
20 | * all Spans of that kind will be ignored.
21 | * @param linkColor the default color for URLs.
22 | * @param isParagraphContentsEnabled Specifies whether to use [ParagraphContent]s
23 | * such as [QuoteSpan] and [BulletSpan] or not.
24 | * For correct display, you must use [AnnotatedText]
25 | * or add [annotatedTextParagraphContents] to modifiers.
26 | * @author AmirHossein Aghajari
27 | */
28 | fun Spanned.asAnnotatedString(
29 | spanMappers: SpanMapperMap? = null,
30 | linkColor: Color = Color.Blue,
31 | isParagraphContentsEnabled: Boolean = true,
32 | linkColorMapper: ((URLSpan) -> Color?)? = null
33 | ): ContentAnnotatedString {
34 | val fixed = if (isParagraphContentsEnabled) {
35 | supportParagraphStyleSpans()
36 | } else {
37 | this
38 | }
39 |
40 | val mappers = getDefaultSpanMappers()
41 | if (spanMappers != null) {
42 | mappers.putAll(spanMappers)
43 | }
44 |
45 | var hasUrl = false
46 | val inlineContent = mutableListOf()
47 | val paragraphContent = mutableListOf()
48 |
49 | val annotatedString = buildAnnotatedString {
50 | append(fixed.toString())
51 |
52 | val paragraphStyles = mutableListOf()
53 | fixed.mapSpans().forEach { (range, spans) ->
54 | mergeSpans(
55 | spans = spans,
56 | range = range,
57 | linkColor = linkColor,
58 | linkColorMapper = linkColorMapper,
59 | urlSpanMapper = { urlSpan ->
60 | hasUrl = true
61 | addURL(urlSpan, range)
62 | },
63 | inlineContentMapper = { content ->
64 | inlineContent.add(content)
65 | addInlineContent(content)
66 | },
67 | paragraphContentMapper = { content ->
68 | // If the ParagraphContent represents a drawer-only configuration,
69 | // we don't need to add a ParagraphStyle to the AnnotatedString.
70 | // Instead, we can directly add the drawer content to the output list.
71 | // Since drawer-only content doesn't affect paragraph styling or overlap
72 | // with other paragraphs, no further checks are needed in such cases.
73 | val isDrawerOnly = content.isDrawerOnly()
74 | if (isDrawerOnly) {
75 | paragraphContent.add(content)
76 | }
77 | isDrawerOnly
78 | },
79 | isParagraphContentsEnabled,
80 | mappers
81 | ).let {
82 | addStyle(it.toSpanStyle(), range.first, range.last)
83 | if (it.hasParagraphStyle()) {
84 | paragraphStyles.safeAdd(ParagraphStyleHolder(it, range))
85 | }
86 | }
87 | }
88 |
89 | paragraphStyles.forEach {
90 | paragraphContent.addAll(it.style.paragraphContents)
91 | addStyle(requireNotNull(it.style.toParagraphStyle()), it.range.first, it.range.last)
92 | }
93 | }
94 |
95 | return ContentAnnotatedString(
96 | annotatedString = annotatedString,
97 | inlineContents = inlineContent.optimize(),
98 | paragraphContents = paragraphContent.optimize(),
99 | hasUrl = hasUrl
100 | )
101 | }
102 |
103 | /**
104 | * Merges all spans within the specified range and returns a combined [MutableSpanStyle].
105 | *
106 | * This function aids in consolidating multiple spans applied to the same text range
107 | * into a single [SpanStyle], promoting a more concise and maintainable approach to
108 | * text styling within the [ContentAnnotatedString.annotatedString].
109 | */
110 | private fun mergeSpans(
111 | spans: List,
112 | range: IntRange,
113 | linkColor: Color,
114 | linkColorMapper: ((URLSpan) -> Color?)? = null,
115 | urlSpanMapper: (URLSpan) -> Unit,
116 | inlineContentMapper: (InlineContent) -> Unit,
117 | paragraphContentMapper: (ParagraphContent) -> Boolean,
118 | supportsParagraphContent: Boolean,
119 | spanMapper: SpanMapperMap
120 | ): MutableSpanStyle {
121 | val style = MutableSpanStyle(
122 | linkColor = linkColor,
123 | linkColorMapper = linkColorMapper
124 | )
125 |
126 | spans.forEach { span ->
127 | when (span) {
128 | is ImageSpan ->
129 | inlineContentMapper(span.asInlineContent(range))
130 | is URLSpan -> {
131 | style.urlSpan = span
132 | urlSpanMapper(span)
133 | }
134 | is AndroidParagraphStyle -> {
135 | if (supportsParagraphContent &&
136 | span.isSupportedParagraphStyle()
137 | ) {
138 | val content = toParagraphContent(span, range)
139 | if (paragraphContentMapper(content).not()) {
140 | style.paragraphContents.add(content)
141 | }
142 | }
143 | }
144 | }
145 |
146 | val mapper = spanMapper[span]
147 | mapper?.invoke(style, span)
148 | }
149 |
150 | return style
151 | }
152 |
153 | /**
154 | * Maps each span to its corresponding text range and sorts the mappings based on the starting
155 | * range. This function facilitates organizing spans applied to text by their respective
156 | * ranges, allowing for efficient processing and manipulation of text styling. By mapping spans to
157 | * their ranges and merging multiple spans within the same range into a single [SpanStyle], we can
158 | * simplify the text styling logic and improve efficiency. Creating only one [SpanStyle] for
159 | * multiple spans in the same range reduces redundancy and ensures consistent styling for overlapping
160 | * text spans.
161 | */
162 | private fun Spanned.mapSpans(): Map> {
163 | val spansMap = mutableMapOf>()
164 |
165 | getSpans(0, length, Any::class.java).forEach { span ->
166 | val range = IntRange(getSpanStart(span), getSpanEnd(span))
167 | spansMap.getOrPut(range) {
168 | mutableListOf()
169 | }.add(span)
170 | }
171 |
172 | return spansMap.toSortedMap { o1, o2 ->
173 | if (o1.first == o2.first) {
174 | o1.last.compareTo(o2.last)
175 | } else {
176 | o1.first.compareTo(o2.first)
177 | }
178 | }
179 | }
180 |
181 | /**
182 | * Optimizes the list by returning a new list with the same elements if the size of the original list
183 | * is less than or equal to 1. Otherwise, it returns the original list itself. This optimization helps
184 | * reduce memory consumption by avoiding the need for a mutable list when there are 0 or 1 elements,
185 | * which are immutable by nature. By returning an immutable list in such cases, unnecessary memory
186 | * overhead associated with maintaining mutability is avoided.
187 | */
188 | private fun MutableList.optimize(): List {
189 | return if (size <= 1) {
190 | toList()
191 | } else {
192 | this
193 | }
194 | }
195 |
196 | /**
197 | * A data class to hold paragraph styles along with their respective ranges within a text.
198 | */
199 | private data class ParagraphStyleHolder(
200 | val style: MutableSpanStyle,
201 | var range: IntRange
202 | )
203 |
204 | /**
205 | * Safely adds a new paragraph style holder to the list, merging it with any existing holder
206 | * if there is an overlap between their ranges. Overlapping styles can lead to inconsistencies
207 | * and unexpected rendering behavior. By merging overlapping styles, we ensure that each range
208 | * of text has a unique set of paragraph styles applied, preventing redundancy and conflicts
209 | *
210 | * @param newStyle The new paragraph style holder to add or merge.
211 | */
212 | private fun MutableList.safeAdd(newStyle: ParagraphStyleHolder) {
213 | for (old in this) {
214 | if (old.range.overlap(newStyle.range)) {
215 | old.style.paragraphContents.addAll(newStyle.style.paragraphContents)
216 | old.range = IntRange(
217 | min(old.range.first, newStyle.range.first),
218 | max(old.range.last, newStyle.range.last),
219 | )
220 | return
221 | }
222 | }
223 | add(newStyle)
224 | }
225 |
226 | private fun IntRange.overlap(other: IntRange): Boolean {
227 | val max = maxOf(first, last)
228 | val min = minOf(first, last)
229 | return (other.first in min.. Color?)? = null
67 | ) {
68 |
69 | val paragraphContents = mutableListOf()
70 | internal var urlSpan: URLSpan? = null
71 |
72 | fun toSpanStyle(): SpanStyle {
73 | return SpanStyle(
74 | color = if (urlSpan != null && color.isUnspecified) {
75 | val mappedColor = linkColorMapper?.invoke(requireNotNull(urlSpan))
76 | if (mappedColor != null && mappedColor.isSpecified) {
77 | mappedColor
78 | } else {
79 | linkColor
80 | }
81 | } else {
82 | color
83 | },
84 | fontSize = fontSize,
85 | fontWeight = fontWeight,
86 | fontStyle = fontStyle,
87 | fontSynthesis = fontSynthesis,
88 | fontFamily = fontFamily,
89 | fontFeatureSettings = fontFeatureSettings,
90 | letterSpacing = letterSpacing,
91 | baselineShift = baselineShift,
92 | background = background,
93 | textDecoration = textDecoration,
94 | localeList = localeList,
95 | shadow = shadow,
96 | textGeometricTransform = textGeometricTransform
97 | ).merge(appearance)
98 | }
99 |
100 | fun toParagraphStyle(): ParagraphStyle? {
101 | if (hasParagraphStyle().not()) {
102 | return null
103 | }
104 |
105 | var first = 0
106 | var rest = 0
107 | var lineHeight: Int? = null
108 | var alignment: TextAlign? = null
109 | paragraphContents.forEach { paragraph ->
110 | first = paragraph.firstLeadingMargin ?: first
111 | rest = paragraph.restLeadingMargin ?: rest
112 | lineHeight = paragraph.lineHeight ?: lineHeight
113 | alignment = paragraph.alignment ?: alignment
114 | }
115 |
116 | val indent = if (first != 0 || rest != 0) {
117 | TextIndent(firstLine = first.pxToSp(), restLine = rest.pxToSp())
118 | } else null
119 |
120 | return ParagraphStyle(
121 | textAlign = alignment ?: TextAlign.Unspecified,
122 | textIndent = indent,
123 | lineHeight = lineHeight?.pxToSp() ?: TextUnit.Unspecified
124 | )
125 | }
126 |
127 | fun hasParagraphStyle() = paragraphContents.isNotEmpty()
128 | }
129 |
130 | internal typealias SpanMapperMap = Map, SpanMapper<*>?>
131 | internal typealias SpanMapper = MutableSpanStyle.(span: T) -> Unit
132 | internal typealias PairSpanMapper = Pair, SpanMapper>
133 |
134 | /**
135 | * @return a map of all default span mapper functions.
136 | */
137 | internal fun getDefaultSpanMappers(): MutableMap, SpanMapper<*>?> {
138 | return mutableMapOf(
139 | absoluteSize(),
140 | relativeSize(),
141 | backgroundColor(),
142 | foregroundColor(),
143 | strikethrough(),
144 | underline(),
145 | style(),
146 | subscript(),
147 | superscript(),
148 | scaleX(),
149 | skewX(),
150 | locales(),
151 | textAppearance(),
152 | typeface(),
153 | urlStyle()
154 | )
155 | }
156 |
157 | private fun absoluteSize(): PairSpanMapper {
158 | return AbsoluteSizeSpan::class to {
159 | val sizeInPx = if (it.dip) {
160 | it.size.dpToPx()
161 | } else {
162 | it.size.toFloat()
163 | }
164 | fontSize = sizeInPx.pxToSp()
165 | }
166 | }
167 |
168 | private fun relativeSize(): PairSpanMapper {
169 | return RelativeSizeSpan::class to {
170 | fontSize = it.sizeChange.em
171 | }
172 | }
173 |
174 | private fun backgroundColor(): PairSpanMapper {
175 | return BackgroundColorSpan::class to {
176 | background = Color(it.backgroundColor)
177 | }
178 | }
179 |
180 | private fun foregroundColor(): PairSpanMapper {
181 | return ForegroundColorSpan::class to {
182 | color = Color(it.foregroundColor)
183 | }
184 | }
185 |
186 | private fun strikethrough(): PairSpanMapper {
187 | return StrikethroughSpan::class to {
188 | textDecoration += TextDecoration.LineThrough
189 | }
190 | }
191 |
192 | private fun underline(): PairSpanMapper {
193 | return UnderlineSpan::class to {
194 | textDecoration += TextDecoration.Underline
195 | }
196 | }
197 |
198 | private fun style(): PairSpanMapper {
199 | return StyleSpan::class to {
200 | when (it.style) {
201 | Typeface.ITALIC,
202 | Typeface.BOLD_ITALIC -> fontStyle = FontStyle.Italic
203 | Typeface.NORMAL -> fontStyle = FontStyle.Normal
204 | }
205 | fontWeight = fontWeight.adjust(it)
206 | }
207 | }
208 |
209 | private fun subscript(): PairSpanMapper {
210 | return SubscriptSpan::class to {
211 | baselineShift = BaselineShift.Subscript
212 | }
213 | }
214 |
215 | private fun superscript(): PairSpanMapper {
216 | return SuperscriptSpan::class to {
217 | baselineShift = BaselineShift.Superscript
218 | }
219 | }
220 |
221 | private fun scaleX(): PairSpanMapper {
222 | return ScaleXSpan::class to {
223 | textGeometricTransform = if (textGeometricTransform != null) {
224 | textGeometricTransform?.copy(
225 | scaleX = textGeometricTransform!!.scaleX * it.scaleX
226 | )
227 | } else {
228 | TextGeometricTransform(scaleX = it.scaleX)
229 | }
230 | }
231 | }
232 |
233 | private fun skewX(): PairSpanMapper {
234 | return SkewXSpan::class to {
235 | textGeometricTransform = if (textGeometricTransform != null) {
236 | textGeometricTransform?.copy(
237 | skewX = textGeometricTransform!!.skewX + it.skewX
238 | )
239 | } else {
240 | TextGeometricTransform(skewX = it.skewX)
241 | }
242 | }
243 | }
244 |
245 | private fun locales(): PairSpanMapper {
246 | return LocaleSpan::class to {
247 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
248 | localeList = LocaleList(it.locales.toLanguageTags())
249 | } else if (it.locale != null) {
250 | localeList = LocaleList(Locale(it.locale!!.toLanguageTag()))
251 | }
252 | }
253 | }
254 |
255 | private fun textAppearance(): PairSpanMapper {
256 | return TextAppearanceSpan::class to {
257 | appearance = if (appearance != null) {
258 | requireNotNull(appearance)
259 | .merge(it.toSpanStyle())
260 | } else {
261 | it.toSpanStyle()
262 | }
263 | if (it.linkTextColor != null) {
264 | linkColor = Color(it.linkTextColor.defaultColor)
265 | }
266 | }
267 | }
268 |
269 | private fun typeface(): PairSpanMapper {
270 | return TypefaceSpan::class to {
271 | fontFamily = it.asFontFamily()
272 | }
273 | }
274 |
275 | private fun urlStyle(): PairSpanMapper {
276 | return URLSpan::class to {
277 | textDecoration += TextDecoration.Underline
278 | }
279 | }
280 |
281 | private operator fun TextDecoration?.plus(decoration: TextDecoration): TextDecoration {
282 | return (this ?: TextDecoration.None) + decoration
283 | }
284 |
285 | @Suppress("UNCHECKED_CAST")
286 | internal operator fun SpanMapperMap.get(
287 | span: T
288 | ): SpanMapper? {
289 | if (containsKey(span::class)) {
290 | return get(span::class) as? SpanMapper
291 | }
292 | return firstOrNull {
293 | it.value != null && it.key.isInstance(span)
294 | } as? SpanMapper
295 | }
296 |
297 | private inline fun Map.firstOrNull(
298 | predicate: (Map.Entry) -> Boolean
299 | ): V? {
300 | for (element in this) {
301 | if (predicate(element)) {
302 | return element.value
303 | }
304 | }
305 | return null
306 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2024 AmirHossein Aghajari
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/SpannedToAnnotatedString/src/main/java/com/aghajari/compose/text/AnnotatedText.kt:
--------------------------------------------------------------------------------
1 | package com.aghajari.compose.text
2 |
3 | import androidx.compose.foundation.gestures.detectTapGestures
4 | import androidx.compose.foundation.text.BasicText
5 | import androidx.compose.foundation.text.InlineTextContent
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.runtime.MutableState
8 | import androidx.compose.runtime.mutableStateOf
9 | import androidx.compose.runtime.remember
10 | import androidx.compose.ui.Modifier
11 | import androidx.compose.ui.composed
12 | import androidx.compose.ui.draw.drawBehind
13 | import androidx.compose.ui.graphics.Color
14 | import androidx.compose.ui.graphics.takeOrElse
15 | import androidx.compose.ui.input.pointer.pointerInput
16 | import androidx.compose.ui.platform.LocalContext
17 | import androidx.compose.ui.text.Paragraph
18 | import androidx.compose.ui.text.TextLayoutResult
19 | import androidx.compose.ui.text.TextStyle
20 | import androidx.compose.ui.text.font.FontFamily
21 | import androidx.compose.ui.text.font.FontStyle
22 | import androidx.compose.ui.text.font.FontWeight
23 | import androidx.compose.ui.text.style.ResolvedTextDirection
24 | import androidx.compose.ui.text.style.TextAlign
25 | import androidx.compose.ui.text.style.TextDecoration
26 | import androidx.compose.ui.text.style.TextOverflow
27 | import androidx.compose.ui.unit.TextUnit
28 |
29 | /**
30 | * High level element that displays text and provides semantics / accessibility information.
31 | *
32 | * @param text the [ContentAnnotatedString] text to be displayed
33 | * @param modifier the [Modifier] to be applied to this layout node
34 | * @param color [Color] to apply to the text.
35 | * @param fontSize the size of glyphs to use when painting the text. See [TextStyle.fontSize].
36 | * @param fontStyle the typeface variant to use when drawing the letters (e.g., italic).
37 | * See [TextStyle.fontStyle].
38 | * @param fontWeight the typeface thickness to use when painting the text (e.g., [FontWeight.Bold]).
39 | * @param fontFamily the font family to be used when rendering the text. See [TextStyle.fontFamily].
40 | * @param letterSpacing the amount of space to add between each letter.
41 | * See [TextStyle.letterSpacing].
42 | * @param textDecoration the decorations to paint on the text (e.g., an underline).
43 | * See [TextStyle.textDecoration].
44 | * @param textAlign the alignment of the text within the lines of the paragraph.
45 | * See [TextStyle.textAlign].
46 | * @param lineHeight line height for the [Paragraph] in [TextUnit] unit, e.g. SP or EM.
47 | * See [TextStyle.lineHeight].
48 | * @param overflow how visual overflow should be handled.
49 | * @param softWrap whether the text should break at soft line breaks. If false, the glyphs in the
50 | * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
51 | * [overflow] and TextAlign may have unexpected effects.
52 | * @param maxLines an optional maximum number of lines for the text to span, wrapping if
53 | * necessary. If the text exceeds the given number of lines, it will be truncated according to
54 | * [overflow] and [softWrap]. If it is not null, then it must be greater than zero.
55 | * @param minLines The minimum height in terms of minimum number of visible lines. It is required
56 | * that 1 <= [minLines] <= [maxLines].
57 | * @param inlineContent a map storing composables that replaces certain ranges of the text, used to
58 | * insert composables into text layout. See [InlineTextContent].
59 | * @param onTextLayout callback that is executed when a new text layout is calculated. A
60 | * [TextLayoutResult] object that callback provides contains paragraph information, size of the
61 | * text, baselines and other details. The callback can be used to add additional decoration or
62 | * functionality to the text. For example, to draw selection around the text.
63 | * @param onURLClick callback that is executed when users click
64 | * the url text. The default implementation
65 | * Will try to open the url, by launching an an Activity with an
66 | * [android.content.Intent.ACTION_VIEW] intent. Pass Null to use
67 | * the default implementation.
68 | * @param style style configuration for the text such as color, font, line height etc.
69 | */
70 | @Composable
71 | fun AnnotatedText(
72 | text: ContentAnnotatedString,
73 | modifier: Modifier = Modifier,
74 | color: Color = Color.Unspecified,
75 | fontSize: TextUnit = TextUnit.Unspecified,
76 | fontStyle: FontStyle? = null,
77 | fontWeight: FontWeight? = null,
78 | fontFamily: FontFamily? = null,
79 | letterSpacing: TextUnit = TextUnit.Unspecified,
80 | textDecoration: TextDecoration? = null,
81 | textAlign: TextAlign = TextAlign.Unspecified,
82 | lineHeight: TextUnit = TextUnit.Unspecified,
83 | overflow: TextOverflow = TextOverflow.Clip,
84 | softWrap: Boolean = true,
85 | maxLines: Int = Int.MAX_VALUE,
86 | minLines: Int = 1,
87 | inlineContent: Map =
88 | text.getInlineContentMap(),
89 | onTextLayout: (TextLayoutResult) -> Unit = {},
90 | onURLClick: ((String) -> Unit)? = null,
91 | style: TextStyle = TextStyle.Default
92 | ) {
93 | val textColor = color.takeOrElse {
94 | style.color.takeOrElse {
95 | Color.Black
96 | }
97 | }
98 |
99 | val mergedStyle = style.merge(
100 | TextStyle(
101 | color = textColor,
102 | fontSize = fontSize,
103 | fontWeight = fontWeight,
104 | textAlign = textAlign,
105 | lineHeight = lineHeight,
106 | fontFamily = fontFamily,
107 | textDecoration = textDecoration,
108 | fontStyle = fontStyle,
109 | letterSpacing = letterSpacing
110 | )
111 | )
112 |
113 | BasicAnnotatedText(
114 | text,
115 | modifier,
116 | mergedStyle,
117 | onTextLayout,
118 | overflow,
119 | softWrap,
120 | maxLines,
121 | minLines,
122 | inlineContent,
123 | onURLClick
124 | )
125 | }
126 |
127 | /**
128 | * Basic element that displays text and provides semantics / accessibility information.
129 | *
130 | * @param text The [ContentAnnotatedString] text to be displayed.
131 | * @param modifier [Modifier] to apply to this layout node.
132 | * @param style Style configuration for the text such as color, font, line height etc.
133 | * @param onTextLayout Callback that is executed when a new text layout is calculated. A
134 | * [TextLayoutResult] object that callback provides contains paragraph information, size of the
135 | * text, baselines and other details. The callback can be used to add additional decoration or
136 | * functionality to the text. For example, to draw selection around the text.
137 | * @param overflow How visual overflow should be handled.
138 | * @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
139 | * text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
140 | * [overflow] and TextAlign may have unexpected effects.
141 | * @param maxLines An optional maximum number of lines for the text to span, wrapping if
142 | * necessary. If the text exceeds the given number of lines, it will be truncated according to
143 | * [overflow] and [softWrap]. It is required that 1 <= [minLines] <= [maxLines].
144 | * @param minLines The minimum height in terms of minimum number of visible lines. It is required
145 | * that 1 <= [minLines] <= [maxLines].
146 | * @param inlineContent A map store composables that replaces certain ranges of the text. It's
147 | * used to insert composables into text layout. Check [InlineTextContent] for more information.
148 | * @param onURLClick callback that is executed when users click
149 | * the url text. The default implementation
150 | * Will try to open the url, by launching an an Activity with an
151 | * [android.content.Intent.ACTION_VIEW] intent. Pass Null to use
152 | * the default implementation.
153 | */
154 | @Composable
155 | fun BasicAnnotatedText(
156 | text: ContentAnnotatedString,
157 | modifier: Modifier = Modifier,
158 | style: TextStyle = TextStyle.Default,
159 | onTextLayout: (TextLayoutResult) -> Unit = {},
160 | overflow: TextOverflow = TextOverflow.Clip,
161 | softWrap: Boolean = true,
162 | maxLines: Int = Int.MAX_VALUE,
163 | minLines: Int = 1,
164 | inlineContent: Map =
165 | text.getInlineContentMap(),
166 | onURLClick: ((String) -> Unit)? = null
167 | ) {
168 | if (text.hasUrl or text.paragraphContents.isNotEmpty()) {
169 | ClickableAnnotatedText(
170 | text = text,
171 | modifier = modifier,
172 | style = style,
173 | softWrap = softWrap,
174 | overflow = overflow,
175 | maxLines = maxLines,
176 | minLines = minLines,
177 | inlineContent = inlineContent,
178 | onTextLayout = onTextLayout,
179 | onURLClick = onURLClick
180 | )
181 | } else {
182 | BasicText(
183 | text = text.annotatedString,
184 | modifier = modifier,
185 | style = style,
186 | softWrap = softWrap,
187 | overflow = overflow,
188 | maxLines = maxLines,
189 | minLines = minLines,
190 | inlineContent = inlineContent,
191 | onTextLayout = onTextLayout
192 | )
193 | }
194 | }
195 |
196 | @Composable
197 | private fun ClickableAnnotatedText(
198 | text: ContentAnnotatedString,
199 | modifier: Modifier,
200 | style: TextStyle,
201 | softWrap: Boolean,
202 | overflow: TextOverflow,
203 | maxLines: Int,
204 | minLines: Int,
205 | inlineContent: Map,
206 | onTextLayout: (TextLayoutResult) -> Unit,
207 | onURLClick: ((String) -> Unit)?
208 | ) {
209 | val layoutResult = remember {
210 | mutableStateOf(null)
211 | }
212 | var textModifier = modifier
213 |
214 | if (text.hasUrl) {
215 | textModifier = textModifier.annotatedTextClickable(
216 | text,
217 | layoutResult,
218 | onURLClick
219 | )
220 | }
221 |
222 | if (text.paragraphContents.isNotEmpty()) {
223 | textModifier = textModifier.annotatedTextParagraphContents(
224 | text,
225 | layoutResult
226 | )
227 | }
228 |
229 | BasicText(
230 | text = text.annotatedString,
231 | modifier = textModifier,
232 | style = style,
233 | softWrap = softWrap,
234 | overflow = overflow,
235 | maxLines = maxLines,
236 | minLines = minLines,
237 | inlineContent = inlineContent,
238 | onTextLayout = {
239 | layoutResult.value = it
240 | onTextLayout(it)
241 | }
242 | )
243 | }
244 |
245 | /**
246 | * Handles click event on the URLS
247 | * in the specified [ContentAnnotatedString].
248 | *
249 | * @param text the specified [ContentAnnotatedString]
250 | * @param layoutResult the text layout result received in onTextLayout
251 | * @param onURLClick callback that is executed when users click
252 | * the url text. The default implementation
253 | * Will try to open the url, by launching an an Activity with an
254 | * [android.content.Intent.ACTION_VIEW] intent. Pass Null to use
255 | * the default implementation.
256 | */
257 | fun Modifier.annotatedTextClickable(
258 | text: ContentAnnotatedString,
259 | layoutResult: MutableState,
260 | onURLClick: ((String) -> Unit)?,
261 | ): Modifier = composed {
262 | val onClick = text.toURLClickable(
263 | onURLClick = onURLClick ?: defaultOnURLClick(LocalContext.current)
264 | )
265 | this.pointerInput(onClick) {
266 | detectTapGestures { pos ->
267 | layoutResult.value?.let { layoutResult ->
268 | onClick(layoutResult.getOffsetForPosition(pos))
269 | }
270 | }
271 | }
272 | }
273 |
274 | /**
275 | * Renders the leading margins for paragraphs that have been styled
276 | * in the specified [ContentAnnotatedString].
277 | *
278 | * @param text the specified [ContentAnnotatedString]
279 | * @param layoutResult the text layout result received in onTextLayout
280 | */
281 | fun Modifier.annotatedTextParagraphContents(
282 | text: ContentAnnotatedString,
283 | layoutResult: MutableState
284 | ): Modifier {
285 | return drawBehind {
286 | layoutResult.value?.let { layoutResult ->
287 | text.paragraphContents.forEach { content ->
288 | val startLine = layoutResult.getLineForOffsetInBounds(
289 | offset = content.start,
290 | checkIfDelimited = true
291 | )
292 | if (startLine < 0) {
293 | // Paragraph delimited by maxLines,
294 | // ignore the content of the next paragraphs
295 | return@drawBehind
296 | }
297 |
298 | if (content.drawer != null && (content.isDrawerOnly() ||
299 | layoutResult.getLineStart(startLine) == content.start)
300 | ) {
301 | val firstEndLine = layoutResult.getLineForOffsetInBounds(
302 | offset = content.end - 1
303 | )
304 | val endOffset = layoutResult.getLineEnd(firstEndLine)
305 | val endLine = if (endOffset == content.end) {
306 | val nextEndLine = layoutResult.getLineForOffsetInBounds(
307 | offset = content.end
308 | )
309 | if (nextEndLine - firstEndLine > 1) {
310 | nextEndLine - 1
311 | } else {
312 | firstEndLine
313 | }
314 | } else {
315 | firstEndLine
316 | }
317 |
318 | val dir = try {
319 | layoutResult.getParagraphDirection(content.start)
320 | } catch (ignore: Exception) {
321 | return@drawBehind
322 | }
323 | content.drawer.onDraw(
324 | this@drawBehind,
325 | ParagraphLayoutInfo(
326 | result = layoutResult,
327 | startLine = startLine,
328 | endLine = endLine,
329 | x = if (dir == ResolvedTextDirection.Ltr) {
330 | layoutResult.getLineLeft(startLine)
331 | } else {
332 | layoutResult.getLineRight(startLine)
333 | },
334 | top = layoutResult.getLineTop(startLine),
335 | bottom = layoutResult.getLineBottom(endLine),
336 | direction = dir
337 | )
338 | )
339 | }
340 | }
341 | }
342 | }
343 | }
--------------------------------------------------------------------------------