본문 바로가기
안드로이드 응용

Android ellipsize로 TextView 안에 더보기 버튼 집어넣기

by nongsusanmul 2024. 7. 3.
반응형

서비스하는 앱의 Detail 화면 등에 설명란이 있다면 "더보기" 기능이 요구되는 경우가 있다.

특히나 아래와 같은 디자인의 경우는 구현부가 생각보다 간단하지 않다.

 

디자인을 간단하게 살펴보면서 해결책을 추론해보자.

  • TextView가 임의의 위치에서 ellipsis 되어야하고,
  • 그 끝에 문자열 "더보기"가 아닌 버튼 더보기가 삽입되어야 한다.

임의의 위치에서 생략 기능을 적용시키려면 기본 ellipsize Attribute를 이용하는 것 보다 임의의 길이를 구해 잘라줄 수 있어야 하며, 더보기를 배치할 땐 button view를 넣어야해서 ClickableSpan을 이용할 수 없으니 직접 이미지를 배치해주어야 할 것 같다.

 

 

1. TextUtilsellipsize를 이용하면 돼요

임의의 길이(width: Float)를 지정하여 생략해주고 싶다면 android.text.TextUtils::ellipsize를 이용하면 된다.

 

TextUtils  |  Android Developers

 

developer.android.com

두 개의 ellipsize 함수 중, 여기서는 preserveLengthEllipsizeCallback이 필요하지 않기 때문에 더 간략한 경우를 사용한다. 필요 시 공식문서를 참조하여 사용하면 된다.

아래 코드에서는 동적인 요청을 해야하는 요구사항이 있고 여러 화면에서 재사용되기 때문에 Custom View를 정의했으나, 요구사항에 따라 원하는 방식으로 코드를 작성하면 된다. 중요한 것은 함수 내부 내용이기 때문에!

class TruncationTextView(
    context: Context,
    attrs: AttributeSet? = null,
) : AppCompatTextView(context, attrs) {

    fun ellipsize(moreWidth: Float, usableLine: Int, onFinish: () -> Unit) {
        // do some calculation
        // ...

        text = TextUtils.ellipsize(
            text, // 기존 text
            paint, // TextView의 paint
            cutLength, // 연산된 길이
            TextUtils.TruncateAt.END, // 생략 유형
        ).toString()

        onFinish()
    }
    // ...
}

 

 

2. TextView layout 멤버 변수를 이용하면 돼요

임의의 길이를 구하려면 android.text.Layout 객체를 이용하면 된다.

 

Layout  |  Android Developers

 

developer.android.com

그려질 layout을 동적으로 얻는 방법은 아래와 같다. StaticLayout의 builder 패턴을 이용해서 생성이 가능하다.

val lineLimit = 3

val maximumLineWidth = MeasureSpec.getSize(width) - compoundPaddingStart - compoundPaddingEnd
val staticLayout = StaticLayout.Builder
    .obtain(originalText, 0, originalText.length, paint, maximumLineWidth)
    .setMaxLines(lineLimit)
    .setIncludePad(false)
    .setEllipsize(TextUtils.TruncateAt.END)
    .setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
    .build()

 

그러나 나의 요구사항 에서는 text 속성이 특정 조건에 따라 계속 변화하여, 더보기 버튼이 생겼다 없어졌다 할 수 있어서 Fragment 단에서 아래와 같은 GlobalLayoutListener를 정의해 준 상태이다.

그래서 이미 그려졌다는 것을 전제하기 때문에 layout을 동적으로 얻을 필요가 없었다.

private val contentLayoutListener = object : OnGlobalLayoutListener {
    fun removeListener() { /* ... */ }

    override fun onGlobalLayout() {
        with(binding.tvContent) {
            // 실제로 ellipsis가 일어나는 순간에 호출
            if (layout != null && layout.getEllipsisCount(lineCount - 1) > 0) {
                binding.btnMore.isVisible = true

                ellipsize(
                    moreWidth = binding.btnMore.measureWidth,
                    usableLine = if (isLongContent) 3 else 2,
                    onFinish = ::removeListener,
                )
            }
        }
    }
}

 

실제 TruncationTextView의 함수 내용도 아래처럼 간단해졌다.

class TruncationTextView(
    context: Context,
    attrs: AttributeSet? = null,
) : AppCompatTextView(context, attrs) {

    fun ellipsize(moreWidth: Float, usableLine: Int, onFinish: () -> Unit) {
    	// 각 줄의 width의 총합
        val sumOfLw = (0 until layout.lineCount)
            .sumOf { layout.getLineWidth(it).toDouble() }
            .toFloat()
        // 마지막 줄의 width
        val lastLineWidth = layout.getLineWidth(usableLine - 1)

        // 화면에 표시된 text의 총 길이
        val cutLength = sumOfLw - (lastLineWidth - (maxWidth - moreWidth))

        text = TextUtils.ellipsize(
            text,
            paint,
            cutLength,
            TextUtils.TruncateAt.END,
        ).toString()

        onFinish()
    }
    // ...
}

 

화면에 표시된 text의 총 길이인 cutLength 계산법에 대해 알아보자. 식으로 정리해봤다.

126은 더보기 버튼의 measured width 값이다.

x = text 길이 총합 - (마지막 줄 - (뷰width - more버튼 width))

왠지 각 줄의 길이를 구하지 않고 width * 3 해주면 되는 거 아닌가 생각할 수도 있지만, TextUtils.ellipsize가 요구하는 avail 값은 화면에서 차지하는 text실제width이므로 글꼴과 word-break 정책에 의해 표시되는 정확한 width를 제공해야한다.

참고로 더보기 버튼에 좌우 마진이 더 있다면 추가로 계산해줘야 한다.

 

 

3. 뷰 배치 예제 코드

ConstraintLayout으로 배치하였기 때문에 TextView 위에 쌓는 형태로 구현했다.

▸ 뷰 배치 (예제 코드)

더보기
<androidx.constraintlayout.widget.ConstraintLayout
    android:id="@+id/cl_content"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <androidx.appcompat.widget.AppCompatTextView
    	android:id="@+id/tv_content"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:ellipsize="end"
        android:includeFontPadding="false"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

    <androidx.appcompat.widget.AppCompatTextView
    	android:id="@+id/btn_more"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:paddingVertical="3dp"
        android:paddingHorizontal="7dp"
        android:text="@string/content_more"
        app:layout_constraintEnd_toEndOf="@id/tv_content"
        app:layout_constraintBottom_toBottomOf="@id/tv_content" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

 

4. 참고 자료

https://careers.wolt.com/en/blog/tech/expandable-text-with-read-more-action-in-android-not-an-easy-task

반응형