상세 컨텐츠

본문 제목

이모지를 포함한 글자수를 세는 방법 🤬

Android

by Android_박동민 2024. 1. 2. 01:31

본문

안녕하세요 고잉고잉 Android 팀 박동민입니다.

 

이번 글은 저희 어플리케이션의 기능을 구현하던 중 겪은 트러블과 그 해결방법에 대한 글입니다! 같은 문제를 겪으시는 분들에게도 도움이됐으면 좋겠네요 :)

 

늘 그렇듯 댓글로 의견 공유는 환영입니다! 바로 가시죠

 

 

😥 Trouble : 이모지는 글자수가 다르다...?

우리 프로젝트 중 일부 UI

우리 프로젝트에서의 화면 중 일부다.

 

언뜻 보기에는 아무 문제가 없는 지극히 자연스러운 화면이다.

그런데 구현 조건 중 하나가 "이모지가 들어갈 수 있다" 이다.

 

처음에는 아무 생각없이 andorid xml에서 기본적으로 제공해주는 counterEnabled와 counterMaxLength를 사용해서 구현하려 했다.

지극히 정상적인 장면

 

 

이걸 기반으로 코드를 다 작성하고 여러 케이스를 테스트해보기 시작했다.

이때까진 순조로웠다... 이모지를 만나기 전까지...

?!?!?!?!?!?!

 

이모지 단 하나에 11글자를 차지해버렸다.

이게 무슨일인지 한참 뒤져보니 이모지는 utf8mb4 인코딩 포멧이라 일반적인 문자와 달랐다.

 

그래서 글자 수를 측정하는 방식에도 차이가 있고, 이를 직접 계산해줘야 하는 것이다.

 

 

👍 Solve : 글자수 세는 함수 커스텀!

public abstract class BreakIterator
extends Object
implements Cloneable

The BreakIterator class implements methods for finding the location of boundaries in text. Instances of BreakIterator maintain a current position and scan over text returning the index of characters where boundaries occur. Internally, BreakIterator scans text using a CharacterIterator, and is thus able to scan text held by any object implementing that protocol. A StringCharacterIterator is used to scan String objects passed to setText.

You use the factory methods provided by this class to create instances of various types of break iterators. In particular, use getWordInstance, getLineInstance, getSentenceInstance, and getCharacterInstance to create BreakIterators that perform word, line, sentence, and character boundary analysis respectively. A single BreakIterator can work only on one unit (word, line, sentence, and so on). You must use a different iterator for each unit boundary analysis you wish to perform.

- oracle의 설명 중 일부 발췌 -

 

 

 

열심히 찾다보니 java 에는 BreakIterator라는 클래스가 있다. 이 친구의 기능을 간단하게 설명하면 텍스트의 경계 위치를 찾는 메서드를 구현한다고 한다. 그래서 이 클래스를 활용해서 한칸한칸 읽으며 길이를 측정하는 방법을 사용했다.

// 이모지 포함 글자 수 세는 함수
    private fun getGraphemeLength(value: String?): Int {
        BREAK_ITERATOR.setText(value)

        var count = 0
        while (BREAK_ITERATOR.next() != BreakIterator.DONE) {
            count++
        }

        return count
    }

 

이 함수를 사용해서 글자수를 세면 이모지도 1글자로 취급이 된다.

 

 

이 함수를 활용해서 글자수도 측정을 하고, Max 글자 수를 넘으면 더이상 작성되지 않는 함수도 만들어서 연결했다.

viewModel.nowNameLength.observe(this) { length ->
    val maxNameLength = viewModel.getMaxNameLen()
    
    if (length > maxNameLength) {
        binding.etOnboardingProfileSettingName.apply {
            setText(text?.subSequence(0, maxNameLength))
            setSelection(maxNameLength)
        }
    }
}

 

 

이를 활용하여 UI를 구현하려고 했다.

그런데 이번에 또 다른 함정이 있었다.

 

counterEnabled을 커스텀하는 방법을 모르겠다는 것...

그래서 고민을 하던 중 어차피 counterEnabled이 있는 TextInputLayout은 hint가 위로 올라가기때문에 우리 UI와는 맞지 않는 다는 것을 깨닫고 내가 구현해서 EditText 밑에 붙이기로 했다.

 

순조롭게 EditText도 커스텀하고, textCounter도 만들어서 붙였다.

 

 

😱 2nd Trouble : 어떻게 error일때 처리를 하지...?

EditText에도 error처리는 있다. 하지만, error를 실행시키는 순간 내가 원하는 모양이 아닌 좌측 아이콘과 함께 에러문구가 나온다.

 

요구사항 / 구현된 모습

그래서 2차 멘붕이 왔다.

왼쪽과 같은 모양은 내가 버렸던 TextInputLayout에서 구현되는 모습이다. 하지만 TextInputLayout으로 돌아간다면 hint의 위치 이동도 애매해지고, cunterEnabeld의 커스텀도 힘들어진다.

 

그래서 다시 엎고 TextInputLayout으로 리펙토링 할까 고민하다가 여기까지 온 김에 다 구현해보자 라는 생각에 하나씩 구현하기 시작했다.

 

 

💪 2nd Solve : 모든 경우를 처리해주자

먼저 외곽 테두리를 만들어줬다.

 

// sel_rounded_corner_edit_text
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_focused="false">
        <shape>
            <stroke android:width="2dp"/>
            <stroke android:color="@color/gray_200"/>
            <corners android:radius="4dp" />
        </shape>
    </item>
    <item android:state_focused="true">
        <shape>
            <stroke android:width="2dp"/>
            <stroke android:color="@color/gray_700"/>
            <corners android:radius="4dp" />
        </shape>
    </item>
</selector>

이걸 만들고 생각해보니 포커스 전 후 색상 변경만 가능하고 error일때와 아닐때는 불가능했다.

그래서 고민하다 내린 결론은 error일때의 테두리를 하나 더 만들어두고 error에따라 갈아까지는 생각이다.

 

// sel_rounded_corner_edit_text_error
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_focused="false">
        <shape>
            <stroke android:width="2dp"/>
            <stroke android:color="@color/red_500"/>
            <corners android:radius="4dp" />
        </shape>
    </item>
</selector>

색상만 바뀐 테두리를 하나 더 만들고, 아래와 같이 databinding을 활용해서 전환처리를 해줬다.

 

android:background="@{viewModel.isNameAvailable() == NameState.Blank ? @drawable/sel_rounded_corner_edit_text_error : @drawable/sel_rounded_corner_edit_text}"

isNameAvaildable이라는 변수에 NameState라는 enum class를 타입으로 넣어줬다.

enum class NameState {
    Empty, Success, Blank
}

Empty : 값이 비어있을 경우 -> 에러 아님

Success : 값이 정상적일 경우 -> 다음페이지 버튼 활성화

Blank : 스페이스바만 있는 경우 -> 에러 발생

 

그렇게 하여 Blank값일 경우 sel_rounded_corner_edit_text_error를 적용, 그 외에는 sel_rounded_corner_edit_text를 적용시키는 방식으로 사용했다.

 

아래 에러메시지와 textCounter또한 같은 방법을 적용하려 했지만 또 다른 문제가 있었다!

 

 

 

😫 3rd Trouble : Focus Control

위와 같은 방식으로 적용하니 또 다른 문제가 생겼다.

EditText의 경우 클릭되어 글자를 입력할 땐 검은색, 클릭되어있지 않을땐 회색을 나타내야한다.

그래서 setOnFocusChangeListener를 활용하여 isFocus일경우 검은색, 아닐땐 회색을 넣었다.

binding.etOnboardingProfileSettingName.setOnFocusChangeListener { _, hasFocus ->
    if (hasFocus) {
        setNameCounterColor(R.color.gray_700)
    } else {
        setNameCounterColor(R.color.gray_200)
    }
}

이렇게 설정한 후 위와 똑같이 dataBinding으로 색상을 변경하게 하였다.

android:textColor="@{viewModel.isNameAvailable() == NameState.Blank ? @color/red_500 : @color/gray_700}"

 

그랬더니 에러인 상태로 focus가 변하니까 회색으로 변하더라... 원래 에러 색 말고...

 

하아.... 진짜 때려칠뻔

 

 

🤬 3rd Solution : Focus Change with error observe

그래서 편하게 생각했다.

포커스가 변할때 마다 색상판정을 할 때 에러인지도 검사하자!

binding.etOnboardingProfileSettingName.setOnFocusChangeListener { _, hasFocus ->
    if (hasFocus) {
        setNameCounterColor(R.color.gray_700)
    } else {
        setNameCounterColor(R.color.gray_200)
    }
    if (viewModel.isNameAvailable.value == NameState.Blank) {
        setNameCounterColor(R.color.red_500)
    }
}

위 코드가 절대 좋은 코드는 아니다.

 

하지만, 구현하기 위해서 발버둥친 코드고 결과적으로 잘 돌아가긴 한다...

 

 

 

 

 

좋은 결과물은 나왔다.

하지만 좋은 코드라고 장담은 못하겠다.

 

분명히 더 좋은 코드가 있을 것이라고 생각한다.

내가 원하는 UI와 기능을 위해서 노력한 내 시간이 낭비되었다고는 생각하지 않는다.

이 과정에서 처음보는 BreakIterator라는 클래스도 알게됐고 여러 컨트롤 방식들도 알게 됐다.

 

원하는 기능을 위해서 진득하게 고민해보고 구현해보는 것도 오랜만이었다.

요즘은 안드로이드를 배우는 단계다보니 구글링하고 찾으면 적용하고의 반복이었다.

이번 기회에 고민하는 법과 생각을 코드로 옮기는 법을 오랜만에 실천해볼 수 있었다.

 

현재는 단기간에 프로덕트를 만드는 중이라 이대로 넘어갈 예정이다. 하지만 추후 시간이 여유로워 진다면 이 부분은 리펙토링 해보고 싶다.

프로젝트를 완성한 시기의 나라면 한층 더 성장하여 해낼 수 있을 것이라고 생각한다.

관련글 더보기