👀
【雑記】ロングクリック可能なリンクテキストを作成する
更新履歴
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