[Android] TextView 글자 기준 자동 줄바꿈 구현하기 : CustomAutoLineBreakTextView
오늘의 안드로이드 TextView의 줄바꿈 정책을 알아보고, 사용하면서 느꼈던 부족한 점을 개선하기 위해 커스텀했던 코드를 알아보자.
최대 줄 수 설정
먼저 TextView는 지정된 width 안에서 자동으로 줄바꿈을 해주며, 이때 최대 줄 수는 maxLines로 지정한다.
말줄임표 설정
길이가 긴 텍스트를 줄임표(…)로 처리하고 싶다면 ellipsize 속성을 사용한다.
- end: 텍스트가 길 경우 끝부분에 줄임표를 표시한다.
- start: 앞부분에 줄임표를 표시한다.
- middle: 중간에 줄임표를 표시하여 앞뒤 텍스트를 보여준다.
- marquee: 스크롤 형태로 텍스트를 자동으로 넘겨 한 줄에 긴 문장을 표현할 때 유용하다. 흐르듯이 보인다.
줄바꿈 방식 설정
android:breakStrategy 속성을 이용해서 줄바꿈 방식을 설정할 수 있다. 그 속성은 세부적으로 다음과 같다.
- simple: 줄바꿈 포인트를 하나씩 보면서 이 줄에 넣을 수 있는 최대한의 텍스트를 집어넣고 나머지는 다음줄로 넘긴다.
- high_quality: TeX 의 LineBreaking 알고리즘을 구현하고 있는데 해당 알고리즘은 hyphen 을 활용하여 전반적으로 텍스트 한줄 한줄의 길이가 비슷하도록 최적화 시켜주어 가독성을 높여준다
- balanced: 화면에 적합한 위치에서 줄바꿈을 한다. simple과 high_quality 사이의 균형을 제공한다.
안드로이드에서 텍스트 줄바꿈 처리 시, 각 언어의 유니코드 규칙에 따라 줄을 끊을 수 있는 지점(이하 중단점)이 다르게 적용된다. 이를 통해 언어별 가독성을 유지하면서 자연스러운 줄바꿈을 구현할 수 있다.
언어별 중단점 차이
- 영어
영어 텍스트의 경우, 한 단어 중간에서 줄이 끊어지지 않도록 한다. 줄바꿈이 필요한 경우 단어 단위로 처리하며, 만약 한 단어가 너무 길어 다음 줄로 넘어가야 할 경우 하이픈(-) 표시를 추가하여 단어가 중간에서 잘려 있다는 것을 시각적으로 알린다. 예를 들어 breakable이라는 단어가 두 줄에 걸쳐 표현되어야 한다면 break-와 able로 줄바꿈하며 단어의 의미를 보존한다. - 한국어
한국어는 한글 음절이 글자 단위로 독립된 의미를 가질 수 있어 한 음절 단위로 줄바꿈 처리가 가능하다. 예를 들어, “안녕하세요”라는 텍스트가 줄을 넘기게 될 경우 안녕-하세요와 같이 단어 중간에서 끊는 것이 아니라 안녕과 하세요를 별개 줄에 배치할 수 있다. 이는 음절이 조합되어 단어를 이루는 한국어의 언어적 특성을 반영한 것이다.
그런데, TextView를 구현하다보면 이러한 언어적 특성의 중단점과 상관없이 정해진 너비에 꽉차게 보여주는 것을 요구받을 수 있다. 실제로 이번 인턴생활을 하면서 해당 사항을 요구받았다. 이럴 때는 안드로이드에서 제공하는 전략이 통하지 않는 경우가 있다.
CustomLineBreakTextView
안드로이드에서 텍스트 줄바꿈 처리 시, 각 언어의. 유니 코드 규칙? ! ㅋㅋㅋㅋ에 따라 줄을 끊을 수 있는 지점(이하 중단점)이 다르게 적용된다.
이를 통해 언어별 가독성을 유지하면서 자연스러운 줄바꿈을 구현할 수 있다.
다음과 같은 텍스트를 노출시켜야 한다고 해보자. 좀 정돈된 문장보다는 사용자가 다양한 문장 부호나 특수문자, 초성을 작성하는 경우를 예로 가져와봤다.
TextView + Simple Strategy
일반TextView에 simple 전략을 반영한 경우이다. 보이는 것처럼, '규' 옆에 분명 '칙'이 들어갈 자리가 있는데도 자동 줄바꿈이 되고 있는 걸 볼 수 있다. 이러한 경우 내가 충족해야할 요구사항을 만족할 수 없었다.
그렇다면 안드로이드에서 제공하는 내부적인 알고리즘을 사용한 줄바꿈 전략이 아닌,
말그대로 글자 한자씩을 쪼개 너비를 초과하는 경우 줄바꿈은 어떻게 구현할까 ? 나는 이를 커스텀을 통해 구현했다.
class CustomLineBreakTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : AppCompatTextView(context, attrs) {
private var mAvailableWidth = 0
private lateinit var mPaint: Paint
private var mCutStr: MutableList<String> = mutableListOf()
private fun setTextInfo(text: String, textWidth: Int): Int {
mPaint = paint.apply {
color = currentTextColor
textSize = textSize
}
var mTextHeight = paddingTop + paddingBottom
mAvailableWidth = textWidth - paddingLeft - paddingRight
if (!mCutStr.isNullOrEmpty()) {
mCutStr.clear()
}
var remainingText = text
var end: Int
var lineCount = 0
do {
// 글자가 width 넘어가는지 체크
end = mPaint.breakText(remainingText, true, mAvailableWidth.toFloat(), null)
if (end > 0) {
val line = remainingText.substring(0, end)
lineCount++
remainingText = remainingText.substring(end)
val fontMetrics = mPaint.fontMetrics
val lineHeight = (fontMetrics.descent - fontMetrics.ascent).toInt()
mTextHeight += lineHeight
if (lineCount == maxLines) {
if (remainingText.isNotEmpty()) {
if (line.length > 2) {
mCutStr.add(line.substring(0, line.length - 2) + "...")
} else {
mCutStr.add(line + "...")
}
} else {
if (mAvailableWidth - mPaint.measureText(line) >= mPaint.measureText("...")) {
mCutStr.add(line)
}
}
break
} else {
mCutStr.add(line)
}
}
} while (end > 0)
mTextHeight += paddingTop + paddingBottom
return mTextHeight
}
override fun onDraw(canvas: Canvas) {
if (mCutStr.isEmpty()) return
var height = paddingTop.toFloat() - mPaint.ascent()
for (text in mCutStr) {
canvas.drawText(text, paddingLeft.toFloat(), height, mPaint)
height += mPaint.descent() - mPaint.ascent()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val parentWidth = MeasureSpec.getSize(widthMeasureSpec)
var parentHeight = MeasureSpec.getSize(heightMeasureSpec)
val height = setTextInfo(text.toString(), parentWidth)
if (parentHeight == 0) {
parentHeight = height
}
setMeasuredDimension(parentWidth, parentHeight)
}
override fun onTextChanged(text: CharSequence, start: Int, before: Int, after: Int) {
setTextInfo(text.toString(), width)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
if (w != oldw) {
setTextInfo(text.toString(), w)
}
}
}
위 클래스는 텍스트가 TextView 너비를 초과하는 경우 지정된 줄 수만큼 줄바꿈하여, 텍스트가 화면에 맞도록 처리한다. 나는 maxLine 제한과 그에 따른 말줄임표 표기가 요구되었기 때문에, 추가적으로 처리한 코드이다. maxLines에 맞춰 줄바꿈을 하고 생략 표시에 대한 추가 처리를 하며, onMeasure와 onDraw를 통해 최종적으로 줄바꿈이 반영된 텍스트를 화면에 표시하는 방식으로 동작한다.
위와 같은 커스텀 텍스트뷰를 적용하면, 원하는 줄바꿈 형식을 구현할 수 있다.
참고자료
Android TextView 톺아보기: Text Layout Measuring
문자열 (CharSequence) 은 독자의 가독성을 고려하여 문자를 어떻게 배열해야할지 많은 부분을 고민해야 합니다.
san5g.medium.com