Blog Infos
Author
Published
Topics
, , , ,
Author
Published

 

In this article, we will learn how to migrate ClickableText to a LinkAnnotation-based solution for text that contains clickable text [URL, custom-actions, etc.]

I was working on a ticket to fix the issue where the URL link opens in an external browser instead of within the app (Chrome Custom Tab Intent or Custom WebView).

When I opened the relevant code, it looked like this 👇

@Composable
fun ClickableTextEx() {
    val uriHandler = LocalUriHandler.current
    val linkLabel = "Link label...."
    val link = "https://......"

    val annotatedDescription = buildAnnotatedString {
        withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.onBackground)) {
            append("Please read the ... here")
        }
        pushStringAnnotation(tag = "Policy", annotation = link)
        withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
            append(linkLabel)
        }
        pop()
    }

    ClickableText(
        text = annotatedDescription,
        onClick = { offset ->
            annotatedDescription.getStringAnnotations(
                tag = "Policy",
                start = offset,
                end = offset
            ).firstOrNull()?.let { stringAnnotation ->
                uriHandler.openUri(stringAnnotation.item)
            }
        }
    )
}
  • You might have already found the issue 🐛 why the link is opening in an external browser 🕸️

 

uriHandler.openUri(stringAnnotation.item)

 

  • It’s because we have used the Compose’s UriHandler, which fires the following intent:
  • AndroidUriHandler’s code can be found here

 

class AndroidUriHandler(private val context: Context) : UriHandler {

    /**
     * Open given URL in browser
     *
     * @throws IllegalArgumentException when given [uri] is invalid and/or can't be handled by the
     *   system
     */
    override fun openUri(uri: String) {
        try {
            context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
        } catch (e: ActivityNotFoundException) {
            throw IllegalArgumentException("Can't open $uri.", e)
        }
    }
}

 

Deprecated API

Deprecated code warning — Android Studio

  • As you can see in the above image👆, C̶l̶i̶c̶k̶a̶b̶l̶e̶T̶e̶x̶t̶ is marked deprecated 🛑, So when I checked the docs 👇 it suggests that we should use Text or BasicText with AnnotatedString + LinkAnnotation
So at this point, we have two options
  1. Fix the Uri handling code ✅ and leave the deprecated API as it is.
    OR
  2. Fix the code and replace the deprecated API with the recommended one ✅ ⭐️
  • I picked the 2nd, so let’s see how we can migrate this code to the new API LinkAnnotationandhandle the URI in a way that checks if Chrome custom tabs are supported, then opens the URI in one; otherwise, fire the intent with the necessary action and data.
LinkAnnotation class
  • It’s an abstract class with two abstract properties
  1. styles
  2. linkInteractionListener
By default, we have two implementations available for us
  • Url: It has three properties: 2 from LinkedAnnotation + 1 its own
  • Clickable: It has three properties: 2 from LinkedAnnotation + 1 its own

As per our use case, I decided to use the Url class because in our case, we have Text, which includes the HTTP URLs.

@Composable
fun LinkAnnotationExample() {
    Text(buildAnnotatedString {
        append("Some other text ")
        withLink( 
            LinkAnnotation.Url(
                // Added the sample Url here.
                url = "https://developer.android.com/jetpack/compose",
                styles = TextLinkStyles(
                    style = SpanStyle(color = Color.Blue),
                    hoveredStyle = SpanStyle(
                        color = Color.Red,
                        textDecoration = TextDecoration.Underline
                    ),
                    focusedStyle = SpanStyle(
                        color = Color.Red,
                        textDecoration = TextDecoration.LineThrough
                    ),
                    pressedStyle = SpanStyle(
                        color = Color.Green,
                        textDecoration = TextDecoration.LineThrough
                    ),
                )
            )
        ) {
            append("Jetpack Compose")
        }
    })
}
  • As you can see 👆, it’s a better approach than ClickableText. Here, we don’t need to worry about the offset, get annotations, and then get the Uri, and proceed from there.
  • We used the withLink extension function to add the link to AnnotatedString

ℹ️ If we don’t pass the LinkInteractionListener to the Url class then internally Text uses the UriHandler to open the link, which under the hood fires the intent for us.

  • Here is the code for the handleLink function from TextLinkScope class

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/TextLinkScope.kt;l=285?q=TextLinkScope

private fun handleLink(link: LinkAnnotation, uriHandler: UriHandler) {
      when (link) {
          is LinkAnnotation.Url ->
              link.linkInteractionListener?.onClick(link)
                  ?: try {
                      uriHandler.openUri(link.url)
                  } catch (_: IllegalArgumentException) {
                     // ..
                  }
          is LinkAnnotation.Clickable -> // ...
      }
  }

So if you want to handle the URL opening within your application or want to listen to the link interactions, for example, for analytics purposes, then you can provide your own linkInteractionListener

@Composable
fun LinkAnnotationExample() {
    Text(buildAnnotatedString {
        append("Build better apps faster with ")
        withLink(
            LinkAnnotation.Url(
                url = "https://developer.android.com/jetpack/compose"
            ){ linkAnnotation ->
                val url = (linkAnnotation as LinkAnnotation.Url).url
                Log.d("TAG", "LinkAnnotationExample: $url")
                
            }
        ) {
            append("Jetpack Compose")
        }
    })
}
Chrome custom tab support implementation
  • Add a query tag to the manifest file

 

<queries>
    <intent>
        <action android:name=
            "android.support.customtabs.action.CustomTabsService" />
    </intent>
</queries>

 

  • Add code to check if custom tabs are supported or not

 

private const val ACTION_CUSTOM_TABS_CONNECTION =
    "android.support.customtabs.action.CustomTabsService"

private const val CHROME_PACKAGE = "com.android.chrome"


fun isChromeCustomTabsSupported(context: Context): Boolean {
    val serviceIntent = Intent(ACTION_CUSTOM_TABS_CONNECTION).apply {
        setPackage(CHROME_PACKAGE)
    }
    val resolveInfo: MutableList<ResolveInfo?>? =
        context.packageManager.queryIntentServices(serviceIntent, 0)
    return resolveInfo?.filterNotNull()?.isNotEmpty() == true
}

 

  • Add an extension function on Uri to open in a custom tab or a browser

 

fun Uri.openInBrowser(context: Context) {
    
    try {
        when (isChromeCustomTabsSupported(context)) {
            true -> {
                val builder: CustomTabsIntent.Builder = CustomTabsIntent.Builder()
                val customTabsIntent: CustomTabsIntent = builder.build()
                customTabsIntent.launchUrl(context, this)
            }

            false -> {
                val browserIntent = Intent(Intent.ACTION_VIEW, this).apply {
                    putExtra(Browser.EXTRA_APPLICATION_ID, context.packageName)
                }
                context.startActivity(browserIntent)
            }
        }
    } catch (activityNotFoundException: ActivityNotFoundException) {
        Log.e("ChromeCustomTab", activityNotFoundException.message.toString())
    } catch (exception: Exception) {
        Log.e("ChromeCustomTab", exception.message.toString())
    }
}

 

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

Menu