👀

【雑記】ロングクリック可能なリンクテキストを作成する

に公開

更新履歴

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