👀

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

に公開

更新履歴

2025.08.18 公開 JetpackCompose 2025.07.00
2025.09.08 タグに対応 JetpackCompose 2025.08.01

よくあるコード例の書き方が気に入らない

ネット検索や生成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つ目の")
        withCombinedLink(
            tag = "一つ目のリンク",
            styles = TextLinkStyles(
                style = SpanStyle(color = Color.Blue),
                pressedStyle = SpanStyle(
                    color = Color.Red,
                    textDecoration = TextDecoration.Underline
                )
            ),
            onClick = { Toast.makeText(context, "1", Toast.LENGTH_SHORT).show() },
            onLongClick = { Toast.makeText(context, (it as LinkAnnotation.Clickable).tag, Toast.LENGTH_SHORT).show() }
        ) {
            append("リンク")
        }

        appendLine()
        append("2つ目の")
        withCombinedLink(
            tag = リンクだよリンクだよ",
            styles = TextLinkStyles(
                style = SpanStyle(color = Color.Gray),
                pressedStyle = SpanStyle(
                    color = Color.DarkGray,
                    textDecoration = TextDecoration.Underline
                )
            ),
            onClick = { Toast.makeText(context, "2", Toast.LENGTH_SHORT).show() },
            onLongClick = { Toast.makeText(context, (it as LinkAnnotation.Clickable).tag, Toast.LENGTH_SHORT).show() }
        ) {
            append("リンクだよ")
        }
    }

    CombinedText(
        text = annotated,
        fontSize = 24.sp,
    )

実装内容

withCombinedLinkで追加されたリンクから再構成する感じで実装しています。

@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) {
            detectTapGestures(
                onPress = { position ->
                    if (layoutResult == null) return@detectTapGestures

                    println(pressedStyle)

                    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
                    }

                    try {
                        awaitRelease()
                    } finally {
                        pressedRange = IntRange.EMPTY
                    }
                },
                onTap = {
                    text.onClick(pressedRange)
                },
                onLongPress = {
                    text.onLongClick(pressedRange)
                }
            )
        },
        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
    )
}

internal const val LINK_RANGE = "LinkRange/"

internal interface CombinedLinkInteractionListener : LinkInteractionListener {

    override fun onClick(link: LinkAnnotation)
    fun onLongClick(link: LinkAnnotation)
}

fun Builder.addCombinedLink(
    tag: String,
    styles: TextLinkStyles,
    onClick: ((link: LinkAnnotation) -> Unit)? = null,
    onLongClick: ((link: LinkAnnotation) -> Unit)? = null,
    start: Int,
    end: Int
 ) {

    val listener = object : CombinedLinkInteractionListener {
        override fun onClick(link: LinkAnnotation) {
            onClick?.invoke(link)
        }

        override fun onLongClick(link: LinkAnnotation) {
            onLongClick?.invoke(link)
        }
    }

    addLink(
        clickable = LinkAnnotation.Clickable(
            tag = LINK_RANGE + tag,
            styles = styles,
            linkInteractionListener = listener
        ),
        start = start,
        end = end
    )
}

fun Builder.withCombinedLink(
    tag: String,
    styles: TextLinkStyles,
    onClick: ((link: LinkAnnotation) -> Unit)? = null,
    onLongClick: ((link: LinkAnnotation) -> Unit)? = null,
    block: Builder.() -> Unit
) {
    val start = length
    block()
    val end = length

    addCombinedLink(tag, styles, onClick, onLongClick, start, end)
}

internal fun AnnotatedString.getLinkRange(position: Int): IntRange {
    return this.getStringAnnotations(position, position)
        .filter { it.tag.startsWith(LINK_RANGE) }
        .firstOrNull()
        ?.let { it.start..it.end } ?: IntRange.EMPTY
}


internal fun AnnotatedString.onClick(range: IntRange) {

    val annotations = this.getLinkAnnotations(range.first, range.last)
        .filter { it.item is LinkAnnotation.Clickable }
        .map { it.item as LinkAnnotation.Clickable }

    val link = annotations
        .firstOrNull { it.tag.startsWith(LINK_RANGE) }
        ?.let {
            LinkAnnotation.Clickable(
                tag = it.tag.removePrefix(LINK_RANGE),
                styles = it.styles,
                linkInteractionListener = it.linkInteractionListener
            )
        }

    val listener = link?.linkInteractionListener as? CombinedLinkInteractionListener

    listener?.onClick(link)
}

internal fun AnnotatedString.onLongClick(range: IntRange) {

    val annotations = this.getLinkAnnotations(range.first, range.last)
        .filter { it.item is LinkAnnotation.Clickable }
        .map { it.item as LinkAnnotation.Clickable }

    val link = annotations
        .firstOrNull { it.tag.startsWith(LINK_RANGE) }
        ?.let {
            LinkAnnotation.Clickable(
                tag = it.tag.removePrefix(LINK_RANGE),
                styles = it.styles,
                linkInteractionListener = it.linkInteractionListener
            )
        }

    val listener = link?.linkInteractionListener as? CombinedLinkInteractionListener

    listener?.onLongClick(link)
}

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 {
        when (val link = it.item) {
            is LinkAnnotation.Url -> builder.addLink(link, it.start, it.end)
            is LinkAnnotation.Clickable -> {
                if (!link.tag.startsWith(LINK_RANGE)) {
                    builder.addLink(link, it.start, it.end)
                } else {
                    builder.addStyle(link.styles?.style ?: SpanStyle(), it.start, it.end)
                    builder.addStringAnnotation(link.tag, "", 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 {
            val link = it.item
            link is LinkAnnotation.Clickable && link.tag.startsWith(LINK_RANGE)
        }
        .map {
            AnnotatedString.Range(
                it.item.styles?.pressedStyle ?: SpanStyle(),
                it.start,
                it.end
            )
        }

}

Discussion