제 채팅 앱 프로젝트에서 메시지 배지 기능을 추가하게 되었습니다.
이 기능을 추가하면서 MeasureSpec에 관해 조금 해맸기 때문에 기록으로 남깁니다.
요구사항
카카오톡, 라인, iOS 등의 배지 형태는 대충 이런 모습입니다.
이처럼, 최소 1:1 비율로 유지하고 있다가, 글자 수가 많아지면 너비가 늘어나는 배지를 구현할 것입니다.
해결 과정
간단하게 TextView에 빨간 백그라운드 컬러를 입히고 layout_width에 wrap_content 속성을 줬습니다.
layout_width="wrap_content"
layout_height="wrap_content"
당연하게도 너비가 높이보다 짧은 상황이 존재하게됩니다. 여기서 layout_constraintDimensionRatio="1:1" 옵션을 주어서 해결 할 수 있지 않을까요?
layout_width="wrap_content"
layout_height="wrap_content"
layout_constraintDimensionRatio="1:1"
너비에 따라 높이도 1:1 비율을 유지하며 함께 변합니다. 높이를 고정시켜야 할까요?
높이를 고정한다고 해도 ratio가 1:1이 적용되면 비율은 유지해도 layout_width도 고정된 높이만큼 너비를 가지게 될 것이고 다음과 같은 상황이 발생합니다.
layout_width="0dp"
layout_height="20dp"
layout_constraintDimensionRatio="1:1"
저희가 필요한 것은 너비가 높이보다 짧으면 1:1 비율을 유지하고, 너비가 높이보다 길어지면 너비가 그대로 적용되는 것입니다.
Badge 커스텀 뷰
차라리 TextView를 상속받아 Badge라는 커스텀 뷰를 만들어, onMeasure를 오버라이딩하여 해결하기로 했습니다.
class Badge @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : androidx.appcompat.widget.AppCompatTextView(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val height = measuredHeight
val width = measuredWidth
// 측정된 width가 height보다 짧다면 width를 heightMeasureSpec에 맞추어 1:1 비율로 만든다.
if (width < height) {
super.onMeasure(heightMeasureSpec, heightMeasureSpec)
}
}
}
- 우선 내부 텍스트에 따라 너비가 달라지니, TextView의 onMeasure를 실행하여 너비와 높이를 측정합니다.
- 그 후, 측정된 너비가 측정된 높이보다 짧다면 onMeasure() 메서드의 widthMeasureSpec 파라미터에 heightMeasureSpec을 전달해 높이와 동일한 공간을 갖게하여 1:1 비율을 맞춥니다.
- XML은 이렇게 작성해줍니다.
<com.donxux.codate.presentation.view.custom.Badge
android:id="@+id/chat_message_count"
android:layout_width="wrap_content"
android:layout_height="20dp"
android:layout_marginTop="2dp"
android:background="@drawable/chat_count_background"
android:fontFamily="@font/suit_semi_bold"
android:gravity="center"
android:paddingHorizontal="4dp"
android:text="@{Integer.toString(chat.messageCount)}"
android:textAlignment="center"
android:textColor="@color/white"
android:textSize="12sp"
android:visibility="@{chat.messageCount == 0 ? View.INVISIBLE : View.VISIBLE}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/chat_date"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="@+id/chat_date"
app:layout_constraintTop_toBottomOf="@+id/chat_date"
tools:text="1" />
결과는 다음과 같습니다.
✋ 잠깐만요! heightMeasureSpec을 전달했다고 왜 너비와 높이가 같아지나요?
제가 만들어놓고 의문을 품고 해맸던 포인트입니다.
heightMeasureSpec은 뷰의 높이에 대한 공간 스펙입니다. 즉, 실제 뷰의 높이가 아니라는 뜻입니다. 뷰가 얼마만큼 높이를 가질 수 있느냐에 대한 스펙에 불과하죠
그렇다면 너비 공간 스펙은 높이 공간 스펙을 가졌지만 그래봤자 뷰가 최대 너비를 갖지 않는다면, 결과는 너비가 높이보다 짧은 상황이 분명히 발생할텐데 말이죠. 심지어 XML을 보면 layout_width는 wrap_content로 설정되어있습니다.
여기서 작은 실험을 하나 해보겠습니다.
layout_height를 wrap_content로 변경해보겠습니다.
<com.donxux.codate.presentation.view.custom.Badge
android:id="@+id/chat_message_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:background="@drawable/chat_count_background"
android:fontFamily="@font/suit_semi_bold"
android:gravity="center"
android:paddingHorizontal="4dp"
android:text="@{Integer.toString(chat.messageCount)}"
android:textAlignment="center"
android:textColor="@color/white"
android:textSize="12sp"
android:visibility="@{chat.messageCount == 0 ? View.INVISIBLE : View.VISIBLE}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/chat_date"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="@+id/chat_date"
app:layout_constraintTop_toBottomOf="@+id/chat_date"
tools:text="1" />
결과는 다음과 같습니다.
MeasureMode
처음처럼 너비가 높이보다 짧아진 상황이 발생했습니다.
여기서 알아야 될 것은 widthMeasureSpec과 heightMeasureSpec은 공간 사이즈에 대한 정보만 있는 것이 아닙니다.
MeasureSpec은 두 가지 데이터로 구성되어있습니다.
- size : 공간의 크기
- mode : size에 대한 모드
TextView의 onMeausre 메서드를 살펴보면 처음에 MeasureSpec에서 size와 mode 정보를 추출하는 것을 알 수 있습니다.
// TextView의 onMeasure
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
...
}
mode는 총 세 가지 모드가 있습니다.
- EXACTLY : View는 size에 지정된 정확한 크기를 가져야 합니다. 이는 뷰의 layout_width 또는 layout_height가 특정 dp 값이거나 match_parent로 설정되었을 때에 해당됩니다.
- AT_MOST : View는 지정된 size보다 같거나 작아야 합니다. 이는 뷰의 layout_width 또는 layout_height가 wrap_content로 설정되었을 때에 해당됩니다. View는 내용에 따라 크기를 결정하지만, 지정된 size보다는 커질 수 없습니다.
- UNSPECIFIED : size에 대한 제한이 없습니다. 즉, View는 원하는 만큼의 공간을 차지할 수 있습니다. 이는 보통 사용자 정의 View에서 볼 수 있으며, View는 이 경우에 필요한 만큼의 공간을 요청하거나 할당받습니다.
즉, widthMeasureSpec 파라미터 자리에 heightMeasureSpec을 전달할 때, 높이의 공간 사이즈 뿐만 아니라, 모드도 같이 전달된 것입니다.
작은 실험을 다시 살펴보면 layout_height가 wrap_content를 가졌습니다.
이는 너비에게 최대 20dp를 가질 수 있는 공간 스펙과 View의 너비는 20dp보다 같거나 작아야 한다는 데이터(AT_MOST)를 전달한 것입니다.
그래서 View의 너비는 최대 20dp를 가질 수 있지만, AT_MOST 모드가 적용되었기 때문에, 더 짧아질 수 있으면 짧아지는 것입니다.
좀 더 되돌아가서 layout_height가 20dp를 가졌을 때 상황을 봅시다.
측정된 너비가 높이보다 짧은 경우 너비에게 최대 20dp를 가질 수 있는 공간 스펙과 View의 너비가 20dp를 가져야한다는 데이터(EXACTLY)를 전달한 것입니다.
그래서 View의 너비가 짧다면 높이와 동일한 길이를 가지게되면서 1:1 비율이 유지된 것입니다.
결론
MeasureSpec은 size, mode 두 가지 데이터가 있다.