Hanbit the Developer

[Android] Rich Text: 앱 업데이트 없이 TextView의 내용과 스타일을 변경해보자 본문

Mobile/Android

[Android] Rich Text: 앱 업데이트 없이 TextView의 내용과 스타일을 변경해보자

hanbikan 2022. 8. 14. 14:46

배경

모바일 어플리케이션의 매우 치명적인 단점 중 하나는 바로 업데이트입니다. 사용자 입장에서 앱을 업데이트하는 것은 매우 귀찮은 일이어서 잘 하려고 하지 않습니다. 따라서 어떻게 하면 업데이트를 최대한 줄이고 앱을 개선해나갈 것인지에 대해 고민해야 합니다.

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

 

스팬  |  Android 개발자  |  Android Developers

스팬 스팬은 강력한 마크업 객체로 문자나 단락 수준에서 텍스트 스타일을 지정하는 데 사용할 수 있습니다. 텍스트 객체에 스팬을 연결하여 다양한 방식으로 텍스트를 변경할 수 있습니다. 예

developer.android.com

 

위와 같은 스타일을 적용하기 위해서 아래와 같은 코드가 필요합니다.

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

이를 통해 다음과 같은 결론을 내립니다.

  1. DTO로 Entity의 리스트를 받고 순서에 맞게 적용시킨다.(SpannableStringBuilder에 Entity에 대응하는 SpannableString을 각각 append 한다.)
  2. Entity에는 다음과 같은 내용이 필요하다.
    1. text 내용
    2. style
    3. color
    4. size
    5. 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)
}

여기서 발생하는 문제는 다음과 같습니다.

  1. SpannableStringBuilder 클래스가 style을 기준으로 분기하여 스타일 Span을 생성하는 책임을 지고 있다.
  2. 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