Skip to content

Commit 8d9a9f2

Browse files
committed
Add inlineContent slot to ReadMoreText composables
1 parent b79eafc commit 8d9a9f2

7 files changed

Lines changed: 441 additions & 1 deletion

File tree

readmore-foundation/src/main/java/com/webtoonscorp/android/readmore/foundation/BasicReadMoreText.kt

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@
1515
*/
1616
package com.webtoonscorp.android.readmore.foundation
1717

18+
import android.annotation.SuppressLint
1819
import android.util.Log
1920
import androidx.compose.foundation.clickable
2021
import androidx.compose.foundation.layout.BoxWithConstraints
2122
import androidx.compose.foundation.layout.PaddingValues
2223
import androidx.compose.foundation.layout.padding
2324
import androidx.compose.foundation.text.BasicText
25+
import androidx.compose.foundation.text.InlineTextContent
2426
import androidx.compose.runtime.Composable
2527
import androidx.compose.runtime.LaunchedEffect
2628
import androidx.compose.runtime.Stable
@@ -31,6 +33,7 @@ import androidx.compose.runtime.setValue
3133
import androidx.compose.ui.Modifier
3234
import androidx.compose.ui.text.AnnotatedString
3335
import androidx.compose.ui.text.LinkAnnotation
36+
import androidx.compose.ui.text.Placeholder
3437
import androidx.compose.ui.text.SpanStyle
3538
import androidx.compose.ui.text.TextLayoutResult
3639
import androidx.compose.ui.text.TextMeasurer
@@ -132,6 +135,8 @@ public fun BasicReadMoreText(
132135
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
133136
* text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
134137
* [readMoreOverflow] and TextAlign may have unexpected effects.
138+
* @param inlineContent A map store composables that replaces certain ranges of the text. It's used
139+
* to insert composables into text layout. Check [InlineTextContent] for more information.
135140
* @param readMoreText The read more text to be displayed in the collapsed state.
136141
* @param readMoreMaxLines An optional maximum number of lines for the text to span, wrapping if
137142
* necessary. If the text exceeds the given number of lines, it will be truncated according to
@@ -154,6 +159,7 @@ public fun BasicReadMoreText(
154159
style: TextStyle = TextStyle.Default,
155160
onTextLayout: (TextLayoutResult) -> Unit = {},
156161
softWrap: Boolean = true,
162+
inlineContent: Map<String, InlineTextContent> = mapOf(),
157163
readMoreText: String = "",
158164
readMoreMaxLines: Int = 2,
159165
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
@@ -171,6 +177,7 @@ public fun BasicReadMoreText(
171177
style = style,
172178
onTextLayout = onTextLayout,
173179
softWrap = softWrap,
180+
inlineContent = inlineContent,
174181
readMoreText = readMoreText,
175182
readMoreMaxLines = readMoreMaxLines,
176183
readMoreOverflow = readMoreOverflow,
@@ -188,6 +195,7 @@ public fun BasicReadMoreText(
188195
private const val ReadMoreTag = "read_more"
189196
private const val ReadLessTag = "read_less"
190197

198+
@SuppressLint("UnusedBoxWithConstraintsScope")
191199
@Composable
192200
private fun CoreReadMoreText(
193201
text: AnnotatedString,
@@ -198,6 +206,7 @@ private fun CoreReadMoreText(
198206
style: TextStyle = TextStyle.Default,
199207
onTextLayout: (TextLayoutResult) -> Unit = {},
200208
softWrap: Boolean = true,
209+
inlineContent: Map<String, InlineTextContent> = mapOf(),
201210
readMoreText: String = "",
202211
readMoreMaxLines: Int = 2,
203212
readMoreOverflow: ReadMoreTextOverflow = ReadMoreTextOverflow.Ellipsis,
@@ -304,6 +313,7 @@ private fun CoreReadMoreText(
304313
overflow = TextOverflow.Ellipsis,
305314
softWrap = softWrap,
306315
maxLines = if (expanded) Int.MAX_VALUE else readMoreMaxLines,
316+
inlineContent = inlineContent,
307317
)
308318

309319
val constraints = Constraints(maxWidth = constraints.maxWidth)
@@ -317,6 +327,7 @@ private fun CoreReadMoreText(
317327
text,
318328
readMoreMaxLines,
319329
softWrap,
330+
inlineContent,
320331
) {
321332
state.applyCollapsedText(
322333
textMeasurer = textMeasurer,
@@ -328,6 +339,7 @@ private fun CoreReadMoreText(
328339
text = text,
329340
readMoreMaxLines = readMoreMaxLines,
330341
softWrap = softWrap,
342+
inlineContent = inlineContent,
331343
)
332344
}
333345
}
@@ -346,7 +358,7 @@ private class ReadMoreState {
346358

347359
var collapsedText: AnnotatedString
348360
get() = _collapsedText
349-
internal set(value) {
361+
private set(value) {
350362
if (value != _collapsedText) {
351363
_collapsedText = value
352364
if (DebugLog) {
@@ -368,6 +380,7 @@ private class ReadMoreState {
368380
text: AnnotatedString,
369381
readMoreMaxLines: Int,
370382
softWrap: Boolean,
383+
inlineContent: Map<String, InlineTextContent>,
371384
) {
372385
val overflowTextWidth = if (overflowText.isNotEmpty()) {
373386
textMeasurer.measure(
@@ -392,6 +405,7 @@ private class ReadMoreState {
392405
overflow = TextOverflow.Clip,
393406
softWrap = softWrap,
394407
constraints = constraints,
408+
placeholders = extractPlaceholders(text, inlineContent),
395409
)
396410

397411
val clipTextCount = textLayout.getLineEnd(lineIndex = textLayout.lineCount - 1)
@@ -410,6 +424,7 @@ private class ReadMoreState {
410424
text = subText,
411425
style = style,
412426
softWrap = softWrap,
427+
placeholders = extractPlaceholders(subText, inlineContent),
413428
).size.width
414429
},
415430
)
@@ -452,6 +467,40 @@ private class ReadMoreState {
452467
return replacedCount
453468
}
454469

470+
/**
471+
* Converts AnnotatedString and inlineContent map to placeholders for use with TextMeasurer.
472+
*
473+
* @param text The annotated string containing inline content annotations
474+
* @param inlineContent Map of inline content IDs to their corresponding InlineTextContent
475+
* @return List of Range<Placeholder> objects representing the inline content positions
476+
*/
477+
private fun extractPlaceholders(
478+
text: AnnotatedString,
479+
inlineContent: Map<String, InlineTextContent>,
480+
): List<AnnotatedString.Range<Placeholder>> {
481+
if (inlineContent.isEmpty()) {
482+
return emptyList()
483+
}
484+
485+
// Get all string annotations with the "androidx.compose.foundation.text.inlineContent" tag
486+
val inlineContentAnnotations = text.getStringAnnotations(
487+
tag = "androidx.compose.foundation.text.inlineContent",
488+
start = 0,
489+
end = text.length,
490+
)
491+
492+
// Map each annotation to a Range<Placeholder> if it exists in the inlineContent map
493+
return inlineContentAnnotations.mapNotNull { annotation ->
494+
inlineContent[annotation.item]?.let { content ->
495+
AnnotatedString.Range(
496+
item = content.placeholder,
497+
start = annotation.start,
498+
end = annotation.end,
499+
)
500+
}
501+
}
502+
}
503+
455504
override fun toString(): String {
456505
return "ReadMoreState(" +
457506
"collapsedText=$collapsedText" +

readmore-material/src/main/java/com/webtoonscorp/android/readmore/material/ReadMoreText.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package com.webtoonscorp.android.readmore.material
1717

1818
import androidx.compose.foundation.layout.PaddingValues
19+
import androidx.compose.foundation.text.InlineTextContent
1920
import androidx.compose.material.LocalContentAlpha
2021
import androidx.compose.material.LocalContentColor
2122
import androidx.compose.material.LocalTextStyle
@@ -276,6 +277,8 @@ public fun ReadMoreText(
276277
* [TextLayoutResult] object that callback provides contains paragraph information, size of the
277278
* text, baselines and other details. The callback can be used to add additional decoration or
278279
* functionality to the text. For example, to draw selection around the text.
280+
* @param inlineContent A map store composables that replaces certain ranges of the text. It's used
281+
* to insert composables into text layout. Check [InlineTextContent] for more information.
279282
* @param style Style configuration for the text such as color, font, line height etc.
280283
* @param readMoreText The read more text to be displayed in the collapsed state.
281284
* @param readMoreColor [Color] to apply to the read more text. If [Color.Unspecified], and [style]
@@ -331,6 +334,7 @@ public fun ReadMoreText(
331334
lineHeight: TextUnit = TextUnit.Unspecified,
332335
softWrap: Boolean = true,
333336
onTextLayout: (TextLayoutResult) -> Unit = {},
337+
inlineContent: Map<String, InlineTextContent> = mapOf(),
334338
style: TextStyle = LocalTextStyle.current,
335339
readMoreText: String = "",
336340
readMoreColor: Color = Color.Unspecified,
@@ -405,6 +409,7 @@ public fun ReadMoreText(
405409
style = mergedStyle,
406410
onTextLayout = onTextLayout,
407411
softWrap = softWrap,
412+
inlineContent = inlineContent,
408413
readMoreText = readMoreText,
409414
readMoreMaxLines = readMoreMaxLines,
410415
readMoreOverflow = readMoreOverflow,

readmore-material3/src/main/java/com/webtoonscorp/android/readmore/material3/ReadMoreText.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package com.webtoonscorp.android.readmore.material3
1717

1818
import androidx.compose.foundation.layout.PaddingValues
19+
import androidx.compose.foundation.text.InlineTextContent
1920
import androidx.compose.material3.LocalContentColor
2021
import androidx.compose.material3.LocalTextStyle
2122
import androidx.compose.material3.MaterialTheme
@@ -270,6 +271,8 @@ public fun ReadMoreText(
270271
* [TextLayoutResult] object that callback provides contains paragraph information, size of the
271272
* text, baselines and other details. The callback can be used to add additional decoration or
272273
* functionality to the text. For example, to draw selection around the text.
274+
* @param inlineContent A map store composables that replaces certain ranges of the text. It's used
275+
* to insert composables into text layout. Check [InlineTextContent] for more information.
273276
* @param style Style configuration for the text such as color, font, line height etc.
274277
* @param readMoreText The read more text to be displayed in the collapsed state.
275278
* @param readMoreColor [Color] to apply to the read more text. If [Color.Unspecified], and [style]
@@ -325,6 +328,7 @@ public fun ReadMoreText(
325328
lineHeight: TextUnit = TextUnit.Unspecified,
326329
softWrap: Boolean = true,
327330
onTextLayout: (TextLayoutResult) -> Unit = {},
331+
inlineContent: Map<String, InlineTextContent> = mapOf(),
328332
style: TextStyle = LocalTextStyle.current,
329333
readMoreText: String = "",
330334
readMoreColor: Color = Color.Unspecified,
@@ -399,6 +403,7 @@ public fun ReadMoreText(
399403
style = mergedStyle,
400404
onTextLayout = onTextLayout,
401405
softWrap = softWrap,
406+
inlineContent = inlineContent,
402407
readMoreText = readMoreText,
403408
readMoreMaxLines = readMoreMaxLines,
404409
readMoreOverflow = readMoreOverflow,

sample/src/main/java/com/webtoonscorp/android/readmore/sample/compose/foundation/BasicReadMoreTextDemo.kt

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@ package com.webtoonscorp.android.readmore.sample.compose.foundation
1717

1818
import androidx.compose.animation.animateContentSize
1919
import androidx.compose.animation.core.tween
20+
import androidx.compose.foundation.background
2021
import androidx.compose.foundation.clickable
22+
import androidx.compose.foundation.layout.Box
2123
import androidx.compose.foundation.layout.Column
2224
import androidx.compose.foundation.layout.PaddingValues
2325
import androidx.compose.foundation.layout.fillMaxSize
2426
import androidx.compose.foundation.layout.fillMaxWidth
2527
import androidx.compose.foundation.layout.padding
2628
import androidx.compose.foundation.rememberScrollState
29+
import androidx.compose.foundation.shape.RoundedCornerShape
30+
import androidx.compose.foundation.text.BasicText
31+
import androidx.compose.foundation.text.InlineTextContent
32+
import androidx.compose.foundation.text.appendInlineContent
2733
import androidx.compose.foundation.verticalScroll
2834
import androidx.compose.material.Divider
2935
import androidx.compose.material.MaterialTheme
@@ -36,11 +42,17 @@ import androidx.compose.runtime.CompositionLocalProvider
3642
import androidx.compose.runtime.mutableStateOf
3743
import androidx.compose.runtime.rememberCoroutineScope
3844
import androidx.compose.runtime.saveable.rememberSaveable
45+
import androidx.compose.ui.Alignment
3946
import androidx.compose.ui.Modifier
47+
import androidx.compose.ui.draw.clip
4048
import androidx.compose.ui.graphics.Color
49+
import androidx.compose.ui.graphics.Shape
50+
import androidx.compose.ui.platform.LocalDensity
4151
import androidx.compose.ui.platform.LocalLayoutDirection
4252
import androidx.compose.ui.res.stringResource
4353
import androidx.compose.ui.text.LinkAnnotation
54+
import androidx.compose.ui.text.Placeholder
55+
import androidx.compose.ui.text.PlaceholderVerticalAlign
4456
import androidx.compose.ui.text.SpanStyle
4557
import androidx.compose.ui.text.TextLinkStyles
4658
import androidx.compose.ui.text.TextStyle
@@ -95,6 +107,8 @@ fun BasicReadMoreTextDemo() {
95107
Divider()
96108
Item_CustomText()
97109
Divider()
110+
Item_InlineTextContent()
111+
Divider()
98112
Item_RTL()
99113
Divider()
100114
Item_Emoji()
@@ -361,6 +375,122 @@ private fun Item_CustomText() {
361375
}
362376
}
363377

378+
@Composable
379+
private fun Item_InlineTextContent() {
380+
val start = "start"
381+
val middle = "middle"
382+
val end = "end"
383+
val annotatedDescription = buildAnnotatedString {
384+
appendInlineContent(start)
385+
append("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.")
386+
appendInlineContent(middle)
387+
append("Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
388+
appendInlineContent(end)
389+
}
390+
val (expanded, onExpandedChange) = rememberSaveable { mutableStateOf(false) }
391+
Column {
392+
Text(
393+
text = stringResource(id = R.string.title_inline_content_text_compose),
394+
modifier = Modifier
395+
.fillMaxWidth()
396+
.padding(start = 18.dp, end = 18.dp, top = 16.dp),
397+
color = MaterialTheme.colors.onSurface,
398+
fontSize = 18.sp,
399+
fontWeight = FontWeight.Bold,
400+
)
401+
val density = LocalDensity.current
402+
BasicReadMoreText(
403+
text = annotatedDescription,
404+
expanded = expanded,
405+
onExpandedChange = onExpandedChange,
406+
modifier = Modifier.fillMaxWidth(),
407+
contentPadding = PaddingValues(start = 18.dp, top = 5.dp, end = 18.dp, bottom = 18.dp),
408+
style = TextStyle.Default.copy(
409+
color = MaterialTheme.colors.onSurface,
410+
fontSize = 15.sp,
411+
fontStyle = FontStyle.Normal,
412+
lineHeight = 22.sp,
413+
),
414+
inlineContent = mapOf(
415+
start to InlineTextContent(
416+
Placeholder(
417+
width = with(density) { 55.dp.toSp() },
418+
height = 15.sp,
419+
placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
420+
),
421+
) {
422+
Badge(
423+
text = "START",
424+
color = Color.Red,
425+
modifier = Modifier.padding(end = 5.dp),
426+
)
427+
},
428+
middle to InlineTextContent(
429+
Placeholder(
430+
width = with(density) { 70.dp.toSp() },
431+
height = 15.sp,
432+
placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
433+
),
434+
) {
435+
Badge(
436+
text = "MIDDLE",
437+
color = Color.Blue,
438+
modifier = Modifier.padding(horizontal = 5.dp),
439+
)
440+
},
441+
end to InlineTextContent(
442+
Placeholder(
443+
width = with(density) { 35.dp.toSp() },
444+
height = 15.sp,
445+
placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter,
446+
),
447+
) {
448+
Badge(
449+
text = "END",
450+
color = Color.Green,
451+
modifier = Modifier.padding(start = 5.dp),
452+
)
453+
},
454+
),
455+
readMoreMaxLines = 3,
456+
readMoreText = stringResource(id = R.string.read_more),
457+
readMoreStyle = SpanStyle(
458+
color = MaterialTheme.colors.secondary,
459+
fontSize = 14.sp,
460+
fontWeight = FontWeight.Bold,
461+
),
462+
readLessText = stringResource(id = R.string.read_less),
463+
)
464+
}
465+
}
466+
467+
@Composable
468+
private fun Badge(
469+
text: String,
470+
color: Color,
471+
modifier: Modifier = Modifier,
472+
contentColor: Color = Color.White,
473+
shape: Shape = RoundedCornerShape(4.dp),
474+
) {
475+
Box(
476+
contentAlignment = Alignment.Center,
477+
modifier = modifier
478+
.fillMaxSize()
479+
.background(color = color, shape = shape)
480+
.clip(shape = shape),
481+
) {
482+
BasicText(
483+
text = text,
484+
style = TextStyle.Default.copy(
485+
color = contentColor,
486+
fontSize = 13.sp,
487+
fontWeight = FontWeight.Bold,
488+
lineHeight = 14.sp,
489+
),
490+
)
491+
}
492+
}
493+
364494
@Composable
365495
private fun Item_RTL() {
366496
val (expanded, onExpandedChange) = rememberSaveable { mutableStateOf(false) }

0 commit comments

Comments
 (0)