Slide 1

Slide 1 text

ReadMoreTextView: 텍스트 '더보기' 기능 구현하기 Sungyong An NAVER WEBTOON

Slide 2

Slide 2 text

Sungyong An NAVER WEBTOON GDE @fornewid

Slide 3

Slide 3 text

Link: https://github.com/webtoon/ReadMoreTextView ! য়ט ઱ઁח ਢొ Android য়೑ࣗझ ۄ੉࠳۞ܻ ҙ۲

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

목차 C. Introduction G. View H. Compose M. Additional features 요구사항 제품 ❌ 사용방법, 배포 ✅ 진행과정, 구현

Slide 6

Slide 6 text

C. Introduction

Slide 7

Slide 7 text

؊ࠁӝ UXܳ ઁҕೞח ఫझ౟ ஹನք౟ Collapsed Expanded ReadMoreTextView?

Slide 8

Slide 8 text

ழޭפ౭

Slide 9

Slide 9 text

N઴ ੉࢚੉ݶ ݈઴੐ ֢୹. చ ೡٸ݃׮ ಟஜ/੽൨ ࢚కо ߸҃ػ׮. ӝദ ਃҳࢎ೦

Slide 10

Slide 10 text

੽ഃ૓ ޙҳীࢲ …җ more੄ ࢚࢝җ ఫझ౟ ௼ӝо ׮ܰ׮. ٣੗ੋ ਃҳࢎ೦

Slide 11

Slide 11 text

SNS ীࢲ ൔ൤ ࠁ੉ח UX ۨಌ۠झ ఐ࢝

Slide 12

Slide 12 text

SNS ীࢲ ൔ൤ ࠁ੉ח UX ۨಌ۠झ ఐ࢝ ൔ൤ ࢎਊغח UX, ೞ૑݅ ೒ۖಬ ૑ਗ਷ হ׮. যڌѱ ҳഅ೧ঠ ೡө? $

Slide 13

Slide 13 text

Githubীࢲ য়೑ࣗझ ۄ੉࠳۞ܻܳ ଺ইࠄ׮. ۄ੉࠳۞ܻ ఐ࢝

Slide 14

Slide 14 text

Usage bravoborja PRNDcompany

Slide 15

Slide 15 text

PRNDcompany ⚠ ӯ߅੉ח അ࢚੉ ੓׮

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

setText(originalText) setText(collapsedText) next frame skip next frame (1) (4) (5) PRNDcompany/ReadMoreTextView Library

Slide 18

Slide 18 text

bravoborja ✅

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

getDisplayableText() setText(collapsedText) same frame (1) (3) bravoborja/ReadMoreTextView Library

Slide 21

Slide 21 text

׮নೠ ҙ੼ীࢲ Ҋ۰غযঠ ೠ׮. Ӓۢ ۄ੉࠳۞ܻܳ ࢎਊ೧ب ؼө? 유지보수 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 ૑ਗ

Slide 22

Slide 22 text

“૒੽ ҳഅೞח Ѫਵ۽ Ѿ੿೮׮ݶ”

Slide 23

Slide 23 text

G. View

Slide 24

Slide 24 text

• Android ೒ۖಬীࢲ ఫझ౟ח ؀ࠗ࠙ TextView۽ ҳഅೠ׮. • AppCompatTextViewܳ ࢚ࣘೞח Custom View ௿ېझ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. Custom View ҳഅೞӝ

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

setText(originalText) skip super.setText(collapsedText) Library next frame same frame

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

Collapsed Text Original Text ఫझ౟ ҅࢑ೞӝ 2઴ ੉࢚਷ ؊ࠁӝ ಴द

Slide 29

Slide 29 text

ఫझ౟ ҅࢑ೞӝ فߣ૩ ઴ ఫझ౟ܳ ӝળਵ۽, ؊ࠁӝ ޙҳо ֢୹ؼ ࣻ ੓ח ఫझ౟ ࣻܳ ҅࢑೧ঠ ೠ׮.

Slide 30

Slide 30 text

ఫझ౟ ҅࢑ೞӝ TextView੄ ୭؀ ց࠺ܳ ҅࢑ೠ׮.

Slide 31

Slide 31 text

ఫझ౟ ҅࢑ೞӝ فߣ૩ ઴ ఫझ౟ ց࠺৬ ؊ࠁӝ ޙҳ ց࠺ܳ ؊ೞҊ, ୭؀ ց࠺৬ ࠺Үೠ׮.

Slide 32

Slide 32 text

ఫझ౟ ҅࢑ೞӝ ୭؀ ց࠺ܳ ֈ૑ ঋب۾ فߣ૩ ઴ ఫझ౟ীࢲ ੜܻח ఫझ౟ ࣻܳ ҅࢑ೠ׮.

Slide 33

Slide 33 text

ఫझ౟ ҅࢑ೞӝ ✅

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

Usage • ୭؀ ઴ ࣻ • ؊ࠁӝ ޙҳ झఋੌ

Slide 43

Slide 43 text

• Android ೒ۖಬীࢲ ఫझ౟ח ؀ࠗ࠙ TextView۽ ҳഅೠ׮. • AppCompatTextViewܳ ࢚ࣘೞח Custom View ௿ېझ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. • ఫझ౟ ҅࢑: StaticLayout, TextPaint API Custom View ҳഅೞӝ

Slide 44

Slide 44 text

੘о ழޭפ౭ > ೐۽೙ ࣻ੿ > ੘оࣗѐ ӡѱ ١۾ > read more ੜܿ ੉ग #1. e

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

੘ࢿೠ Ӗী ౠࣻޙ੗!о ನೣػ ҃਋, moreо ׮਺ ઴۽ ֢୹ؽ ੉ग #2.

Slide 49

Slide 49 text

੘ࢿೠ Ӗী ౠࣻޙ੗!о ನೣػ ҃਋, Read moreо 2઴۽ ֢୹ؽ ੉ग #2.

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

ࠄޙ ӡѱ ੘ࢿೞৈ ݈઴੐غח ҃਋ ੉ݽ૑ ӵ૗ ੉ग #3.

Slide 52

Slide 52 text

ࠄޙ ӡѱ ੘ࢿೞৈ ݈઴੐غח ҃਋ ੉ݽ૑ ӵ૗ ੉ग #3. & 0xDC9B 0xD83D high surrogate low surrogate U+DC00 U+DFFF U+D800 U+D8FF

Slide 53

Slide 53 text

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 }

Slide 54

Slide 54 text

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/

Slide 55

Slide 55 text

H. Compose

Slide 56

Slide 56 text

• Composeীࢲח BasicText۽ ఫझ౟ܳ ҳഅೡ ࣻ ੓׮. • BasicText ೣࣻܳ ഐ୹ೞח Custom Composable ೣࣻ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. Jetpack Compose ૑ਗೞӝ BasicText(text = "Text") @Composable fun BasicText( text: String, modifier: Modifier = Modifier, ... ) { ... }

Slide 57

Slide 57 text

@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

Slide 58

Slide 58 text

state.onTextLayout(it) BasicText(originalText) skip BasicText(collapsedText) Library state.onTextLayout(it) ⚠ ఫझ౟о Ӓ۰૓ റীঠ ց࠺ܳ ঌ ࣻ ੓׮.

Slide 59

Slide 59 text

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 ⚠ ؊ࠁӝ ޙҳب ੉۠ җ੿੉ ೙ਃೞ׮.

Slide 60

Slide 60 text

@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ѐա ࢎਊ೧ঠ ೠ׮…)

Slide 61

Slide 61 text

state.onTextLayout(it) BasicText(originalText) skip BasicText(collapsedText) Library state.onTextLayout(it) ( ... ) BasicText(overflowText) state.onOverflowTextLayout(it) state.onReadMoreTextLayout(it) BasicText(readMoreText)

Slide 62

Slide 62 text

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 (ࢎਊ੗ীѱ ֢୹غ૑ ঋب۾ ऀӣ ୊ܻ)

Slide 63

Slide 63 text

private fun Modifier.notDraw(): Modifier { return then(NotDrawModifier) } private object NotDrawModifier : DrawModifier { override fun ContentDrawScope.draw() { // not draws content. } } Library (ࢎਊ੗ীѱ ֢୹غ૑ ঋب۾ ऀӣ ୊ܻ)

Slide 64

Slide 64 text

@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 (୭؀ೠ সؘ੉౟ പࣻܳ ઴੉۰Ҋ ઑѤਸ ୶о ')

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

ఫझ౟ ҅࢑ೞӝ فߣ૩ ઴੄ ݃૑݄ o"setਸ ଺ח׮. getLineEnd(0) = 37 getLineEnd(1) = 37 + 36 = 73 getLineEnd(2) = 37 + 36 + 25 = 98

Slide 67

Slide 67 text

ఫझ౟ ҅࢑ೞӝ ୭؀ ց࠺ীࢲ ؊ࠁӝ ޙҳ ց࠺ܳ ࡒ ೲਊ ց࠺ܳ ҳೠ׮.

Slide 68

Slide 68 text

ఫझ౟ ҅࢑ೞӝ ழࢲ੄ le"о ೲਊ ց࠺ܳ ֈ૑ ঋਸ ٸө૑ O"setਸ ઴ੋ׮.

Slide 69

Slide 69 text

ఫझ౟ ҅࢑ೞӝ ழࢲ੄ le"о ೲਊ ց࠺ܳ ֈ૑ ঋਸ ٸө૑ O"setਸ ઴ੋ׮.

Slide 70

Slide 70 text

ఫझ౟ ҅࢑ೞӝ ழࢲ੄ le"о ೲਊ ց࠺ܳ ֈ૑ ঋਸ ٸө૑ O"setਸ ઴ੋ׮.

Slide 71

Slide 71 text

ఫझ౟ ҅࢑ೞӝ ழࢲ੄ le"о ೲਊ ց࠺ܳ ֈ૑ ঋਸ ٸө૑ O"setਸ ઴ੋ׮.

Slide 72

Slide 72 text

ఫझ౟ ҅࢑ೞӝ ✅ '

Slide 73

Slide 73 text

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 • ୭؀ ઴ ࣻ • ؊ࠁӝ ޙҳ झఋੌ

Slide 74

Slide 74 text

• Composeীࢲח BasicText۽ ఫझ౟ܳ ҳഅೡ ࣻ ੓׮. • BasicText ೣࣻܳ ഐ୹ೞח Custom Composable ೣࣻ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. • ఫझ౟ ҅࢑: TextLayoutResult API Jetpack Compose ૑ਗೞӝ BasicText(text = "Text")

Slide 75

Slide 75 text

No content

Slide 76

Slide 76 text

• Kotlin Multipla#orm ߂ Jetpack Composeܳ ӝ߈ਵ۽ ೞח ৈ۞ ೒ۖಬীࢲ UIܳ ҕਬೡ ࣻ ੓ח ࢶ঱ഋ ೐ۨ੐ਕ௼ • ૑ਗೞח ೒ۖಬ: Compose Multiplatform? Link: jb.gg/compose

Slide 77

Slide 77 text

• 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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

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

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

• iOS Compose Multiplatform ب੹ೞӝ ❌

Slide 83

Slide 83 text

• Desktop (JVM) Compose Multiplatform ب੹ೞӝ ❌

Slide 84

Slide 84 text

• TextLayoutResult#isLineEllipsized о ೦࢚ falseܳ ߈ജೠ׮. ਗੋ਷...? Link: https://youtrack.jetbrains.com/issue/CMP-3074 commonMain androidMain skikoMain nativeMain desktopMain webMain

Slide 85

Slide 85 text

• ! ইۉযীࢲ “Read More” ޙҳо ୶оغ૑ ঋणפ׮. RTL ੉ग View Compose

Slide 86

Slide 86 text

• TextLayoutResult#getLineEnd RTL ੉ग فߣ૩ ઴੄ ݃૑݄ o"setਸ ଺ח׮.

Slide 87

Slide 87 text

• TextLayoutResult#size RTL ੉ग ୭؀ ց࠺ীࢲ ؊ࠁӝ ޙҳ ց࠺ܳ ࡒ ೲਊ ց࠺ܳ ҳೠ׮.

Slide 88

Slide 88 text

• TextLayoutResult#getCursorRect RTL ੉ग ழࢲ੄ le"о ೲਊ ց࠺ܳ ֈ૑ ঋਸ ٸө૑ o"setਸ ઴ੋ׮.

Slide 89

Slide 89 text

• TextLayoutResult#getCursorRect RTL ੉ग ⚠ o%setਸ ઴ੌࣻ۾ ழࢲ੄ le#о ழ૓׮.

Slide 90

Slide 90 text

• TextLayoutResult#getCursorRect RTL ੉ग ( RTLীࢲח ழࢲ੄ right۽ ҅࢑ೠ׮? ❌ ఫझ౟ ೞաী RTL, LTR੉ ࢴੌ ࣻب ੓׮.

Slide 91

Slide 91 text

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, )

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

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 ۽૒ਸ ాੌೡ ࣻ ੓঻׮.

Slide 94

Slide 94 text

@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

Slide 95

Slide 95 text

@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

Slide 96

Slide 96 text

@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

Slide 97

Slide 97 text

@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۽ ୭؀ ց࠺ܳ ҅࢑೧ঠ ೠ׮.

Slide 98

Slide 98 text

@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ܳ ҅࢑ೠ׮.

Slide 99

Slide 99 text

@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

Slide 100

Slide 100 text

@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

Slide 101

Slide 101 text

@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

Slide 102

Slide 102 text

@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

Slide 103

Slide 103 text

@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о زੌ೧ࢲ, ೠߣ݅ সؘ੉౟ೠ׮.

Slide 104

Slide 104 text

state.applyCollapsedText(originalText) skip BasicText(collapsedText) Compose Text Measurer

Slide 105

Slide 105 text

• ఫझ౟ ҅࢑: • ❌ TextLayoutResult#getCursorRect • ✅ TextMeasurer (Compose 1.3+) Jetpack Compose ૑ਗೞӝ View Compose

Slide 106

Slide 106 text

• Composeীࢲח BasicText۽ ఫझ౟ܳ ҳഅೡ ࣻ ੓׮. • BasicText ೣࣻܳ ഐ୹ೞח Custom Composable ೣࣻ۽ ؊ࠁӝ UXܳ ҳഅೠ׮. • ఫझ౟ ҅࢑: TextMeasurer, BoxWithConstraints API Jetpack Compose ૑ਗೞӝ BasicText(text = "Text")

Slide 107

Slide 107 text

(؋࠙ী Compose Multiplatform ૑ਗ оמ) Link: https://github.com/webtoon/ReadMoreTextView/pull/104

Slide 108

Slide 108 text

Compose Multiplatform (iOS)

Slide 109

Slide 109 text

Compose Multiplatform (Desktop)

Slide 110

Slide 110 text

Compose Multiplatform (Web)

Slide 111

Slide 111 text

M. Additional features

Slide 112

Slide 112 text

Read Less Collapsed Expanded

Slide 113

Slide 113 text

val expandedText = buildSpannedString { append(originalText) append(' ') inSpans(spans = arrayOf( TextAppearanceSpan(...), UnderlineSpan(), )) { append(readLessText) } } Usage View

Slide 114

Slide 114 text

Usage Compose val expandedText = buildAnnotatedString { append(originalText) append(' ') withStyle(style = SpanStyle( ..., textDecoration = TextDecoration.Underline, )) { append(readLessText) } }

Slide 115

Slide 115 text

• All • More • None Toggle Area

Slide 116

Slide 116 text

// ToggleArea.All view.setOnClickListener { toggle() } View

Slide 117

Slide 117 text

// 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

Slide 118

Slide 118 text

// ToggleArea.All Box( modifier = modifier.clickable( onClick = { onExpandedChange(!expanded) } ) ) { ... Compose

Slide 119

Slide 119 text

// 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

Slide 120

Slide 120 text

Hyperlink Collapsed Expanded

Slide 121

Slide 121 text

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

Slide 122

Slide 122 text

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

Slide 123

Slide 123 text

Hyperlink ⚠

Slide 124

Slide 124 text

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ѐ੄ ੉߮౟о ߊࢤೠ׮.

Slide 125

Slide 125 text

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۽ ೧Ѿೡ ࣻ ੓׮.

Slide 126

Slide 126 text

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

Slide 127

Slide 127 text

No content

Slide 128

Slide 128 text

Hyperlink ✅

Slide 129

Slide 129 text

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

Slide 130

Slide 130 text

val text = buildAnnotatedString { ... withLink(link = LinkAnnotation.Clickable("TAG") { // #TAG click! }) { append("#TAG") } } Compose

Slide 131

Slide 131 text

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

Slide 132

Slide 132 text

No content