Skip to content

Instantly share code, notes, and snippets.

@iamcalledrob
Created February 26, 2024 21:41
Show Gist options
  • Save iamcalledrob/66a02ab7792501d4e3e4440baf138b40 to your computer and use it in GitHub Desktop.
Save iamcalledrob/66a02ab7792501d4e3e4440baf138b40 to your computer and use it in GitHub Desktop.
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.*
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlin.random.Random
// LinkableText converts markdown-style hyperlinks [like](http://this) into inline text buttons
//
// Compose offers no way to do this nicely, the basic annotatedString + ClickableText approach
// doesn't support tap states or any more advanced interactions (e.g. a long press) -- and
// makes building the input string more complex than it should be.
//
// (Android Views do support this, but would be tricky to implement in conjunction with Compose
// textStyle and modifiers)
//
// Known bug: Inline buttons on the first line push the first line down by ~4dp
@Composable
fun LinkableText(
modifier: Modifier = Modifier,
text: String,
style: TextStyle,
linkColor: Color,
onClick: (String) -> Unit = {}
) {
val inlineContent = mutableMapOf<String, InlineTextContent>()
val textMeasurer = rememberTextMeasurer()
// Don't need to do this on recomposition
val spans = rememberSaveable(text) { extractLinkableSpans(text) }
val annotated = buildAnnotatedString {
spans.forEach { span ->
when (span) {
is LinkableSpan.Link -> {
val id = Random.nextInt().toString()
appendInlineContent(id, span.text)
// Measurer appears to work correctly: measured text ends up the same size
// as a BasicText() containing that text.
// However the InlineTextContent layout appears to place the inline view
// in slightly the wrong place. No placeholderVerticalAlign results in the
// inline BasicText aligning to the container's text, even with matching text
// and text styles.
//
// However, baseline alignment does reliably work, so it's possible to (1)
// align the placeholder to the baseline of the container's text, (2) then
// shift down the inline basic text to compensate.
val measurement = textMeasurer.measure(text = span.text, style = style)
val baselineDelta = measurement.size.height - measurement.firstBaseline
with(LocalDensity.current) {
inlineContent[id] = InlineTextContent(
Placeholder(
width = measurement.size.width.toSp(),
height = measurement.size.height.toSp(),
placeholderVerticalAlign = PlaceholderVerticalAlign.AboveBaseline
)
) {
BasicText(
// Outset to make touch state more comfortable
modifier = Modifier
.offset(y = baselineDelta.toDp())
.outsetParent(horizontal = 3.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(percent = 33))
.clickable { onClick(span.dest) },
text = span.text,
style = style.copy(
color = linkColor,
textAlign = TextAlign.Center // Ensures extra padding from outsetParent is evenly distributed
),
)
}
}
}
else -> {
append(span.text)
}
}
}
}
BasicText(modifier = modifier, text = annotated, style = style, inlineContent = inlineContent)
}
// Could potentially be extended to support other non-nested span types, e.g. Bold.
private sealed class LinkableSpan(val text: String) {
class Plain(text: String): LinkableSpan(text)
class Link(text: String, val dest: String): LinkableSpan(text)
}
private val linkRegex = """\[([^\]]+)\]\(([^\)]+)\)""".toRegex()
private fun extractLinkableSpans(text: String): List<LinkableSpan> {
var cursor = 0
return mutableListOf<LinkableSpan>().also { spans ->
linkRegex.findAll(text).forEach {
// Groups are [1] the entire match "[foo](http://bar)", [2] foo, [3] http://bar
// linkRegex should only have 2 matches, anything else means regex has been changed
if (it.groups.count() != 3) {
throw Exception("regex match unexpectedly has ${it.groups.count()} groups: ${it.groups}")
}
val label = it.groups[1]!!
val dest = it.groups[2]!!
// Append plain text since last match but before this url
spans.add(LinkableSpan.Plain(text.subSequence(cursor, it.range.first).toString()))
// Append this link
spans.add(
LinkableSpan.Link(
text = text.substring(label.range),
dest = text.substring(dest.range),
)
)
cursor = it.range.last + 1
}
// No more links, append the remaining plain text.
spans.add(LinkableSpan.Plain(text.subSequence(cursor, text.length).toString()))
}
}
// Makes the view larger than its natural size in its parent (i.e. the effect of negative padding)
// .fillMaxWidth() / .fillMaxHeight() may be needed after .outsetParent() to prevent the view
// from getting resized back down.
fun Modifier.outsetParent(
horizontal: Dp = 0.dp,
vertical: Dp = 0.dp
): Modifier {
return this.layout { measurable, constraints ->
val placeable = measurable.measure(
constraints.copy(
// constraints.maxWidth/maxHeight can = Int.MAX_VALUE, and therefore overflow if
// you're not careful here. Additional checks could be added to prevent the
// overflow -- a task for the future perhaps.
maxWidth = constraints.maxWidth + (horizontal * 2).roundToPx(),
maxHeight = constraints.maxHeight + (vertical * 2).roundToPx()
)
)
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment