1 /*
<lambda>null2 * Copyright 2023 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.foundation.text
18
19 import androidx.compose.foundation.combinedClickable
20 import androidx.compose.foundation.hoverable
21 import androidx.compose.foundation.interaction.MutableInteractionSource
22 import androidx.compose.foundation.layout.Box
23 import androidx.compose.runtime.Composable
24 import androidx.compose.runtime.DisposableEffect
25 import androidx.compose.runtime.LaunchedEffect
26 import androidx.compose.runtime.getValue
27 import androidx.compose.runtime.mutableStateListOf
28 import androidx.compose.runtime.mutableStateOf
29 import androidx.compose.runtime.remember
30 import androidx.compose.runtime.setValue
31 import androidx.compose.ui.Modifier
32 import androidx.compose.ui.geometry.Offset
33 import androidx.compose.ui.geometry.Size
34 import androidx.compose.ui.graphics.Outline
35 import androidx.compose.ui.graphics.Path
36 import androidx.compose.ui.graphics.Shape
37 import androidx.compose.ui.graphics.graphicsLayer
38 import androidx.compose.ui.input.pointer.PointerIcon
39 import androidx.compose.ui.input.pointer.pointerHoverIcon
40 import androidx.compose.ui.layout.ParentDataModifier
41 import androidx.compose.ui.platform.LocalUriHandler
42 import androidx.compose.ui.platform.UriHandler
43 import androidx.compose.ui.semantics.SemanticsProperties.LinkTestMarker
44 import androidx.compose.ui.semantics.semantics
45 import androidx.compose.ui.text.AnnotatedString
46 import androidx.compose.ui.text.LinkAnnotation
47 import androidx.compose.ui.text.SpanStyle
48 import androidx.compose.ui.text.TextLayoutResult
49 import androidx.compose.ui.text.TextLinkStyles
50 import androidx.compose.ui.unit.Density
51 import androidx.compose.ui.unit.IntOffset
52 import androidx.compose.ui.unit.LayoutDirection
53 import androidx.compose.ui.unit.roundToIntRect
54 import androidx.compose.ui.util.fastForEach
55 import kotlin.math.min
56
57 internal typealias LinkRange = AnnotatedString.Range<LinkAnnotation>
58
59 /**
60 * A scope that provides necessary information to attach a hyperlink to the text range.
61 *
62 * This class assumes that links exist and does not perform any additional check inside its methods.
63 * Therefore this class initialisation should be guarded by the `hasLinks` check.
64 */
65 internal class TextLinkScope(internal val initialText: AnnotatedString) {
66 var textLayoutResult: TextLayoutResult? by mutableStateOf(null)
67
68 /** [initialText] with applied links styling to it from [LinkAnnotation.styles] */
69 internal var text: AnnotatedString
70
71 init {
72 text =
73 initialText.flatMapAnnotations {
74 // If link styles don't contain a non-null style for at least one of the states,
75 // we don't add any additional style to the list of annotations
76 if (
77 it.item is LinkAnnotation && !(it.item as LinkAnnotation).styles.isNullOrEmpty()
78 ) {
79 arrayListOf(
80 // original link annotation
81 it,
82 // SpanStyle from the link styling object, or default SpanStyle otherwise
83 AnnotatedString.Range(
84 (it.item as LinkAnnotation).styles?.style ?: SpanStyle(),
85 it.start,
86 it.end
87 )
88 )
89 } else {
90 arrayListOf(it)
91 }
92 }
93 }
94
95 // Additional span style annotations applied to the AnnotatedString. These SpanStyles are coming
96 // from LinkAnnotation's style arguments
97 private val annotators = mutableStateListOf<TextAnnotatorScope.() -> Unit>()
98
99 // indicates whether the links should be measured or not. The latter needed to handle
100 // case where translated string forces measurement before the recomposition. Recomposition in
101 // this case will dispose the links altogether because translator returns plain text
102 val shouldMeasureLinks: () -> Boolean
103 get() = { text == textLayoutResult?.layoutInput?.text }
104
105 /**
106 * Causes the modified element to be measured with fixed constraints equal to the bounds of the
107 * text range and placed over that range of text.
108 */
109 private fun Modifier.textRange(link: LinkRange): Modifier {
110 return this.then(
111 TextRangeLayoutModifier {
112 val layoutResult =
113 textLayoutResult
114 ?: return@TextRangeLayoutModifier layout(0, 0) { IntOffset.Zero }
115 val updatedRange =
116 calculateVisibleLinkRange(link, layoutResult)
117 ?: return@TextRangeLayoutModifier layout(0, 0) { IntOffset.Zero }
118 val bounds =
119 layoutResult
120 .getPathForRange(updatedRange.start, updatedRange.end)
121 .getBounds()
122 .roundToIntRect()
123 layout(bounds.width, bounds.height) { bounds.topLeft }
124 }
125 )
126 }
127
128 /**
129 * Clips the Box representing the link to the path of the text range corresponding to that link
130 */
131 private fun Modifier.clipLink(link: LinkRange): Modifier =
132 this.graphicsLayer {
133 shapeForRange(link)?.let { linkShape ->
134 shape = linkShape
135 clip = true
136 }
137 }
138
139 private fun shapeForRange(link: LinkRange): Shape? =
140 pathForRangeInRangeCoordinates(link)?.let {
141 object : Shape {
142 override fun createOutline(
143 size: Size,
144 layoutDirection: LayoutDirection,
145 density: Density
146 ): Outline {
147 return Outline.Generic(it)
148 }
149 }
150 }
151
152 private fun pathForRangeInRangeCoordinates(link: LinkRange): Path? {
153 return if (!shouldMeasureLinks()) null
154 else {
155 textLayoutResult?.let {
156 val range = calculateVisibleLinkRange(link, it) ?: return null
157 val path = it.getPathForRange(range.start, range.end)
158
159 val firstCharBoundingBox = it.getBoundingBox(range.start)
160 val lastCharBoundingBox = it.getBoundingBox(range.end - 1)
161
162 val rangeStartLine = it.getLineForOffset(range.start)
163 val rangeEndLine = it.getLineForOffset(range.end - 1)
164
165 val xOffset =
166 if (rangeStartLine == rangeEndLine) {
167 // if the link occupies a single line, we take the left most position of the
168 // link's range
169 minOf(lastCharBoundingBox.left, firstCharBoundingBox.left)
170 } else {
171 // if the link occupies more than one line, the left sides of the link node
172 // and
173 // text node match so we don't need to do anything
174 0f
175 }
176
177 // the top of the top-most (first) character
178 val yOffset = firstCharBoundingBox.top
179
180 path.translate(-Offset(xOffset, yOffset))
181 return path
182 }
183 }
184 }
185
186 /**
187 * Conditionally updates [link]'s end based on [textLayoutResult] so the resulted link range is
188 * within the visible bounds of text. Returns null if the link is fully outside visible text
189 * bounds.
190 *
191 * Avoid calling this in the composition scope, instead call in layout or draw scope of the
192 * modifier.
193 */
194 private fun calculateVisibleLinkRange(
195 link: LinkRange,
196 textLayoutResult: TextLayoutResult
197 ): LinkRange? {
198 // The paragraph with a link might not be added to the paragraphs list if it exceeds
199 // the maxline. The Box will be measured (0, 0) in that case and some other modifier like
200 // clip won't apply
201 val lastOffset = textLayoutResult.getLineEnd(textLayoutResult.lineCount - 1)
202 return if (link.start < lastOffset) {
203 // The link might be clipped if we reach the maxLines so we adjust its end to be the
204 // last visible offset.
205 link.copy(end = min(link.end, lastOffset))
206 } else null
207 }
208
209 /**
210 * This composable responsible for creating layout nodes for each link annotation. Since
211 * [TextLinkScope] object created *only* when there are links present in the text, we don't need
212 * to do any additional guarding inside this composable function.
213 */
214 @Composable
215 fun LinksComposables() {
216 val uriHandler = LocalUriHandler.current
217
218 val links = text.getLinkAnnotations(0, text.length)
219 links.fastForEach { range ->
220 if (range.start != range.end) {
221 val interactionSource = remember { MutableInteractionSource() }
222
223 Box(
224 Modifier.clipLink(range)
225 .semantics {
226 // adding this to identify links in tests, see performFirstLinkClick
227 this[LinkTestMarker] = Unit
228 }
229 .textRange(range)
230 .hoverable(interactionSource)
231 .pointerHoverIcon(PointerIcon.Hand)
232 .combinedClickable(
233 indication = null,
234 interactionSource = interactionSource,
235 onClick = { handleLink(range.item, uriHandler) }
236 )
237 )
238
239 if (!range.item.styles.isNullOrEmpty()) {
240 // the interaction source is not hoisted, we create and remember it in the
241 // code above. Therefore there's no need to pass it as a key to the remember and
242 // a
243 // launch effect.
244 val linkStateObserver = remember {
245 LinkStateInteractionSourceObserver(interactionSource)
246 }
247 LaunchedEffect(Unit) { linkStateObserver.collectInteractionsForLinks() }
248
249 StyleAnnotation(
250 linkStateObserver.isHovered,
251 linkStateObserver.isFocused,
252 linkStateObserver.isPressed,
253 range.item.styles?.style,
254 range.item.styles?.focusedStyle,
255 range.item.styles?.hoveredStyle,
256 range.item.styles?.pressedStyle,
257 ) {
258 // we calculate the latest style based on the link state and apply it to the
259 // initialText's style. This allows us to merge the style with the original
260 // instead of fully replacing it
261 val mergedStyle =
262 range.item.styles
263 ?.style
264 .mergeOrUse(
265 if (linkStateObserver.isFocused) range.item.styles?.focusedStyle
266 else null
267 )
268 .mergeOrUse(
269 if (linkStateObserver.isHovered) range.item.styles?.hoveredStyle
270 else null
271 )
272 .mergeOrUse(
273 if (linkStateObserver.isPressed) range.item.styles?.pressedStyle
274 else null
275 )
276 replaceStyle(range, mergedStyle)
277 }
278 }
279 }
280 }
281 }
282
283 private fun SpanStyle?.mergeOrUse(other: SpanStyle?) = this?.merge(other) ?: other
284
285 private fun handleLink(link: LinkAnnotation, uriHandler: UriHandler) {
286 when (link) {
287 is LinkAnnotation.Url ->
288 link.linkInteractionListener?.onClick(link)
289 ?: try {
290 uriHandler.openUri(link.url)
291 } catch (_: IllegalArgumentException) {
292 // we choose to silently fail when the uri can't be opened to avoid crashes
293 // for users. This is the case where developer don't provide the link
294 // handlers themselves and therefore I suspect are less likely to test them
295 // manually.
296 }
297 is LinkAnnotation.Clickable -> link.linkInteractionListener?.onClick(link)
298 }
299 }
300
301 /** Returns [text] with additional styles from [LinkAnnotation] based on link's state */
302 internal fun applyAnnotators(): AnnotatedString {
303 val styledText =
304 if (annotators.isEmpty()) text
305 else {
306 val scope = TextAnnotatorScope(text)
307 annotators.fastForEach { it.invoke(scope) }
308 scope.styledText
309 }
310 text = styledText
311 return styledText
312 }
313
314 /** Adds style annotations to [text]. */
315 @Composable
316 private fun StyleAnnotation(vararg keys: Any?, block: TextAnnotatorScope.() -> Unit) {
317 DisposableEffect(block, *keys) {
318 annotators += block
319 onDispose { annotators -= block }
320 }
321 }
322 }
323
isNullOrEmptynull324 private fun TextLinkStyles?.isNullOrEmpty(): Boolean {
325 return this == null ||
326 (style == null && focusedStyle == null && hoveredStyle == null && pressedStyle == null)
327 }
328
329 /** Interface holding the width, height and positioning logic. */
330 internal class TextRangeLayoutMeasureResult
331 internal constructor(val width: Int, val height: Int, val place: () -> IntOffset)
332
333 /**
334 * The receiver scope of a text range layout's measure lambda. The return value of the measure
335 * lambda is [TextRangeLayoutMeasureResult], which should be returned by [layout]
336 */
337 internal class TextRangeLayoutMeasureScope {
layoutnull338 fun layout(width: Int, height: Int, place: () -> IntOffset): TextRangeLayoutMeasureResult =
339 TextRangeLayoutMeasureResult(width, height, place)
340 }
341
342 /** Provides the size and placement for an element inside a [TextLinkScope] */
343 internal fun interface TextRangeScopeMeasurePolicy {
344 fun TextRangeLayoutMeasureScope.measure(): TextRangeLayoutMeasureResult
345 }
346
347 internal class TextRangeLayoutModifier(val measurePolicy: TextRangeScopeMeasurePolicy) :
348 ParentDataModifier {
modifyParentDatanull349 override fun Density.modifyParentData(parentData: Any?) = this@TextRangeLayoutModifier
350 }
351
352 /**
353 * Provides methods to update styles of the text inside a [TextLinkScope.StyleAnnotation] function.
354 */
355 private class TextAnnotatorScope(private val initialText: AnnotatedString) {
356 var styledText = initialText
357
358 fun replaceStyle(linkRange: AnnotatedString.Range<LinkAnnotation>, newStyle: SpanStyle?) {
359 var linkFound = false
360 styledText =
361 initialText.mapAnnotations {
362 // if we found a link annotation on previous iteration, we need to update the
363 // SpanStyle
364 // on this iteration. This SpanStyle with the same range as the link annotation
365 // coming right after the link annotation corresponds to the link styling
366 val annotation =
367 if (
368 linkFound &&
369 it.item is SpanStyle &&
370 it.start == linkRange.start &&
371 it.end == linkRange.end
372 ) {
373 AnnotatedString.Range(newStyle ?: SpanStyle(), it.start, it.end)
374 } else {
375 it
376 }
377 linkFound = linkRange == it
378 annotation
379 }
380 }
381 }
382