
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
- 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
- Fix the Uri handling code ✅ and leave the deprecated API as it is.
OR - 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
- styles
- 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
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()) } }