👀
【雑記】ロングクリック可能なリンクテキストを作成する
更新履歴
2025.08.18 公開 JetpackCompose 2025.07.00
2025.09.08 タグに対応 JetpackCompose 2025.08.01
2025.09.15 コードを改善
2025.09.21 リンク以外をクリックした際、イベントを消費しないように変更
よくあるコード例の書き方が気に入らない
ネット検索や生成AIだと、よくこんな感じの例が出てくる。
detectTapGesturesを使ってレイアウトの位置から押された文字を判別する方法。
これが気に入らない。
よくある例
val text = buildAnnotatedString {
withAnnotation(TAG, "リンク") {
append("リンクの文章")
}
}
Text(
text = text,
modifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = {
/* TAGなどから位置を計算してクリック時の動作を行う */
},
onLongPress = {
/* TAGなどから位置を計算してロングクリック時の動作を行う */
}
)
}
)
なぜ気に入らないかと言うと、withLinkを使用した書き方ではAnnotatedString側でイベント定義が出来るから。
方法によって定義する場所が違うのは余りよろしくない気がする。
withLinkの例
val text = buildAnnotatedString {
withLink(
link = LinkAnnotation.Clickable(
tag = "tag",
styles = TextLinkStyles(
style = /* 通常時のスタイル */,
pressedStyle = /* 押下時のスタイル */
),
linkInteractionListener = { /* クリック時の動作、ロングクリックなどは定義出来ない */}
)
) {
append("リンクの文章")
}
}
Text(text = text)
とりあえず実装
こんな感じに使えるようにしてみた

使用例
@Composable
internal fun ExampleCombinedText() {
val context = LocalContext.current
val annotated = buildAnnotatedString {
append("クリック可能なテキスト\n\n")
append("1つ目の")
withLink(
link = LinkAnnotation.Clickable(
tag = "First",
styles = TextLinkStyles(
style = SpanStyle(color = Color.Red),
pressedStyle = SpanStyle(
color = Color.Green,
textDecoration = TextDecoration.Underline
)
),
linkInteractionListener = object : CombinedLinkInteractionListener {
override fun onClick(link: LinkAnnotation) {
Toast.makeText(context, "1つ目", Toast.LENGTH_SHORT).show()
}
override fun onLongClick(link: LinkAnnotation) {
val tag = (link as LinkAnnotation.Clickable).tag
Toast.makeText(context, tag, Toast.LENGTH_SHORT).show()
}
}
)
) {
append("リンク")
}
withBulletList { }
appendLine()
append("2つ目の")
withLink(
link = LinkAnnotation.Clickable(
tag = "Second",
styles = TextLinkStyles(
style = SpanStyle(color = Color.Blue),
pressedStyle = SpanStyle(
color = Color.DarkGray,
textDecoration = TextDecoration.Underline
)
),
linkInteractionListener = object : CombinedLinkInteractionListener {
override fun onClick(link: LinkAnnotation) {
Toast.makeText(context, "2つ目", Toast.LENGTH_SHORT).show()
}
override fun onLongClick(link: LinkAnnotation) {
val tag = (link as LinkAnnotation.Clickable).tag
Toast.makeText(context, tag, Toast.LENGTH_SHORT).show()
}
}
)
) {
append("リンクだよ")
}
}
CombinedText(
text = annotated,
fontSize = 24.sp,
)
}
実装内容
LinkInteractionListenerを継承したCombinedLinkInteractionListenerを定義しておき、CombinedTextに内で再構成させる感じです。
fromHtmlなどに渡しても動作します。
@Composable
fun CombinedText(
text: AnnotatedString,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
inlineContent: Map<String, InlineTextContent> = mapOf(),
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
) {
var pressedRange by remember { mutableStateOf(IntRange.EMPTY) }
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val baseText = remember(text) { text.convertCombinedLink() }
val pressedStyle = remember(baseText) { text.getPressedStyle() }
val displayText = remember(baseText, pressedRange) {
baseText.applyPressedStyle(pressedRange, pressedStyle)
}
Text(
text = displayText,
modifier = modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
if (layoutResult == null) return@awaitPointerEventScope
val down = awaitPointerEvent().changes.firstOrNull { it.pressed } ?: continue
val position = down.position
val offset = layoutResult!!.getOffsetForPosition(position).coerceIn(0, displayText.length - 1)
val box = layoutResult!!.getBoundingBox(offset)
pressedRange = if (box.contains(position)) {
displayText.getLinkRange(offset)
} else {
IntRange.EMPTY
}.apply { if (isEmpty()) continue }
down.consume()
val longPress =
withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
}
if (longPress == null) {
text.onLongClick(pressedRange)
} else if (longPress.pressed.not()) {
text.onClick(pressedRange)
}
pressedRange = IntRange.EMPTY
}
}
},
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
inlineContent = inlineContent,
onTextLayout = {
layoutResult = it
onTextLayout(it)
},
style = style
)
}
private val TAG = "CombinedLinkInteractionListener"
interface CombinedLinkInteractionListener : LinkInteractionListener {
override fun onClick(link: LinkAnnotation)
fun onLongClick(link: LinkAnnotation)
}
internal fun AnnotatedString.convertCombinedLink(): AnnotatedString {
val builder = Builder(this.text)
this.spanStyles.forEach { builder.addStyle(it.item, it.start, it.end) }
this.paragraphStyles.forEach { builder.addStyle(it.item, it.start, it.end) }
this.getStringAnnotations(0, length).forEach {
builder.addStringAnnotation(it.tag, it.item, it.start, it.end)
}
this.getLinkAnnotations(0, length).forEach {
if (it.item.linkInteractionListener is CombinedLinkInteractionListener) {
when (val link = it.item) {
is LinkAnnotation.Url -> {
builder.addStyle(link.styles?.style ?: SpanStyle(), it.start, it.end)
builder.addStringAnnotation(TAG, "", it.start, it.end)
}
is LinkAnnotation.Clickable -> {
builder.addStyle(link.styles?.style ?: SpanStyle(), it.start, it.end)
builder.addStringAnnotation(TAG, "", it.start, it.end)
}
}
} else {
when (val link = it.item) {
is LinkAnnotation.Url -> {
builder.addLink(link, it.start, it.end)
}
is LinkAnnotation.Clickable -> {
builder.addLink(link, it.start, it.end)
}
}
}
}
return builder.toAnnotatedString()
}
internal fun AnnotatedString.applyPressedStyle(pressedRange: IntRange, pressedStyle: List<AnnotatedString. Range<SpanStyle>>): AnnotatedString {
val builder = Builder(this)
pressedStyle.forEach {
if (pressedRange == it.start .. it.end) {
builder.addStyle(it.item, it.start, it.end)
}
}
return builder.toAnnotatedString()
}
internal fun AnnotatedString.getPressedStyle(): List<AnnotatedString.Range<SpanStyle>> {
return this.getLinkAnnotations(0, length)
.filter { it.item.linkInteractionListener is CombinedLinkInteractionListener }
.map {
AnnotatedString.Range(
it.item.styles?.pressedStyle ?: SpanStyle(),
it.start,
it.end
)
}
}
internal fun AnnotatedString.getLinkRange(position: Int): IntRange {
return this.getStringAnnotations(position, position)
.firstOrNull { it.tag == TAG }
?.let { it.start..it.end } ?: IntRange.EMPTY
}
internal fun AnnotatedString.onClick(range: IntRange) {
val link = this.getLinkAnnotations(start = range.start, end = range.last)
.firstOrNull { it.item.linkInteractionListener is CombinedLinkInteractionListener }
val listener = link?.item?.linkInteractionListener as? CombinedLinkInteractionListener
listener?.onClick(link.item)
}
internal fun AnnotatedString.onLongClick(range: IntRange) {
val link = this.getLinkAnnotations(start = range.start, end = range.last)
.firstOrNull { it.item.linkInteractionListener is CombinedLinkInteractionListener }
val listener = link?.item?.linkInteractionListener as? CombinedLinkInteractionListener
listener?.onLongClick(link.item)
}
Discussion