Last active
December 14, 2023 04:00
-
-
Save warahiko/32dd1e0607fccd7a08d817bc1d6977e4 to your computer and use it in GitHub Desktop.
Sample of clickable in text
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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("。") | |
} | |
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 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 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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
}, | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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