-
-
Save iamcalledrob/66a02ab7792501d4e3e4440baf138b40 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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