서비스하는 앱의 Detail 화면 등에 설명란이 있다면 "더보기" 기능이 요구되는 경우가 있다.
특히나 아래와 같은 디자인의 경우는 구현부가 생각보다 간단하지 않다.
디자인을 간단하게 살펴보면서 해결책을 추론해보자.
- TextView가 임의의 위치에서 ellipsis 되어야하고,
- 그 끝에 문자열 "더보기"가 아닌 버튼 더보기가 삽입되어야 한다.
임의의 위치에서 생략 기능을 적용시키려면 기본 ellipsize Attribute를 이용하는 것 보다 임의의 길이를 구해 잘라줄 수 있어야 하며, 더보기를 배치할 땐 button view를 넣어야해서 ClickableSpan을 이용할 수 없으니 직접 이미지를 배치해주어야 할 것 같다.
1. TextUtils의 ellipsize를 이용하면 돼요
임의의 길이(width: Float)를 지정하여 생략해주고 싶다면 android.text.TextUtils::ellipsize를 이용하면 된다.
TextUtils | Android Developers
developer.android.com
두 개의 ellipsize 함수 중, 여기서는 preserveLength와 EllipsizeCallback이 필요하지 않기 때문에 더 간략한 경우를 사용한다. 필요 시 공식문서를 참조하여 사용하면 된다.
아래 코드에서는 동적인 요청을 해야하는 요구사항이 있고 여러 화면에서 재사용되기 때문에 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 값이다.
왠지 각 줄의 길이를 구하지 않고 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. 참고 자료
'안드로이드 응용' 카테고리의 다른 글
Android에서 Coroutine Flow로 네트워크 변화 모니터링하기 (0) | 2024.07.02 |
---|