Upgrade to Pro — share decks privately, control downloads, hide ads and more …

ReadMoreTextView

 ReadMoreTextView

2025년 06월 17일 (화) 드로이드나이츠 2025 발표자료입니다.
"ReadMoreTextView: 텍스트 '더보기' 기능 구현하기"
https://www.droidknights.dev/

Avatar for Sungyong An

Sungyong An

June 17, 2025
Tweet

More Decks by Sungyong An

Other Decks in Programming

Transcript

  1. 목차 C. Introduction G. View H. Compose M. Additional features

    요구사항 제품 ❌ 사용방법, 배포 ✅ 진행과정, 구현
  2. SNS ীࢲ ൔ൤ ࠁ੉ח UX ۨಌ۠झ ఐ࢝ ൔ൤ ࢎਊغח UX,

    ೞ૑݅ ೒ۖಬ ૑ਗ਷ হ׮. যڌѱ ҳഅ೧ঠ ೡө? $
  3. Usage <com.borjabravo.readmoretextview.ReadMoreTextView android:layout_width="match_parent" android:layout_height="wrap_content" app:trimCollapsedText="Read more" app:colorClickableText="@color/colorPrimary" app:trimLines="2" app:trimMode="trimModeLine" />

    bravoborja <kr.co.prnd.readmore.ReadMoreTextView android:layout_width="match_parent" android:layout_height="wrap_content" app:readMoreText="... Read more" app:readMoreColor="@color/colorPrimary" app:readMoreMaxLine="2" /> PRNDcompany
  4. class ReadMoreTextView : AppCompatTextView { override fun setText(text: CharSequence?, type:

    BufferType?) { super.setText(text, type) doOnLayout { post { setupReadMore() } } } private fun setupReadMore() { if (needSkipSetupReadMore()) return originalText = text val adjustCutCount = getAdjustCutCount(readMoreMaxLine, readMoreText) val maxTextIndex = layout.getLineVisibleEnd(readMoreMaxLine - 1) val originalSubText = originalText.substring(0, maxTextIndex - 1 - adjustCutCount) text = buildSpannedString { append(originalSubText) color(readMoreColor) { append("… ؊ࠁӝ") } } } } PRNDcompany/ReadMoreTextView (4) (2) (3) (5) (1) Library
  5. public class ReadMoreTextView extends TextView { @Override public void setText(CharSequence

    text, BufferType type) { this.text = text; bufferType = type; super.setText(getDisplayableText(), bufferType); } private CharSequence getDisplayableText() { return getTrimmedText(text); } private CharSequence getTrimmedText(CharSequence text) { if (text != null && lineEndIndex > 0) { if (readMore) { if (getLayout().getLineCount() > trimLines) { return updateCollapsedText(); } } else { return updateExpandedText(); } } return text; } bravoborja/ReadMoreTextView (1) (2) (3) Library
  6. ׮নೠ ҙ੼ীࢲ Ҋ۰غযঠ ೠ׮. Ӓۢ ۄ੉࠳۞ܻܳ ࢎਊ೧ب ؼө? 유지보수 Maintenance

    안정성 Stability 호환성 Compatibility 기능 Functionality ⚠ ⚠ bravoborja class ReadMoreTextView : AppCompatTextView { ... } PRNDcompany class ReadMoreTextView : TextView { ... } Link: https://developer.android.com/jetpack/androidx/releases/appcompat#1.4.0 Emoji ૑ਗ
  7. • Android ೒ۖಬীࢲ ఫझ౟ח ؀ࠗ࠙ TextView۽ ҳഅೠ׮. • AppCompatTextViewܳ ࢚ࣘೞח

    Custom View ௿ېझ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. Custom View ҳഅೞӝ <TextView android:id="@+id/text_view_id" android:layout_height="wrap_content" android:layout_width="wrap_content" android:text="@string/hello" />
  8. class ReadMoreTextView : AppCompatTextView { override fun setText(text: CharSequence?, type:

    BufferType?) { this.originalText = text this.bufferType = type updateText(text ?: "", width) } private fun updateText(text: CharSequence, width: Int) { this.collapseText = ... invalidateText() } private fun invalidateText() { if (expanded) { ... } else { super.setText(collapseText, bufferType) super.setMaxLines(readMoreMaxLines) } } } Library
  9. class ReadMoreTextView : AppCompatTextView { override fun setText(text: CharSequence?, type:

    BufferType?) { this.originalText = text this.bufferType = type updateText(text ?: "", width) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) if (w != oldw) { originalText?.let { originalText -> updateText(originalText, w) } } } ... } Library
  10. val maximumTextWidth = width - (paddingLeft + paddingRight) this.collapseText =

    buildSpannedString { val overflowText = buildOverflowText() // "…" val overflowTextWidth = StaticLayoutCompat .Builder(overflowText, paint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreText = buildReadMoreText() // " Read more" val readMoreTextWidth = StaticLayoutCompat .Builder(readMoreText, readMorePaint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreWidth = overflowTextWidth + readMoreTextWidth val replaceCount = text .substringOf(layout, line = readMoreMaxLines) // 2nd Line Text .calculateReplaceCountToBeSingleLineWith(maximumTextWidth - readMoreWidth) append(text.subSequence(0, countUntilMaxLine - replaceCount)) append(overflowText) append(readMoreText) } Library
  11. val maximumTextWidth = this.collapseText = buildSpannedString { val overflowText =

    buildOverflowText() // "…" val overflowTextWidth = StaticLayoutCompat .Builder(overflowText, paint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreText = buildReadMoreText() // " Read more" val readMoreTextWidth = StaticLayoutCompat .Builder(readMoreText, readMorePaint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreWidth = overflowTextWidth + readMoreTextWidth val replaceCount = text .substringOf(layout, line = readMoreMaxLines) // 2nd Line Text .calculateReplaceCountToBeSingleLineWith(maximumTextWidth - readMoreWidth) append(text.subSequence(0, countUntilMaxLine - replaceCount)) append(overflowText) append(readMoreText) } Library
  12. val maximumTextWidth = this.collapseText = buildSpannedString { val overflowText =

    buildOverflowText() // "…" val overflowTextWidth = StaticLayoutCompat .Builder(overflowText, paint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreText = buildReadMoreText() // " Read more" val readMoreTextWidth = StaticLayoutCompat .Builder(readMoreText, readMorePaint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreWidth = val replaceCount = text .substringOf(layout, line = readMoreMaxLines) // 2nd Line Text .calculateReplaceCountToBeSingleLineWith(maximumTextWidth - readMoreWidth) append(text.subSequence(0, countUntilMaxLine - replaceCount)) append(overflowText) append(readMoreText) } Library
  13. val maximumTextWidth = this.collapseText = buildSpannedString { val overflowText =

    buildOverflowText() // "…" val overflowTextWidth = StaticLayoutCompat .Builder(overflowText, paint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreText = buildReadMoreText() // " Read more" val readMoreTextWidth = StaticLayoutCompat .Builder(readMoreText, readMorePaint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreWidth = val replaceCount = text .substringOf(layout, line = readMoreMaxLines) // 2nd Line Text .calculateReplaceCountToBeSingleLineWith(maximumTextWidth - readMoreWidth) append(text.subSequence(0, countUntilMaxLine - replaceCount)) append(overflowText) append(readMoreText) } Library
  14. val maximumTextWidth = this.collapseText = buildSpannedString { val overflowText =

    buildOverflowText() // "…" val overflowTextWidth = StaticLayoutCompat .Builder(overflowText, paint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreText = buildReadMoreText() // " Read more" val readMoreTextWidth = StaticLayoutCompat .Builder(readMoreText, readMorePaint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreWidth = val replaceCount = text .substringOf(layout, line = readMoreMaxLines) // 2nd Line Text .calculateReplaceCountToBeSingleLineWith(maximumTextWidth - readMoreWidth) append(text.subSequence(0, countUntilMaxLine - replaceCount)) append(overflowText) append(readMoreText) } Library
  15. private fun CharSequence.calculateReplaceCountToBeSingleLineWith( maximumTextWidth: Int, ): Int { val currentTextBounds

    = Rect() var replacedCount = -1 do { replacedCount++ val replacedText = substring(0, this.length - replacedCount) paint.getTextBounds(substring(0, this.length - replacedCount)) } while (replacedCount < this.length && currentTextBounds.width() >= maximumTextWidth) ... return replacedCount } TextPaint#getTextBounds Library
  16. val maximumTextWidth = width - (paddingLeft + paddingRight) this.collapseText =

    buildSpannedString { val overflowText = buildOverflowText() // "…" val overflowTextWidth = StaticLayoutCompat .Builder(overflowText, paint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreText = buildReadMoreText() // " Read more" val readMoreTextWidth = StaticLayoutCompat .Builder(readMoreText, readMorePaint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreWidth = overflowTextWidth + readMoreTextWidth val replaceCount = text .substringOf(layout, line = readMoreMaxLines) // 2nd Line Text .calculateReplaceCountToBeSingleLineWith(maximumTextWidth - readMoreWidth) append(text.subSequence(0, countUntilMaxLine - replaceCount)) append(overflowText) append(readMoreText) } Library
  17. val maximumTextWidth = width - (paddingLeft + paddingRight) this.collapseText =

    buildSpannedString { val overflowText = buildOverflowText() // "…" val overflowTextWidth = StaticLayoutCompat .Builder(overflowText, paint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreText = buildReadMoreText() // " Read more" val readMoreTextWidth = StaticLayoutCompat .Builder(readMoreText, readMorePaint, maximumTextWidth) .build() .getLineWidth(0).toInt() val readMoreWidth = overflowTextWidth + readMoreTextWidth val replaceCount = text .substringOf(layout, line = readMoreMaxLines) // 2nd Line Text .calculateReplaceCountToBeSingleLineWith(maximumTextWidth - readMoreWidth) append( ) append( ) append( ) } Library
  18. <com.webtoonscorp.android.readmore.ReadMoreTextView android:layout_width="match_parent" android:layout_height="wrap_content" // Set maximum lines to show 'read

    more' text. app:readMoreMaxLines="3" app:readMoreOverflow="ellipsis" // Set 'read more' text and styles. app:readMoreText="@string/read_more" app:readMoreTextColor="?colorPrimary" app:readMoreTextFontFamily="sans-serif" app:readMoreTextSize="12sp" app:readMoreTextStyle="bold" app:readMoreTypeface="normal" app:readMoreTextUnderline="true" // Set textAppearance to 'read more' text. app:readMoreTextAppearance="@style/TextAppearance.AppCompat.Small" // If you want to use custom OnClickListener, you must be set this attribute to false. app:readMoreToggleEnabled="false" /> Usage • ୭؀ ઴ ࣻ • ؊ࠁӝ ޙҳ झఋੌ
  19. • Android ೒ۖಬীࢲ ఫझ౟ח ؀ࠗ࠙ TextView۽ ҳഅೠ׮. • AppCompatTextViewܳ ࢚ࣘೞח

    Custom View ௿ېझ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. • ఫझ౟ ҅࢑: StaticLayout, TextPaint API Custom View ҳഅೞӝ <TextView android:id="@+id/text_view_id" android:layout_height="wrap_content" android:layout_width="wrap_content" android:text="@string/hello" />
  20. private fun CharSequence.calculateReplaceCountToBeSingleLineWith( maximumTextWidth: Int, ): Int { val currentTextBounds

    = Rect() var replacedCount = -1 do { replacedCount++ val replacedText = substring(0, this.length - replacedCount) paint.getTextBounds(replacedText, 0, replacedText.length, currentTextBounds) } while (replacedCount < this.length && currentTextBounds.width() >= maximumTextWidth) ... return replacedCount } TextPaint#getTextBounds Library
  21. private fun CharSequence.calculateReplaceCountToBeSingleLineWith( maximumTextWidth: Int, ): Int { var replacedTextWidth:

    Float var replacedCount = -1 do { replacedCount++ val replacedText = substring(0, this.length - replacedCount) replacedTextWidth = paint.measureText(replacedText) } while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth) ... return replacedCount } * Special thanks to @hyeonu1258 TextPaint#measureText Library
  22. private fun CharSequence.calculateReplaceCountToBeSingleLineWith( maximumTextWidth: Int, ): Int { var replacedTextWidth:

    Float var replacedCount = -1 do { replacedCount++ replacedTextWidth = paint.measureText(substring(0, this.length - replacedCount)) } while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth) ... return replacedCount } * Special thanks to @hyeonu1258 TextPaint#measureText Library +3 +5 +3 +2 +3 +5
  23. private fun buildMoreText(): CharSequence { return buildSpannedString { append("… ")

    append(readMoreText) // Read more } } private fun buildMoreText(): CharSequence { return buildSpannedString { append('…') append(nbsp) // non-breaking space append(readMoreText) // Read more } } Link: h!ps://en.wikipedia.org/wiki/Non-breaking_space
  24. ࠄޙ ӡѱ ੘ࢿೞৈ ݈઴੐غח ҃਋ ੉ݽ૑ ӵ૗ ੉ग #3. &

    0xDC9B 0xD83D high surrogate low surrogate U+DC00 U+DFFF U+D800 U+D8FF
  25. private fun CharSequence.calculateReplaceCountToBeSingleLineWith( replaceText: CharSequence, maximumTextWidth: Int ): Int {

    val currentTextBounds = Rect() var replacedCount = -1 do { replacedCount++ val subText = substring(0, this.length - replacedCount) val replacedText = subText + replaceText paint.getTextBounds(replacedText, 0, replacedText.length, currentTextBounds) } while (currentTextBounds.width() >= maximumTextWidth) return replacedCount }
  26. private fun CharSequence.calculateReplaceCountToBeSingleLineWith( replaceText: CharSequence, maximumTextWidth: Int ): Int {

    val currentTextBounds = Rect() var replacedCount = -1 do { replacedCount++ val subText = substring(0, this.length - replacedCount) val replacedText = subText + replaceText paint.getTextBounds(replacedText, 0, replacedText.length, currentTextBounds) } while (currentTextBounds.width() >= maximumTextWidth) val subText = substring(0, this.length - replacedCount) if (subText.isNotEmpty() && subText.last().isSurrogate()) { val index = subText.indexOfLast { it.isHighSurrogate() } return length - index } return replacedCount } Link: h!ps://engineering.linecorp.com/ko/blog/the-7-ways-of-counting-characters/
  27. • Composeীࢲח BasicText۽ ఫझ౟ܳ ҳഅೡ ࣻ ੓׮. • BasicText ೣࣻܳ

    ഐ୹ೞח Custom Composable ೣࣻ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. Jetpack Compose ૑ਗೞӝ BasicText(text = "Text") @Composable fun BasicText( text: String, modifier: Modifier = Modifier, ... ) { ... }
  28. @Composable public fun BasicReadMoreText( text: String, ... ) { val

    state = remember(text, readMoreMaxLines) { ReadMoreState( originalText = AnnotatedString(text), readMoreMaxLines = readMoreMaxLines ) } val collapsedText = state.collapsedText val currentText = ... BasicText( text = currentText, onTextLayout = { it -> // TextLayoutResult state.onTextLayout(it) }, maxLines = if (expanded) Int.MAX_VALUE else readMoreMaxLines ) } Library
  29. BasicText( text = overflowText, // "…" onTextLayout = { state.onOverflowTextLayout(it)

    }, modifier = Modifier.notDraw(), style = style ) BasicText( text = readMoreText, // " Read more" onTextLayout = { state.onReadMoreTextLayout(it) }, modifier = Modifier.notDraw(), style = style.merge(readMoreStyle) ) Library ⚠ ؊ࠁӝ ޙҳب ੉۠ җ੿੉ ೙ਃೞ׮.
  30. @Composable public fun BasicReadMoreText( text: String, ... ) { val

    state = remember(...) { ReadMoreState(...) } val currentText = ... Box(...) { BasicText(text = currentText, ...) if (expanded.not()) { BasicText(text = overflowText, ...) // "…" BasicText(text = readMoreText, ...) // " Read more" } } } Library (BasicTextܳ 3ѐա ࢎਊ೧ঠ ೠ׮…)
  31. BasicText( text = overflowText, // "…" onTextLayout = { state.onOverflowTextLayout(it)

    }, modifier = Modifier.notDraw(), style = style ) BasicText( text = readMoreText, // " Read more" onTextLayout = { state.onReadMoreTextLayout(it) }, modifier = Modifier.notDraw(), style = style.merge(readMoreStyle) ) Library (ࢎਊ੗ীѱ ֢୹غ૑ ঋب۾ ऀӣ ୊ܻ)
  32. private fun Modifier.notDraw(): Modifier { return then(NotDrawModifier) } private object

    NotDrawModifier : DrawModifier { override fun ContentDrawScope.draw() { // not draws content. } } Library (ࢎਊ੗ীѱ ֢୹غ૑ ঋب۾ ऀӣ ୊ܻ)
  33. @Stable private class ReadMoreState( private val originalText: AnnotatedString, private val

    readMoreMaxLines: Int ) { fun onTextLayout(result: TextLayoutResult) { val lastLineIndex = readMoreMaxLines - 1 val previous = textLayout val old = previous != null && previous.lineCount <= readMoreMaxLines && previous.isLineEllipsized(lastLineIndex) val new = result.lineCount <= readMoreMaxLines && result.isLineEllipsized(lastLineIndex) val changed = previous != result && old != new if (changed) { textLayout = result updateCollapsedText() } } ... } Library (୭؀ೠ সؘ੉౟ പࣻܳ ઴੉۰Ҋ ઑѤਸ ୶о ')
  34. private fun updateCollapsedText() { ... val countUntilMaxLine = textLayout.getLineEnd(readMoreMaxLines -

    1, visibleEnd = true) val readMoreWidth = overflowTextLayout.size.width + readMoreTextLayout.size.width val maximumWidth = textLayout.size.width - readMoreWidth var replacedEndIndex = countUntilMaxLine + 1 var currentTextBounds: Rect do { replacedEndIndex -= 1 currentTextBounds = textLayout.getCursorRect(replacedEndIndex) } while (currentTextBounds.left > maximumWidth) collapsedText = originalText.subSequence(startIndex = 0, endIndex = replacedEndIndex) } Library ⚠ ಞ૘ೠ ఫझ౟ ց࠺ܳ ஏ੿ೡ ࣻ ੓ח ߑߨب হ׮. TextLayoutResult#getCursorRect
  35. ఫझ౟ ҅࢑ೞӝ فߣ૩ ઴੄ ݃૑݄ o"setਸ ଺ח׮. getLineEnd(0) = 37

    getLineEnd(1) = 37 + 36 = 73 getLineEnd(2) = 37 + 36 + 25 = 98
  36. ReadMoreText( text = "Original Text", expanded = false, onExpandedChange =

    null, modifier = Modifier.fillMaxWidth(), readMoreMaxLines = 3, readMoreOverflow = ReadMoreTextOverflow.Ellipsis, readMoreText = "Read more", readMoreColor = Color.Black, readMoreFontFamily = FontFamily.Default, readMoreFontSize = 15.sp, readMoreFontWeight = FontWeight.Bold, readMoreFontStyle = FontStyle.Normal, readMoreTextDecoration = TextDecoration.Underline, readMoreStyle = SpanStyle( // ... ) ) Usage • ୭؀ ઴ ࣻ • ؊ࠁӝ ޙҳ झఋੌ
  37. • Composeীࢲח BasicText۽ ఫझ౟ܳ ҳഅೡ ࣻ ੓׮. • BasicText ೣࣻܳ

    ഐ୹ೞח Custom Composable ೣࣻ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. • ఫझ౟ ҅࢑: TextLayoutResult API Jetpack Compose ૑ਗೞӝ BasicText(text = "Text")
  38. • Kotlin Multipla#orm ߂ Jetpack Composeܳ ӝ߈ਵ۽ ೞח ৈ۞ ೒ۖಬীࢲ

    UIܳ ҕਬೡ ࣻ ੓ח ࢶ঱ഋ ೐ۨ੐ਕ௼ • ૑ਗೞח ೒ۖಬ: Compose Multiplatform? Link: jb.gg/compose
  39. • forked from androidx/androidx Compose Multiplatform Core commonMain androidMain jbMain

    ... nativeMain desktopMain … webMain or skikoMain Jetpack Compose Compose Multiplatform Link: https://github.com/JetBrains/compose-multiplatform-core
  40. • ۄ੉࠳۞ܻ ߸҃੼: • Kotlin plugin • ೒ۖಬ Targetਸ ࢶ঱ೠ׮.

    • Jetbrains Compose۽ ੹ജೠ׮. Compose Multiplatform ب੹ೞӝ
  41. • ۄ੉࠳۞ܻ ߸҃੼: • Kotlin plugin • ೒ۖಬ Targetਸ ࢶ঱ೠ׮.

    • Jetbrains Compose۽ ੹ജೠ׮. • ௏٘ܳ commonMainਵ۽ ৤ӟ׮. Compose Multiplatform ب੹ೞӝ Kotlin 1.8 ੉੹ Kotlin 1.8 ੉റ Android
  42. • ۄ੉࠳۞ܻ ߸҃੼: • Kotlin plugin • ೒ۖಬ Targetਸ ࢶ঱ೠ׮.

    • Jetbrains Compose۽ ੹ജೠ׮. • ௏٘ܳ commonMainਵ۽ ৤ӟ׮. • ౠ੿ ೒ۖಬী ઙࣘػ APIܳ ઁѢೠ׮. (ex. android.util.Log) Compose Multiplatform ب੹ೞӝ
  43. • ۄ੉࠳۞ܻ ߸҃੼: • Kotlin plugin • ೒ۖಬ Targetਸ ࢶ঱ೠ׮.

    • Jetbrains Compose۽ ੹ജೠ׮. • ௏٘ܳ commonMainਵ۽ ৤ӟ׮. • ౠ੿ ೒ۖಬী ઙࣘػ APIܳ ઁѢೠ׮. (ex. android.util.Log) • (non-JVM) value classܳ ੌ߈ class۽ ߸҃ೠ׮. Compose Multiplatform ب੹ೞӝ
  44. TextMeasurer ) Compose 1.3+ val textMeasurer: TextMeasurer = rememberTextMeasurer() ⚠

    ఫझ౟ܳ Ӓܻӝ ੹ী ஏ੿೧ࠅ ࣻ ੓׮. Link: https://developer.android.com/reference/kotlin/androidx/compose/ui/text/TextMeasurer val result: TextLayoutResult = textMeasurer.measure( text = currentText, style = style, softWrap = softWrap, )
  45. private fun CharSequence.calculateReplaceCountToBeSingleLineWith( maximumTextWidth: Int, ): Int { var replacedTextWidth:

    Float var replacedCount = -1 do { replacedCount++ replacedTextWidth = paint.measureText( substring(0, this.length - replacedCount) ) } while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth) ... return replacedCount } TextPaint#measureText View
  46. TextMeasurer#measure private fun AnnotatedString.calculateReplaceCountToBeSingleLineWith( maximumTextWidth: Int, ): Int { var

    replacedTextWidth: Int var replacedCount = -1 do { replacedCount++ replacedTextWidth = textMeasurer.measure( text = subSequence(0, this.length - replacedCount), style = style, softWrap = softWrap, ).size.width } while (replacedCount < this.length && replacedTextWidth >= maximumTextWidth) ... return replacedCount } Usage Compose ✅ View৬ Compose ۽૒ਸ ాੌೡ ࣻ ੓঻׮.
  47. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { ... } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, ... ) } } } Compose
  48. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { ... } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, ... ) } } } Compose
  49. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { ... } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, ... ) } } } Compose
  50. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { ... } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, ... ) } } } Compose ✅ BoxWithConstraints۽ ୭؀ ց࠺ܳ ҅࢑೧ঠ ೠ׮.
  51. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { ... } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, ... ) } } } Compose ✅ ఫझ౟, ୭؀ ց࠺ ١ਵ۽ collapsed textܳ ҅࢑ೠ׮.
  52. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { ... } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, Compose
  53. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { if (expanded) { append(text) ... } else { append(state.collapsedText) ... } } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, Compose
  54. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { ... } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, Compose
  55. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { ... } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, ... ) } } } Compose
  56. @Composable public fun CoreReadMoreText(text: AnnotatedString, ...) { ... val textMeasurer

    = rememberTextMeasurer() val state = remember { ReadMoreState() } val currentText = buildAnnotatedString { ... } BoxWithConstraints(...) { BasicText(text = currentText, ...) val constraints = Constraints(maxWidth = constraints.maxWidth) LaunchedEffect(...) { state.applyCollapsedText( textMeasurer = textMeasurer, text = text, constraints = constraints, ... ) } } } Compose ✅ keyо زੌ೧ࢲ, ೠߣ݅ সؘ੉౟ೠ׮.
  57. • Composeীࢲח BasicText۽ ఫझ౟ܳ ҳഅೡ ࣻ ੓׮. • BasicText ೣࣻܳ

    ഐ୹ೞח Custom Composable ೣࣻ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. • ఫझ౟ ҅࢑: TextMeasurer, BoxWithConstraints API Jetpack Compose ૑ਗೞӝ BasicText(text = "Text")
  58. val expandedText = buildSpannedString { append(originalText) append(' ') inSpans(spans =

    arrayOf( TextAppearanceSpan(...), UnderlineSpan(), )) { append(readLessText) } } Usage View
  59. Usage Compose val expandedText = buildAnnotatedString { append(originalText) append(' ')

    withStyle(style = SpanStyle( ..., textDecoration = TextDecoration.Underline, )) { append(readLessText) } }
  60. // ToggleArea.All view.setOnClickListener { toggle() } // ToggleArea.More val currentText

    = buildSpannedString { ... inSpans(span = object : ClickableSpan() { override fun onClick(widget: View) { toggle() } }) { append(readMoreText) // "Read more" } } view.movementMethod = LinkMovementMethod.getInstance() View
  61. // ToggleArea.More val currentText = buildAnnotatedString { ... withLink( LinkAnnotation.Clickable(tag

    = ReadMoreTag) { onExpandedChange(true) }, ) { append(readMoreText) // "Read more" } } // ToggleArea.All Box( modifier = modifier.clickable( onClick = { onExpandedChange(!expanded) } ) ) { ... Compose
  62. view.text = buildSpannedString { ... inSpans(span = object : ClickableSpan()

    { override fun onClick(widget: View) { // #TAG click! } }) { append("#TAG") } } view.movementMethod = LinkMovementMethod.getInstance() View
  63. view.text = buildSpannedString { ... inSpans(span = object : ClickableSpan()

    { override fun onClick(widget: View) { // #TAG click! } }) { append("#TAG") } } view.movementMethod = LinkMovementMethod.getInstance() view.setOnClickListener { view.toggle() } View
  64. view.text = buildSpannedString { ... inSpans(span = object : ClickableSpan()

    { override fun onClick(widget: View) { // #TAG click! } }) { append("#TAG") } } view.movementMethod = LinkMovementMethod.getInstance() view.setOnClickListener { view.toggle() } View ⚠ Click ೠߣী 2ѐ੄ ੉߮౟о ߊࢤೠ׮.
  65. view.text = buildSpannedString { ... inSpans(span = object : ClickableSpan()

    { override fun onClick(widget: View) { // #TAG click! } }) { append("#TAG") } } view.movementMethod = LinkMovementMethod.getInstance() view.setOnClickListener { if (view.selectionStart == -1 && view.selectionEnd == -1) { view.toggle() } } View Link: https://stackoverflow.com/a/35694135 ⚠ Workaround۽ ೧Ѿೡ ࣻ ੓׮.
  66. view.text = buildSpannedString { ... inSpans(span = object : ClickableSpan()

    { override fun onClick(widget: View) { // #TAG click! } }) { append("#TAG") } } view.movementMethod = LinkMovementMethod.getInstance() view.setOnClickListener { if (view.selectionStart == -1 && view.selectionEnd == -1) { view.toggle() } } View Link: https://stackoverflow.com/a/35694135
  67. view.text = buildSpannedString { ... inSpans(span = object : ClickableSpan()

    { override fun onClick(widget: View) { // #TAG click! } }) { append("#TAG") } } view.movementMethod = LinkMovementMethod.getInstance() view.setOnClickListener { if (view.selectionStart == -1 && view.selectionEnd == -1) { view.toggle() } } View
  68. • 텍스트 더보기 UX 구현과정 • 요구사항 → (레퍼런스 탐색

    → 라이브러리 탐색 / 분석 → 구현) → 테스트 • 사용한 API • View: TextPaint, StaticLayout • Compose: TextMeasurer, BoxWithConstraints • “더 구현할 것은 없을까?” • Stateful → Stateless? • 정말 정말 정말 긴 텍스트? 정리