Skip to content

Instantly share code, notes, and snippets.

@warahiko
Last active December 14, 2023 04:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save warahiko/32dd1e0607fccd7a08d817bc1d6977e4 to your computer and use it in GitHub Desktop.
Save warahiko/32dd1e0607fccd7a08d817bc1d6977e4 to your computer and use it in GitHub Desktop.
Sample of clickable in text
private fun TextLayoutResult.calculateTextPath(start: Int, endExclusive: Int): Path {
val path = Path()
var currentLineStartCharOffset = start
val lineStart = getLineForOffset(start)
val lineEndExclusive = getLineForOffset(endExclusive)
for (line in lineStart..lineEndExclusive) {
val lineEndOffsetExclusive = getLineEnd(line)
// 各行の最後の文字のオフセット
val lineEndCharOffset = lineEndOffsetExclusive - 1
if (lineEndCharOffset !in start until endExclusive) break
// 行末の文字の領域を TextLayoutResult#getPathForRange() で取得すると
// 次の行の先頭まで伸びてしまうため、別で追加する
path.addPath(getPathForRange(start = currentLineStartCharOffset, end = lineEndCharOffset))
path.addRect(getBoundingBox(lineEndCharOffset))
currentLineStartCharOffset = lineEndOffsetExclusive
}
path.addPath(getPathForRange(start = currentLineStartCharOffset, end = endExclusive))
return path
}
private fun TextLayoutResult.getPressedLinkAnnotation(
pressOffset: Offset,
annotatedString: AnnotatedString,
): AnnotatedString.Range<String>? {
// タップ位置に一番近い左端を持つ文字のオフセットのため、画面上タップしている文字とは異なる
val closestCharOffset = getOffsetForPosition(pressOffset)
// タップされた文字のオフセット
val pressedCharOffset = listOf(closestCharOffset - 1, closestCharOffset)
// 範囲外参照を回避
.filter { it in annotatedString.indices }
// タップ位置が文字列を囲む Rect に含まれているか
.firstOrNull { pressOffset in getBoundingBox(it) }
?: return null
return annotatedString
.getStringAnnotations(tag = LINK_TAG, start = pressedCharOffset, end = pressedCharOffset)
.firstOrNull()
}
private const val LINK_TAG = "linkTag"
@OptIn(ExperimentalTextApi::class)
@Composable
internal fun TextWithLink(
onClickTextLink: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val url = "https://example.com/"
val annotatedText = buildAnnotatedString {
append("詳細は")
// LINK_TAG アノテーションを付加する
withAnnotation(tag = LINK_TAG, annotation = url) {
withStyle(
style = SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline),
) {
append("こちら")
}
}
append("。")
}
ClickableText(
modifier = modifier,
text = annotatedText,
onClick = { offset ->
// タップした位置のテキストに LINK_TAG アノテーションが付加されているか
val linkAnnotation = annotatedText.getStringAnnotations(
tag = LINK_TAG, start = offset, end = offset,
).firstOrNull() ?: return@ClickableText
onClickTextLink(linkAnnotation.item)
},
)
}
@OptIn(ExperimentalTextApi::class)
@Composable
internal fun TextWithLink(
onClickTextLink: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val url = "https://example.com/"
val annotatedText = buildAnnotatedString { /* ... */ }
val currentOnClickTextLink by rememberUpdatedState(onClickTextLink)
val interactionSource = remember { MutableInteractionSource() }
var layoutResult: TextLayoutResult? by remember { mutableStateOf(null) }
// テキストのうち、リンク部分を覆う Shape で、Ripple の表示領域を表す
var pressedLinkTextShape: Shape by remember { mutableStateOf(RectangleShape) }
val handleGestureModifier = Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = { pressOffset ->
val layout = layoutResult ?: return@detectTapGestures
// タップされたリンクに対応するアノテーションを取得する
val linkAnnotation = layout.getPressedLinkAnnotation(pressOffset, annotatedText) ?: return@detectTapGestures
// リンク部分を覆う Path を求め、Shape として保持する
val linkTextPath = layout.calculateTextPath(start = linkAnnotation.start, endExclusive = linkAnnotation.end)
pressedLinkTextShape = GenericShape { _, _ -> addPath(linkTextPath) }
// Ripple アニメを発火させる
val press = PressInteraction.Press(pressOffset)
interactionSource.emit(press)
tryAwaitRelease()
interactionSource.emit(PressInteraction.Release(press))
},
onTap = { offset ->
val layout = layoutResult ?: return@detectTapGestures
val linkAnnotation = layout.getPressedLinkAnnotation(offset, annotatedText) ?: return@detectTapGestures
currentOnClickTextLink(linkAnnotation.item)
},
)
}
Box(
modifier = modifier
.width(IntrinsicSize.Max)
.height(IntrinsicSize.Min),
) {
Text(
modifier = handleGestureModifier,
text = annotatedText,
onTextLayout = { layoutResult = it },
)
Spacer(
modifier = Modifier
.fillMaxSize()
// graphicsLayer で Ripple を切り取る
.graphicsLayer {
shape = pressedLinkTextShape
clip = true
}
.indication(interactionSource, LocalIndication.current),
)
}
}
private fun TextLayoutResult.getPressedLinkAnnotation(
pressOffset: Offset,
annotatedString: AnnotatedString,
): AnnotatedString.Range<String>? {
// 文字単位のオフセット
val pressedCharOffset = getOffsetForPosition(pressOffset)
return annotatedString
.getStringAnnotations(tag = LINK_TAG, start = pressedCharOffset, end = pressedCharOffset)
.firstOrNull()
}
private fun TextLayoutResult.calculateTextPath(start: Int, endExclusive: Int): Path {
return getPathForRange(start, endExclusive)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment