Skip to content

Instantly share code, notes, and snippets.

@euri16
Created July 11, 2025 19:06
Show Gist options
  • Save euri16/614a460fe6a690ce57cd23cc41164b5a to your computer and use it in GitHub Desktop.
Save euri16/614a460fe6a690ce57cd23cc41164b5a to your computer and use it in GitHub Desktop.
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.util.fastForEach
import kotlinx.collections.immutable.ImmutableList
sealed interface ClickableStyleString
/**
* A sealed interface representing different types of styled strings that can be used in a
* [StyledText] composable. It includes simple styled text, clickable email links,
* and clickable URL links.
*
* Each type contains the highlighted text, its style, and additional properties
* for clickable types (email and URL).
*/
@Immutable
sealed interface StyledString {
val highlightedText: String
val style: SpanStyle
/**
* Represents a simple styled string without any clickable functionality.
*
* @param highlightedText The text to be highlighted.
* @param style The style to be applied to the highlighted text.
*/
@Immutable
data class Simple(
override val highlightedText: String,
override val style: SpanStyle,
) : StyledString
/**
* Represents a clickable email link with a specific style.
*
* @param highlightedText The text to be highlighted.
* @param email The email address that the link points to.
* @param style The style to be applied to the highlighted text.
*/
@Immutable
data class ClickableEmail(
override val highlightedText: String,
val email: String,
override val style: SpanStyle,
) : StyledString, ClickableStyleString
/**
* Represents a clickable URL link with a specific style.
*
* @param highlightedText The text to be highlighted.
* @param url The URL that the link points to.
* @param style The style to be applied to the highlighted text.
*/
@Immutable
data class ClickableUrl(
override val highlightedText: String,
val url: String,
override val style: SpanStyle
) : StyledString, ClickableStyleString
}
/**
* A composable function that displays styled text with clickable links.
*
* This function takes a full text string and a list of styled string types,
* applying the specified styles and handling click events for clickable links.
*
* @param fullText The complete text to be displayed.
* @param styledStrings A list of [StyledString] defining the styles and clickable links.
* @param style The overall text style to be applied.
* @param onClick A callback function that is invoked when a clickable link is clicked.
* @param modifier An optional [Modifier] to apply to the Text composable.
* @param color The color to be applied to the text. Defaults to [Color.Unspecified].
* @param ignoreCase Whether to ignore case when searching for highlighted text.
*/
@Composable
fun StyledText(
fullText: String,
styledStrings: ImmutableList<StyledString>,
style: TextStyle,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
onClick: (ClickableStyleString) -> Unit = {},
ignoreCase: Boolean = false,
) {
val annotatedString = rememberStyledAnnotationString(
fullText = fullText,
styledStrings = styledStrings,
ignoreCase = ignoreCase,
onClick = onClick
)
Text(
modifier = modifier,
text = annotatedString,
style = style,
color = color
)
}
/**
* Creates an [AnnotatedString] from the provided full text and styled string types.
*
* This function builds an annotated string that applies styles and clickable links
* based on the specified [StyledString]s.
*
* @param fullText The complete text to be processed.
* @param styledStrings A list of [StyledString] defining the styles and clickable links.
* @param ignoreCase Whether to ignore case when searching for highlighted text.
* @param onClick A callback function that is invoked when a clickable link is clicked.
* @return An [AnnotatedString] with the applied styles and clickable links.
*/
@Composable
fun rememberStyledAnnotationString(
fullText: String,
styledStrings: ImmutableList<StyledString>,
ignoreCase: Boolean = false,
onClick: (ClickableStyleString) -> Unit
): AnnotatedString {
val currentOnClick by rememberUpdatedState(onClick)
return remember(fullText, styledStrings, ignoreCase) {
buildAnnotatedString {
append(fullText)
styledStrings.fastForEach { styledStringInfo ->
val indices = fullText.findAllOccurrences(
substring = styledStringInfo.highlightedText,
ignoreCase = ignoreCase
)
indices.fastForEach { startIndex ->
applyStyle(
styledString = styledStringInfo,
startIndex = startIndex,
endIndex = startIndex + styledStringInfo.highlightedText.length,
onClick = currentOnClick
)
}
}
}
}
}
private fun AnnotatedString.Builder.applyStyle(
styledString: StyledString,
startIndex: Int,
endIndex: Int,
onClick: (ClickableStyleString) -> Unit
) {
when (styledString) {
is StyledString.ClickableUrl -> {
val linkAnnotation = LinkAnnotation.Url(
url = styledString.url,
styles = TextLinkStyles(style = styledString.style),
linkInteractionListener = { onClick(styledString) }
)
addLink(linkAnnotation, startIndex, endIndex)
}
is StyledString.ClickableEmail -> {
val linkAnnotation = LinkAnnotation.Clickable(
tag = styledString.highlightedText,
styles = TextLinkStyles(style = styledString.style),
linkInteractionListener = { onClick(styledString) }
)
addLink(linkAnnotation, startIndex, endIndex)
}
is StyledString.Simple -> {
addStyle(style = styledString.style, start = startIndex, end = endIndex)
}
}
}
/**
* Find all occurrences of a substring in a string, optionally ignoring case.
*
* @param substring The substring to search for.
* @param ignoreCase Whether to perform a case-insensitive search.
* @return A list of indices where the substring was found.
*/
private fun String.findAllOccurrences(substring: String, ignoreCase: Boolean = false): List<Int> {
if (substring.isEmpty()) return emptyList()
val indices = mutableListOf<Int>()
val searchString = if (ignoreCase) this.lowercase() else this
val searchSubstring = if (ignoreCase) substring.lowercase() else substring
var startIndex = 0
val maxStartIndex = length - substring.length
while (startIndex <= maxStartIndex) {
val index = searchString.indexOf(searchSubstring, startIndex)
if (index == -1) break
indices.add(index)
startIndex = index + 1
}
return indices.toList()
}
@euri16
Copy link
Author

euri16 commented Jul 11, 2025

@PreviewLightDark
@Composable
private fun StyledTextPreview() {
    MyTheme {
        Box(
            modifier = Modifier
                .background(color = MaterialTheme.colors.background)
                .padding(16.dp)
        ) {
            // This list can be built in the ViewModel
            val styledStrings = persistentListOf(
                StyledString.ClickableEmail(
                    highlightedText = "[email protected]",
                    email = "[email protected]",
                    style = SpanStyle(
                        color = Color.Gray,
                        textDecoration = TextDecoration.Underline
                    )
                ),
                StyledString.ClickableUrl(
                    highlightedText = "website",
                    url = "https://euryperez.dev",
                    style = SpanStyle(
                        color = Color.Gray,
                        textDecoration = TextDecoration.Underline
                    )
                )
            )

            // In your Compose Screen
            StyledText(
                fullText = "Contact us at [email protected] or visit our website",
                styledStrings = styledStrings,
                style = MaterialTheme.typography.body2,
                color = MaterialTheme.colors.onBackground,
                onClick = { styled ->
                    when (styled) {
                        is StyledString.ClickableEmail -> TODO()
                        is StyledString.ClickableUrl -> TODO()
                    }
                }
            )
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment