Hanbit the Developer
[Android] Rich Text: 앱 업데이트 없이 TextView의 내용과 스타일을 변경해보자 본문
배경
모바일 어플리케이션의 매우 치명적인 단점 중 하나는 바로 업데이트입니다. 사용자 입장에서 앱을 업데이트하는 것은 매우 귀찮은 일이어서 잘 하려고 하지 않습니다. 따라서 어떻게 하면 업데이트를 최대한 줄이고 앱을 개선해나갈 것인지에 대해 고민해야 합니다.
Rich Text를 사용하면 앱을 업데이트되지 않더라도 TextView의 내용은 물론이고 스타일, 색상, 크기를 변경할 수 있으며 심지어 이미지까지 첨부할 수 있습니다.
단 1개의 TextView만으로 위와 같은 결과를 낼 수 있습니다. 이렇게 디테일한 내용을 앱 업데이트 없이도 적용할 수 있습니다.
Server Driven UI를 구현함으로써 이를 적용할 수 있으며, 서버에서 텍스트의 디테일한 attribute를 보내준다는 가정 하에 구현이 가능합니다. 예를 들어, 서버에서 [{"RED", "#FF0000"}, {"BLACK", "#000000"}]와 같이 데이터를 보내주면 클라이언트에서 이를 처리하여 'REDBLACK'을 그려내는 것이 server driven ui입니다.
설계 및 구현
먼저 앱에서 받게 될 DTO와 Entity를 정의하기 이전에, 어떻게 해야 하나의 TextView에 여러 스타일을 적용할 수 있는지를 알아야 합니다. 결론은 ‘Span’입니다.
https://developer.android.com/guide/topics/text/spans?hl=ko
위와 같은 스타일을 적용하기 위해서 아래와 같은 코드가 필요합니다.
val spannableStringBuilder = SpannableStringBuilder()
val text1 = "Spannable"
val spannableString1 = SpannableString(text1).apply {
setSpan(
ForegroundColorSpan(Color.parseColor("#3182F6"))),
0,
text1.text.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
setSpan(
StyleSpan(Typeface.BOLD),
0,
text1.text.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
spannableStringBuilder.append(spannableString1)
val text2 = "Text"
val spannableString2 = SpannableString(text1).apply {
setSpan(
ForegroundColorSpan(Color.parseColor("#3182F6"))),
0,
text2.text.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
setSpan(
RelativeSizeSpan(0.8f),
0,
text2.text.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
spannableStringBuilder.append(spannableString2)
binding.textView.text = spannableStringBuilder
이를 통해 다음과 같은 결론을 내립니다.
- DTO로 Entity의 리스트를 받고 순서에 맞게 적용시킨다.(SpannableStringBuilder에 Entity에 대응하는 SpannableString을 각각 append 한다.)
- Entity에는 다음과 같은 내용이 필요하다.
- text 내용
- style
- color
- size
- image(Optional, ImageSpan이라는 것이 따로 있어서 이미지 또한 넣을 수 있습니다.)
이에 따라 받게 될 json 데이터 예시를 정의하면 다음과 같습니다.
{
"richTexts": [
{
"image": "https://picsum.photos/200"
},
{
"text": " Spannable",
"style": "BOLD",
"color": "#3182F6",
"size": 1.2
},
{
"text": "Text",
"color": "#3182F6"
},
{
"text": "\\n"
},
{
"image": "https://picsum.photos/201"
},
{
"text": " 안녕하세요. "
},
{
"text": "\"홍길동\"",
"style": "BOLD",
"color": "#000000",
"size": 1.5
},
{
"text": "이라고 합니다.",
"style": "STRIKE_THROUGH"
}
]
}
또한 RichText Entity의 정의는 다음과 같습니다.
/**
* A entity to draw TextView
*/
data class RichText(
val text: String?,
val style: String?,
val color: String?,
val size: Float?,
val image: String?
)
멤버가 nullable하기 때문에 input으로 들어오지 않은 내용은 따로 처리를 하지 않게끔 설계할 수 있습니다.
그리고 style이 String이며 BOLD, STRIKE_THROUGH와 같이 정해진 바가 있으므로 다음과 같은 enum class를 쓰는 것이 적합합니다.
/**
* Defines text styles.
*/
enum class TextStyle(style: String) {
BOLD("BOLD"), UNDERLINE("UNDERLINE"), STRIKE_THROUGH("STRIKE_THROUGH")
}
이제 본격적으로 구현을 시작하기에 앞서, json 데이터를 받아서 richTextList로 변환하기까지의 과정은 이 글에선 중요하지 않으므로 다루지 않겠습니다.
List<RichText>가 들어오면 결과적으로 모든 SpannableString이 append된 SpannableStringBuilder를 받는 것이 목표입니다. 이를 위한 SpannableStringBuilderProvider 클래스는 대략 다음과 같을 것입니다.
class SpannableStringBuilderProvider {
companion object {
fun getSpannableStringBuilder(richTextList: List<RichText>): SpannableStringBuilder {
val spannableStringBuilder = SpannableStringBuilder()
// 어떤 작업을 해서 spannableStringBuilder를 완성시킨다...
return spannableStringBuilder
}
}
}
다음은 주석이 있는 부분을 채워나가야 합니다. 그 내용은 다음과 같을 것입니다.
richTextList.forEach { richText ->
val spannableString = SpannableString(richText.text)
richText.color?.let {
spannableString.setSpan(
ForegroundColorSpan(Color.parseColor(it)),
0,
richText.text?.length ?: 0,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
richText.style?.let {
val span = when (style) {
TextStyle.BOLD.name -> StyleSpan(Typeface.BOLD)
TextStyle.UNDERLINE.name -> UnderlineSpan()
TextStyle.STRIKE_THROUGH.name -> StrikethroughSpan()
else -> StyleSpan(Typeface.NORMAL)
}
spannableString.setSpan(
span,
0,
richText.text?.length ?: 0,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
richText.size?.let {
spannableString.setSpan(
RelativeSizeSpan(it),
0,
richText.text?.length ?: 0,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
richText.image?.let {
val bitmap = BitmapFactory.decodeStream(URL(image).openConnection().getInputStream())
spannableString.setSpan(
ImageSpan(context, bitmap),
0,
1,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
spannableStringBuilder.append(spannableString)
}
여기서 발생하는 문제는 다음과 같습니다.
- SpannableStringBuilder 클래스가 style을 기준으로 분기하여 스타일 Span을 생성하는 책임을 지고 있다.
- SpannableStringBuilder 클래스가 SpannableString을 완성시키는 책임까지 지고 있다.
먼저 1번 문제를 해결하기 위해서 StyleSpanFactory 클래스를 다음과 같이 작성했습니다.
class StyleSpanFactory {
companion object {
/**
* Returns a Span by [style].
* @param style A name of [TextStyle]
*/
fun createStyleSpan(style: String): Any {
return when (style) {
TextStyle.BOLD.name -> StyleSpan(Typeface.BOLD)
TextStyle.UNDERLINE.name -> UnderlineSpan()
TextStyle.STRIKE_THROUGH.name -> StrikethroughSpan()
else -> StyleSpan(Typeface.NORMAL)
}
}
}
}
이에 따라 기존 코드에서 스타일 Span을 적용하는 부분은 다음과 같이 변경됩니다.
richText.style?.let {
spannableString.setSpan(
createStyleSpan(it),
0,
richText.text?.length ?: 0,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
다음으로 2번 문제를 해결하기 위해 RichTextSpannableString 클래스를 작성하고자 합니다. 이 클래스에 richText를 넣으면 완성된 SpannableString이 나오게끔 설계하겠습니다. SpannableString의 Attribute가 너무 많으므로 생성 패턴 중 하나인 Builder 패턴을 사용하기에 적합한 것 같습니다.
클래스의 내용은 다음과 같습니다.
/**
* Its [Builder] returns a [SpannableString] whose style has changed by [RichText].
*/
class RichTextSpannableString(richText: RichText): SpannableString(richText.text ?: " ") {
class Builder(private val richText: RichText) {
private var spannableString = SpannableString(richText.text ?: " ")
fun setColor(color: String?): Builder {
color?.let {
spannableString.setSpan(
ForegroundColorSpan(Color.parseColor(it)),
0,
richText.text?.length ?: 0,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
return this
}
fun setStyle(style: String?): Builder {
style?.let {
spannableString.setSpan(
createStyleSpan(it),
0,
richText.text?.length ?: 0,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
return this
}
fun setSize(size: Float?): Builder {
size?.let {
spannableString.setSpan(
RelativeSizeSpan(it),
0,
richText.text?.length ?: 0,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
return this
}
fun setImage(image: String?, context: Context): Builder {
image?.let {
val bitmap = BitmapFactory.decodeStream(URL(image).openConnection().getInputStream())
spannableString.setSpan(
ImageSpan(context, bitmap),
0,
1,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
return this
}
fun build() = spannableString
}
}
또한 setSpan을 텍스트 범위에 대해 적용하는 코드가 겹치므로 아래와 같이 코드를 더 줄일 수 있었습니다.
/**
* Its [Builder] returns a [SpannableString] whose style has changed by [RichText].
*/
class RichTextSpannableString(richText: RichText): SpannableString(richText.text ?: " ") {
class Builder(private val richText: RichText) {
private var spannableString = SpannableString(richText.text ?: " ")
fun setColor(color: String?): Builder {
color?.let {
val span = ForegroundColorSpan(Color.parseColor(it))
setSpanForTextRange(span)
}
return this
}
private fun setSpanForTextRange(span: Any?) {
spannableString.setSpan(
span,
0, richText.text?.length ?: 0,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
fun setStyle(style: String?): Builder {
style?.let {
setSpanForTextRange(createStyleSpan(it))
}
return this
}
fun setSize(size: Float?): Builder {
size?.let {
val span = RelativeSizeSpan(it)
setSpanForTextRange(span)
}
return this
}
fun setImage(image: String?, context: Context): Builder {
image?.let {
val bitmap = BitmapFactory.decodeStream(URL(image).openConnection().getInputStream())
spannableString.setSpan(
ImageSpan(context, bitmap),
0,
1,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
return this
}
fun build() = spannableString
}
}
이에 따라 SpannableStringBuilderProvider의 기존 코드는 다음과 같이 줄어들게 됩니다.
fun getSpannableStringBuilder(richTextList: List<RichText>, context: Context): SpannableStringBuilder {
val spannableStringBuilder = SpannableStringBuilder()
richTextList.forEach { richText ->
val spannableString = RichTextSpannableString.Builder(richText)
.setColor(richText.color)
.setStyle(richText.style)
.setSize(richText.size)
.setImage(richText.image, context)
.build()
spannableStringBuilder.append(spannableString)
}
return spannableStringBuilder
}
이제 모든 구현이 끝났습니다. 이제 아래와 같은 코드 한 줄만으로 TextView를 다이나믹하게 변경할 수 있습니다.
textView.text = SpannableStringBuilderProvider.getSpannableStringBuilder(richTextList, context)
Github Repository
전체 코드는 아래 레포에서 보실 수 있습니다.
https://github.com/hanbikan/rich-text
'Android' 카테고리의 다른 글
모두의 PICK 서비스 Refactoring History (0) | 2022.09.09 |
---|---|
[Kotlin] Android Splash 로딩 속도를 29% 개선하다 (0) | 2022.08.19 |
[Kotlin] Android Offline Caching Using Room (0) | 2022.08.13 |
[Kotlin] ViewPager2에 custom indicator 적용하기(without TabLayout) (0) | 2022.08.06 |
[Trouble shooting] TabLayout customView에 setTypeface를 주면 indicator의 값이 잠시 0으로 리셋되는 문제 (0) | 2022.08.02 |