Draw a reply's links as text spans, not a node per link
Compose materializes every LinkAnnotation as its own layout node -- a clipped, focusable, hoverable, clickable box laid out against the text -- and the markdown renderer emits one per link. Measured on the emulator against the same paragraphs with each link replaced by its label and URL as plain words: the linked version cost five times the worst measure (26.3ms vs 5.2ms) and 1.7x the place time, with less text on screen. On the phone that was the bump at a reply's list of sources. The paragraph, text and heading components now go through LinkedText, which builds the renderer's annotated string with an annotator that styles a link as a span carrying its address, and hit-tests taps against the text layout itself. After: place back at the link-free level (worst 3.1ms), worst measure halved. Table cells and reference links keep the renderer's path; the table draws its own cells. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
63c0bb9e4a
commit
f87c99c71b
2 files changed
+154
No files matched your search
@@ -0,0 +1,141 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.node.Ref
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import com.mikepenz.markdown.annotator.AnnotatorSettings
|
||||
import com.mikepenz.markdown.annotator.annotatorSettings
|
||||
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
|
||||
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownText
|
||||
import com.mikepenz.markdown.model.markdownAnnotator
|
||||
import com.mikepenz.markdown.utils.getUnescapedTextInNode
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
import org.intellij.markdown.ast.findChildOfType
|
||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
|
||||
|
||||
/**
|
||||
* A paragraph, heading or bare text whose links are spans of the text rather than nodes of their
|
||||
* own.
|
||||
*
|
||||
* Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable,
|
||||
* hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text
|
||||
* layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one of those
|
||||
* annotations per link. Measured on the emulator against the same paragraphs with each link
|
||||
* replaced by its label and address as plain words -- *more* text, the same gestures -- the linked
|
||||
* version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a
|
||||
* Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it.
|
||||
*
|
||||
* Here a link is the link colour and underline, a string annotation carrying its address, and one
|
||||
* tap detector for the whole text that asks the layout which character was under the finger. What
|
||||
* that gives up is a link being its own accessibility node with a pressed state; the app's link
|
||||
* style never defined a pressed style, so nothing visible changes.
|
||||
*
|
||||
* Every block the renderer dispatches through its component table comes here, which includes the
|
||||
* paragraphs inside lists, quotes and alerts. Table cells do not: the table draws its own cells and
|
||||
* offers no slot for them, so a link in a cell keeps the renderer's path -- correct, and dearer.
|
||||
* Reference-style links stay there too; the renderer resolves those against its definitions.
|
||||
*/
|
||||
@Composable
|
||||
fun LinkedText(model: MarkdownComponentModel, style: TextStyle, heading: Boolean = false) {
|
||||
val settings = plainLinkSettings()
|
||||
val text =
|
||||
remember(model.content, model.node, style) {
|
||||
model.content.buildMarkdownAnnotatedString(model.node, style, settings)
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val layout = remember { Ref<TextLayoutResult>() }
|
||||
MarkdownText(
|
||||
content = text,
|
||||
node = model.node,
|
||||
modifier =
|
||||
Modifier.then(if (heading) Modifier.semantics { heading() } else Modifier).pointerInput(
|
||||
text
|
||||
) {
|
||||
detectTapGestures { position ->
|
||||
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
|
||||
uriHandler.openUri(url)
|
||||
}
|
||||
},
|
||||
style = style,
|
||||
onTextLayout = { result, _ -> layout.value = result },
|
||||
)
|
||||
}
|
||||
|
||||
/** The address under [position], if a link's glyph is there rather than merely nearest to it. */
|
||||
private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? {
|
||||
layout ?: return null
|
||||
val offset = layout.getOffsetForPosition(position)
|
||||
if (offset >= length || !layout.getBoundingBox(offset).contains(position)) return null
|
||||
return getStringAnnotations(LINK_URL, offset, offset).firstOrNull()?.item
|
||||
}
|
||||
|
||||
private const val LINK_URL = "url"
|
||||
|
||||
/**
|
||||
* The renderer's annotator settings with [appendPlainLink] answering for links. The annotator needs
|
||||
* the settings to draw a link's label, and the settings hold the annotator, so the reference goes
|
||||
* through a cell filled in once both exist.
|
||||
*/
|
||||
@Composable
|
||||
private fun plainLinkSettings(): AnnotatorSettings {
|
||||
val cell = remember { Ref<AnnotatorSettings>() }
|
||||
val annotator = remember {
|
||||
markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) }
|
||||
}
|
||||
return annotatorSettings(annotator = annotator).also { cell.value = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends [node] as a styled, annotated span if it is a link the renderer would otherwise emit a
|
||||
* `LinkAnnotation` for; false leaves anything else to the renderer.
|
||||
*/
|
||||
private fun appendPlainLink(
|
||||
builder: AnnotatedString.Builder,
|
||||
content: String,
|
||||
node: ASTNode,
|
||||
settings: AnnotatorSettings,
|
||||
): Boolean {
|
||||
val destination: String
|
||||
val label: List<ASTNode>?
|
||||
when (node.type) {
|
||||
MarkdownElementTypes.INLINE_LINK -> {
|
||||
val text = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: return false
|
||||
destination =
|
||||
node
|
||||
.findChildOfType(MarkdownElementTypes.LINK_DESTINATION)
|
||||
?.getUnescapedTextInNode(content)
|
||||
?.removeSurrounding("<", ">") ?: return false
|
||||
// The brackets are the first and last children of the label.
|
||||
label = text.children.drop(1).dropLast(1)
|
||||
}
|
||||
MarkdownElementTypes.AUTOLINK -> {
|
||||
destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">")
|
||||
label = null
|
||||
}
|
||||
GFMTokenTypes.GFM_AUTOLINK -> {
|
||||
destination = node.getUnescapedTextInNode(content)
|
||||
label = null
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
builder.pushStringAnnotation(LINK_URL, destination)
|
||||
builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle())
|
||||
if (label == null) builder.append(destination)
|
||||
else builder.buildMarkdownAnnotatedString(content, label, settings)
|
||||
builder.pop()
|
||||
builder.pop()
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user