9

I can't find how to linkify my Text() using Jetpack Compose.

Before compose all I had to do was:

Linkify.addLinks(myTextView, Linkify.EMAIL_ADDRESSES or Linkify.WEB_URLS)

And all the links contained in my TextView were becoming clickable links, obviously.

Important: The content of the Text is coming from an API and the links do not have a fixed position and content may contain multiple links.

I want to keep this behavior with using Jetpack Compose but I can't find any information about doing that.

Does anyone know?

agonist_
  • 4,890
  • 6
  • 32
  • 55
  • I opened a feature request on the Jetpack Compose issue tracker if you'd like to see Compose support this directly - https://issuetracker.google.com/issues/205183601. – Sean Barbeau Nov 05 '21 at 19:00

8 Answers8

11

In case someone is looking for a solution, the following will make any links clickable and styled in your text:

@Composable
fun LinkifyText(text: String, modifier: Modifier = Modifier) {
    val uriHandler = LocalUriHandler.current
    val layoutResult = remember {
        mutableStateOf<TextLayoutResult?>(null)
    }
    val linksList = extractUrls(text)
    val annotatedString = buildAnnotatedString {
        append(text)
        linksList.forEach {
            addStyle(
                style = SpanStyle(
                    color = Color.Companion.Blue,
                    textDecoration = TextDecoration.Underline
                ),
                start = it.start,
                end = it.end
            )
            addStringAnnotation(
                tag = "URL",
                annotation = it.url,
                start = it.start,
                end = it.end
            )
        }
    }
    Text(
        text = annotatedString, 
        style = MaterialTheme.typography.body1,
        onTextLayout = { layoutResult.value = it },
        modifier = modifier
            .pointerInput(Unit) {
                detectTapGestures { offsetPosition ->
                    layoutResult.value?.let {
                        val position = it.getOffsetForPosition(offsetPosition)
                        annotatedString.getStringAnnotations(position, position).firstOrNull()
                            ?.let { result ->
                                if (result.tag == "URL") {
                                    uriHandler.openUri(result.item)
                                }
                            }
                    }
                }
            }
    )
}

private val urlPattern: Pattern = Pattern.compile(
        "(?:^|[\\W])((ht|f)tp(s?):\\/\\/|www\\.)"
                + "(([\\w\\-]+\\.){1,}?([\\w\\-.~]+\\/?)*"
                + "[\\p{Alnum}.,%_=?&#\\-+()\\[\\]\\*$~@!:/{};']*)",
        Pattern.CASE_INSENSITIVE or Pattern.MULTILINE or Pattern.DOTALL
)

fun extractUrls(text: String): List<LinkInfos> {
    val matcher = urlPattern.matcher(text)
    var matchStart: Int
    var matchEnd: Int
    val links = arrayListOf<LinkInfos>()

    while (matcher.find()) {
        matchStart = matcher.start(1)
        matchEnd = matcher.end()

        var url = text.substring(matchStart, matchEnd)
        if (!url.startsWith("http://") && !url.startsWith("https://"))
            url = "https://$url"

        links.add(LinkInfos(url, matchStart, matchEnd))
    }
    return links
}

data class LinkInfos(
        val url: String,
        val start: Int,
        val end: Int
)
Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
agonist_
  • 4,890
  • 6
  • 32
  • 55
7

You can still use Linkify.addLinks but convert the result into AnnotatedString like this:

fun String.linkify(
    linkStyle: SpanStyle,
) = buildAnnotatedString {
    append(this@linkify)

    val spannable = SpannableString(this@linkify)
    Linkify.addLinks(spannable, Linkify.WEB_URLS)

    val spans = spannable.getSpans(0, spannable.length, URLSpan::class.java)
    for (span in spans) {
        val start = spannable.getSpanStart(span)
        val end = spannable.getSpanEnd(span)

        addStyle(
            start = start,
            end = end,
            style = linkStyle,
        )
        addStringAnnotation(
            tag = "URL",
            annotation = span.url,
            start = start,
            end = end
        )
    }
}

fun AnnotatedString.urlAt(position: Int, onFound: (String) -> Unit) =
    getStringAnnotations("URL", position, position).firstOrNull()?.item?.let {
        onFound(it)
    }

Use it in your composable like this:

val linkStyle = SpanStyle(
    color = MaterialTheme.colors.primary,
    textDecoration = TextDecoration.Underline,
)
ClickableText(
    text = remember(text) { text.linkify(linkStyle) },
    onClick = { position -> text.urlAt(position, onClickLink) },
)
Saša Tarbuk
  • 509
  • 7
  • 8
  • Nice solution. +1 for wrapping the result in `remember(text)`, so it doesn't get executed too often. Note: if you want to Linkify a `SpannableString` with styles already in it, you'll have to copy those over manually by reading them and calling `addStyle()` as is done with the link style above. – Jacob Ras May 10 '23 at 10:30
6

I think the better solution for now is create your own component with textview like that:

@Composable
fun DefaultLinkifyText(modifier: Modifier = Modifier, text: String?) {
    val context = LocalContext.current
    val customLinkifyTextView = remember {
       TextView(context)
    }
    AndroidView(modifier = modifier, factory = { customLinkifyTextView }) { textView ->
        textView.text = text ?: ""
        LinkifyCompat.addLinks(textView, Linkify.ALL)
        Linkify.addLinks(textView, Patterns.PHONE,"tel:",
            Linkify.sPhoneNumberMatchFilter, Linkify.sPhoneNumberTransformFilter)
        textView.movementMethod = LinkMovementMethod.getInstance()
    }
}
Jose Pose S
  • 1,175
  • 17
  • 33
2

You can use AnnotatedString to achieve this behavior.

docs: https://developer.android.com/reference/kotlin/androidx/compose/ui/text/AnnotatedString

Also, this one may help you:

AutoLink for Android Compose Text

Amirhosein
  • 1,048
  • 7
  • 19
2

Based on above answers, You can use https://github.com/firefinchdev/linkify-text

Its a single file, you can directly copy it to your project.

Also, it uses Android's Linkify for link detection, which is same as that of TextView's autoLink.

Anirudh Gupta
  • 573
  • 5
  • 12
0

A similar, but simpler solution that I went with to get the proper Material Design look and feel:

@Composable
fun BodyWithLinks(body: String, modifier: Modifier = Modifier) {
  AndroidView(
    modifier = modifier,
    factory = { context ->
      (MaterialTextView(context) as AppCompatTextView).apply {
        val spannableString = SpannableString(body)
        Linkify.addLinks(spannableString, Linkify.ALL)
        text = spannableString
        setTextAppearance(R.style.Theme_Body_1)
      }
    },
  )
}
Martin K
  • 782
  • 7
  • 13
0

This is an example if you have multiple clickable words in one sentence and you want to navigate inside the application:

    @Composable
    fun InformativeSignUpText() {
        val informativeText = stringResource(R.string.sign_up_already_have_an_account)
        val logInSubstring = stringResource(R.string.general_log_in)
        val supportSubstring = stringResource(R.string.general_support)
        val logInIndex = informativeText.indexOf(logInSubstring)
        val supportIndex = informativeText.indexOf(supportSubstring)
    
        val informativeAnnotatedText = buildAnnotatedString {
            append(informativeText)
            addStyle(
                style = SpanStyle(
                    color = MaterialTheme.colors.primary
                ),
                start = logInIndex,
                end = logInIndex + logInSubstring.length
            )
            addStringAnnotation(
                tag = logInSubstring,
                annotation = logInSubstring,
                start = logInIndex,
                end = logInIndex + logInSubstring.length
            )
            addStyle(
                style = SpanStyle(
                    color = MaterialTheme.colors.primary
                ),
                start = supportIndex,
                end = supportIndex + supportSubstring.length
            )
            addStringAnnotation(
                tag = supportSubstring,
                annotation = supportSubstring,
                start = supportIndex,
                end = supportIndex + supportSubstring.length
            )
        }
        ClickableText(
            modifier = Modifier.padding(
                top = 16.dp
            ),
            style = MaterialTheme.typography.subtitle1.copy(
                color = Nevada
            ),
            text = informativeAnnotatedText,
            onClick = { offset ->
                informativeAnnotatedText.getStringAnnotations(
                    tag = logInSubstring,
                    start = offset,
                    end = offset
                ).firstOrNull()?.let {
                    Log.d("mlogs", it.item)
                }
    
                informativeAnnotatedText.getStringAnnotations(
                    tag = supportSubstring,
                    start = offset,
                    end = offset
                ).firstOrNull()?.let {
                    Log.d("mlogs", it.item)
                }
            }
        )
   }
Edhar Khimich
  • 1,468
  • 1
  • 17
  • 20
0

Suppose you already have a Spanned that potentially contains clickable spans (i.e. you've already done the linkify part), then you can use this:

@Composable
fun StyledText(text: CharSequence, modifier: Modifier = Modifier) {
    val clickable = rememberSaveable {
        text is Spanned && text.getSpans(0, text.length, ClickableSpan::class.java).isNotEmpty()
    }
    AndroidView(
        modifier = modifier,
        factory = { context ->
            TextView(context).apply {
                if (clickable) {
                    movementMethod = LinkMovementMethod.getInstance()
                }
            }
        },
        update = {
            it.text = text
        }
    )
}

This will also render any other span types that may be there.

Mark
  • 7,446
  • 5
  • 55
  • 75