본문 바로가기
Android/기록

채팅 메시지 Badge 커스텀 뷰 구현 (Feat. MeasureSpec)

by DONXUX 2023. 7. 1.

질척

제 채팅 앱 프로젝트에서 메시지 배지 기능을 추가하게 되었습니다.

이 기능을 추가하면서 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 : Viewsize에 지정된 정확한 크기를 가져야 합니다. 이는 뷰의 layout_width 또는 layout_height가 특정 dp 값이거나 match_parent로 설정되었을 때에 해당됩니다.
  • AT_MOST : View는 지정된 size보다 같거나 작아야 합니다. 이는 뷰의 layout_width 또는 layout_heightwrap_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 두 가지 데이터가 있다.