펼쳐지고 닫히는 TextView를 원하시나요? (like. Youtube)

킹정인·2023년 5월 8일
1

긴~글을 줄여서 '더보기' 버튼과 함께 표시해야 한다구요?
TextView 두개를 겹쳐서 구현하기엔 너무 짜치다구요~~?

🔥🔥🔥 소개합니다! ReadMoreView 🔥🔥🔥

ReadMoreView 는 TextView 를 상속받아 만들었어요!
사용법도 무척 간단하답니다~

백문이 불여일견.. 먼저 보시죠

ReadMoreView 의 요구사항은 아래와 같습니다.

  • '닫힘' 상태 글의 maxLines 를 지정하여 글의 끝에 말줄임(ellipsize) 표시
  • 글 끝에 '닫힘', '열림' 상태 변경 버튼 추가

구현 초기

처음에는 아래와 같은 xml 레이아웃을 inflate 하는 간단한 Custom View 를 구상했습니다.

<layout
    xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <import type="android.view.View"/>
        <variable
            name="text"
            type="String" />
        <variable
            name="expand"
            type="Boolean" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/tvCollapse"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:visibility="@{expand ? View.GONE : View.VISIBLE}"
            android:ellipsize="end"
            android:maxLines="3"
            android:text="@{text}" />

        <TextView
            android:id="@+id/tvExpand"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:visibility="@{expand ? View.VISIBLE : View.GONE}"
            android:text="@{text}" />

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>
  • '닫힘', '열림' 상태는 각각의 TextView 를 통해 표시하고, visibility 를 변경하여 구현
  • '닫힘', '열림' 버튼은 Spannable 을 통해 구현

그러나 이 방법은 TextView 를 상속받아 구현한게 아니므로 textSize 등의 속성을 직접 정의하고 각 View 에 연결해줘야 하는 번거로움이 있었고..🥲
직접 구현을 해보고 싶은 열정이 더 컸으므로.. 직접 View 를 상속받아 만들게 되었습니다!🔥

구현

ReadMoreView 는 상속받을 수 없는 TextView 를 대신해 AppCompatTextView 를 상속받아 구현되었습니다.
따라서, FontFamily 등의 TextView 속성을 그대로 사용할 수 있습니다. (간편하죠? 😄)
'닫힘', '열림' 버튼의 색상등 속성도 변경할 수 있구요..

ReadMoreView 의 동작 단계는 아래와 같습니다.

  1. View.onMeausre 에서 Text 가 입력될 너비 구하기
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val givenWidth = MeasureSpec.getSize(widthMeasureSpec)
        val textWidth = givenWidth - compoundPaddingStart - compoundPaddingEnd
        if (textWidth == oldTextWidth) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec)
            return
        }

        updateDisplayText(textWidth)

        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    }

MeasureSpec.getSize() 로 너비를 가져와, Padding 을 제외한 영역의 너비를 updateDisplayText 에 전달하여 다음 단계를 진행합니다.

  1. 구한 너비와 MaxLine 및 StaticLayout 를 활용하여 닫힘 상태의 String 구하기
private fun updateDisplayText(textWidth: Int = measuredWidth - compoundPaddingStart - compoundPaddingEnd) {
		//... 생략

        val lastEllipsizeWidth = if (btnLocation is BtnLocation.NextLine) {
            0
        } else {
            getEllipsizeWidth() + getColBtnTextWidth(expandBtnText, btnSizePx)
        }

        val collapseLayout = getCollapseStaticLayout(originalText?: "", textWidth, colMaxLine, lastEllipsizeWidth)
        val collapseContentText = "${collapseLayout.text}"

		// ...생략
    }
private fun getCollapseStaticLayout(text: CharSequence, textWidth: Int, maxLine: Int, ellipsizeWidth: Int): StaticLayout {
        val ellipsizedWidth = textWidth - ellipsizeWidth
        return StaticLayout.Builder
        	.obtain(text, 0, text.length, paint, textWidth.coerceAtLeast(0))
	        .setEllipsize(TextUtils.TruncateAt.END)
	        .setEllipsizedWidth(ellipsizedWidth)
	        .setMaxLines(maxLine)
	        .setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
	        .build()
		// ... 생략
    }

StaticLayout 가 생소하신 분도 있으실텐데요, Canvas 에 텍스트를 그려줄때 많이 사용합니다.
ReadMoreView 에서는 줄바꿈 처리와 ellipszie 처리된 String 을 얻기위해 사용했습니다!

getCollapseStaticLayout() 메서드에 원본 String 과 삽입될 너비와 MaxLine 을 전달하여 ellipsize 가 적용된 StaticLayout 을 얻고, text 프로퍼티로 String 을 얻습니다.

  1. Spannable 을 활용하여 글 끝에 '닫힘', '열림' 버튼 삽입하기
private fun getContentSpannable(content: String, btnText: String, isExpandable: Boolean): SpannableStringBuilder {
        return SpannableStringBuilder().apply {
            append(content)

            if (btnText.isEmpty() || !isExpandable) {
                return@apply
            }

            if (btnLocation is BtnLocation.NextLine) {
                append("\n")
            }

            append(btnText)

            val btnStart = this.length - btnText.length
            val btnEnd = btnStart + btnText.length

            setSpan(UnderlineSpan(), btnStart, btnEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
            setSpan(
                AbsoluteSizeSpan(btnSizePx),
                btnStart,
                btnEnd,
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
            )
            setSpan(object : ClickableSpan() {
                override fun onClick(widget: View) {
                    toggle()
                }

                override fun updateDrawState(ds: TextPaint) {
                    super.updateDrawState(ds)
                    ds.color = btnColor
                }
            }, btnStart, btnEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

        }
    }

getContentSpannable() 메서드는 입력된 String 과 버튼 텍스트를 입력받아, 기 지정된 버튼 Text의 속성을 적용하여 SpannableString 을 반환합니다.

  1. TextView.setText 메서드로 현재 상태의 String 삽입하기
    private fun updateDisplayText(textWidth: Int = measuredWidth - compoundPaddingStart - compoundPaddingEnd) {
		// ... 생략

        val collapseSpannable = getContentSpannable(ellipsizedOrNotText.toString(), expandBtnText, isExpandable)

        text = when (state) {
            MoreState.COLLAPSED -> collapseSpannable
            MoreState.EXPANDED -> expandSpannable
        }

		// ... 생략
    }

현재 '열림' 상태인 경우 expandSpannable 를, '닫힘' 상태인 경우 collapseSpannableTextView.setText() 를 통해 TextView 에 삽입합니다.

어떤가요? StaticLayout 을 활용한 부분이 조금 생소할 뿐 간단한 구현 내용입니다👏👏👏

종속성 및 사용법!

ReadMoreView 종속성 설정 방법, 여러 기능과 사용법, 자세한 구현 내용이 궁금하다면 아래 github 를 참고해주세요~!
📎 ReadMoreView github 링크!!📎

유용하셨다면 Star 도...🌠

profile
🕶안드로이드 개발자입니다! 🕶

0개의 댓글