본문 바로가기

MoneyMong

TextField 의 VisualTransformation 를 사용해 입맛대로 보여주자

동작 화면

요구사항은 다음과 같다.

  • 앞에 symbol(+, -) 을 붙일 수 있다.
  • 맨 뒤에 "원"이 붙는다.
  • 커서가 "원" 뒤로 올 순 없다.
  • 3 자리마다 "," 가 붙는다.
  • 커서가 쉼표 앞 숫자에 위치할 땐 쉼표 뒤에 위치시킨다
    | 쉼표 앞 숫자 뒤에 위치 시키면 쉼표가 커서에 가려져서 보이지 않는다.
    | 사용에 있어서 중요한 부분은 아니지만 쉼표 하나도 정보인데 가려지는 게 마음에 들지 않았다.

 

VisualTransformation

위와 같이 TextField 에서 실제 Text 와 사용자에게 보여지는 Text 를 다르게 하기 위해선 VisualTransformation 을 사용하면 된다.

 

https://developer.android.com/reference/kotlin/androidx/compose/ui/text/input/VisualTransformation

 

VisualTransformation  |  Android Developers

androidx.compose.desktop.ui.tooling.preview

developer.android.com

 

 

VisualTransformation 을 구현하게 되면 filter 함수를 override 해야한다.

 

해당 함수는 TransformedText 타입을 반환하게 되는데

해당 클래스는 아래와 같이 text , offsetMapping 변수를 가지게 된다.

 

우리의 역할은 변형된 TextoffsetMapping 을 구현해서 반환값에 넘겨주면 된다.

 

구현

1. 변형된 Text 를 구하자.

기존 Text 를 원하는 형태로 변형하자
val number = text.text
var numberWithComma = ""
for (i in number.indices) {
    numberWithComma += number[i]
    if (i != number.length - 1 && (number.length - 1 - i) % 3 == 0) {
        numberWithComma += ","
    }
}
val out =
    if (numberWithComma.isEmpty()) numberWithComma else "${type.symbol}${numberWithComma}원"

 

 

text.text: 입력된 숫자

numberWithComma: ","  가 적절한 위치에 포함된 숫자 String

out: 변형된(원하는) 형태의 String

    ▪ TransformedText 에 들어갈 text

 

ex).
text.text == 1234567

 numberWithComma == 1,234,567

 out == +1,234,567원

 

 

2. offsetMapping 을 구현하자.

기존 Text 와 변형된 Text 간의 offset 을 조율한다.

 

offsetMapping inteface 를 구현하면 2개의 함수 override 가 필요하다.

 

알아야할 정보는 공통으로 사용되는 offset 이란 단어

문서와 내부 주석을 봐도 offset 의 의미가 와닫지 않는데, 필자가 이해한 offset 은 커서의 위치이다.

 

위 함수들은 이름과 주석에서 알 수 있듯 original Text ↔ Transformed Text  사이 offset(커서 위치)을 조작하는 함수들이다.

 

하나씩 살펴보자.

 

1. originalToTransformed(offset: Int): Int

역할: 실제 Text 의 offset 을 기반으로 변형된 Text 의 offset(커서 위치)을 지정

 

함수가 언제 호출될까?

 TextField 에서 커서가 깜빡거릴 때

함수를 살펴보자

  offset 을 파라미터로 받고 Int 타입을 반환한다.

 

파라미터 offset 은 original Text 의 커서 위치

▪ 여기서 original Text 란 사용자가 입력한 Text.

예시로 "+1,234,567원" 일때의 offset 은 다음과 같다.

parmeter

 

반환해야하는 값은 변형된 Text 의 커서 위치

"+1,234,567원" 일때의 반환해야 하는 Int 값의 범위는 다음과 같다.

return

 

위 예시에서 각 offset 들이 반환해야 하는 값들을 알아보자.

offset(실제 Text의 커서 위치) 반환값(변형된 Text 의 커서 위치)
0 1
1 3
2 4
3 5
4 7
5 8
6 9
7 10

 

왜 offest 1일 때 3 을 반환할까??

→ 2 로 반환한다면 커서가 쉼표가 가려 요구사항을 충족하지 못한다.

 

그럼 어떻게 반환해야할 지 알았으니 구현을 보자.

override fun originalToTransformed(offset: Int): Int {
    val cursorPoint = offset

    if (text.isEmpty()) return 0    // 1
    if (cursorPoint == text.length) return numberWithComma.length + type.symbol.length  // 2

    val rightCursorPointCount = text.length - cursorPoint // 3

    val commaCountToTheRight =      // 4
        if (rightCursorPointCount % 3 == 0) {
            rightCursorPointCount / 3 - 1
        } else {
            rightCursorPointCount / 3
        }
    return (numberWithComma.length - rightCursorPointCount - commaCountToTheRight) + type.symbol.length  // 5
}

 

오른쪽(주석)에 적힌 숫자를 기준으로 하나씩 살펴보자.

// 1
if (text.isEmpty()) return 0

text 가 비어있을 때 0 을 return 한다.

해당 조건문의 존재 이유는 symbol(+, -) 때문이다.

text 가 비어있지만 symbol 이 있는 상태에서 함수 끝까지 간다면 1을 return 시킨다.

→ 변형된 Text (out)의 length 가 0인데 1을 반환하면 런타임 에러가 발생한다.

        cause:  변형된 Text 의 커서 위치를 반환해야 하는데 커서 위치가 변형된 Text 의 길이(0)보다 클 수가 없다.

 

// 2
if (cursorPoint == text.length) return numberWithComma.length + type.symbol.length

cursorPoint 와 text.length 가 같을 때는 커서가 Text 의 맨 뒤에 있는 시점이다.

 이는 두가지 상황이다.

1. 사용자가 입력하는 시점

2. 커서가 Text 끝에서 깜빡 거리는 시점

 

이 때는 변형된 Text 에서 "원" 바로 앞에 커서를 위치 시켜야 하기에

콤마가 붙어있는 숫자의 길이 +  symbol 숫자를 더한 값을 return.

→ return out - "원".length 로도 가능하다.

 

// 3
val rightCursorPointCount = text.length - cursorPoint

현재 커서 기준으로 오른쪽에 올 수 있는 커서의 갯수이다.

text 길이에서 커서 위치를 빼면 오른쪽에 올 수 있는 커서의 갯수를 구할 수 있다.

    cause: cursorPoint(offset) 은 왼쪽 기준으로 증가하기에

 

// 4
val commaCountToTheRight =
    if (rightCursorPointCount % 3 == 0) {
        rightCursorPointCount / 3 - 1
    } else {
        rightCursorPointCount / 3
    }

현재 커서 기준으로 오른쪽에 나오는 콤마(",")의 갯수이다.

 

rightCursorPointCount / 3 - 1 은 왜 존재할까?

ex). originText 1234567

여기서 사용자가 4 뒤를 클릭했다고 가정하자.

그럼 내가 원하는 커서의 위치는 아래의 빨간 선이다.

1,234,|567

빨간선을 기준으로 뒤에 올 수 있는 커서의 갯수는 3개이다.

그렇기에 실제로 오른쪽에 나오는 콤마의 갯수는 0이 되어야 한다.

 

// 5
return (numberWithComma.length - rightCursorPointCount - commaCountToTheRight) + type.symbol.length

콤마가 붙은 숫자 길이 - 오른쪽에 올 수 있는 커서의 갯수 - 오른쪽의 콤마 갯수 + 심볼의 길이

 계산하면 왼쪽을 기준으로 변형된 Text 가 가져야할 올바른 커서의 위치를 구할 수 있다.

 

위 표에 해당 return 식을 대입하면 다음과 같다.

offset(실제 Text의 커서 위치) 반환값(변형된 Text 의 커서 위치)
0 9 - 7 - 2 + 1 = 1
1 9 - 6 - 1 + 1 = 3
2 9 - 5 - 1 + 1 = 4
3 9 - 4 - 1 + 1 = 5
4 9 - 3 - 0 + 1 = 7
5 9 - 2 - 0 + 1 = 8
6 9 - 1 - 0 + 1 = 9
7 9 - 0 - 0 + 1 = 10

 


 

2. transformedToOriginal

역할: 변형된 Text 의 offset 을 기반으로 실제 Text 의 offset(커서 위치)을 지정

 

함수가 언제 호출될까?

  TextField 에서 사용자가 Text 를 클릭하여 커서 위치를 변경(지속)할 때

함수를 살펴보자

  offset 을 파라미터로 받고 Int 타입을 반환한다.

 

파라미터 offset 은 변형된 Text 의 커서 위치

예시로 "+1,234,567원" 일때의 offset 은 다음과 같다.

parameter - offset

어디서 본 듯한 사진이다. -> originalToTransformed 의 설명에서 본 반환값의 범위이다.

여기선 반대로 해당 범위들이 파라미터 offset 으로 올 수 있다.

 

그럼 추측할 수 있는 사항은 반환값의 범위이다.

보다시피 다음과 같다.

return

 

그럼 각 offset 을 기준으로 반환해야 하는 값을 알아보자.

offset(변형된 Text의 커서 위치) 반환값(실제 Text 의 커서 위치)
0 0
1 0
2 1
3 1
4 2
5 3
6 4
7 4
8 5
9 6
10 7
11 7

 

단순히 실제 Text 의 적절한 위치를 반환하면 된다.

 

구현을 살펴보자.

override fun transformedToOriginal(offset: Int): Int {
    val cursorPoint = offset

    if (cursorPoint in 0..1) return 0    // 1
    if (cursorPoint == out.length) return text.length    // 2

    // 3
    val commaCount = numberWithComma.count { it == ',' }
    val rightCursorPointCount = (numberWithComma.length + type.symbol.length) - cursorPoint
    val commaCountToTheRight = rightCursorPointCount / 4
    val leftCommaCount = commaCount - commaCountToTheRight

    // 4
    return (cursorPoint - leftCommaCount - type.symbol.length)
}

 

주석을 기준으로 하나씩 살펴보자.

 

// 1
if (cursorPoint in 0..1) return 0

커서가 0 또는 1 이라면 0 을 리턴한다.

→   symbol(+, -) 로 인해 1 이여도 무시하여 실제 Text 에선 0 이다.

 

// 2
if (cursorPoint == out.length) return text.length

해당 조건문은 커서가 가장 맨뒤에 있을 때 이다.

→ 그럼 단순히 실제 text 의 length 를 반환하면 실제 Text 의 마지막 커서위치이다.

 

// 3 
val commaCount = numberWithComma.count { it == ',' }
val rightCursorPointCount = (numberWithComma.length + type.symbol.length) - cursorPoint
val commaCountToTheRight = rightCursorPointCount / 4
val leftCommaCount = commaCount - commaCountToTheRight

왼쪽에 오는 쉼표의 개수를 구하는 코드이다.

 

// 4
return (cursorPoint - leftCommaCount - type.symbol.length)

현재 커서 위치 - 왼쪽에 오는 쉼표 갯수 - symbol 의 길이를 계산하면 원하는 실제 Text의 커서 위치이다.

→ 당연하다. 왼쪽에 오는 쉼표, symbol 을 제외하면 모두 실제 사용자가 입력한 Text 이기에.

 

아래 표는 offset 에 따른 반환값을 식에 따라 정리한 표이다.

offset(실제 Text의 커서 위치) 반환값(변형된 Text 의 커서 위치)
0 0 = 0     주석 1 에 의해
1 1 = 0     주석 1 에 의해    
2 2 - 0 - 1 = 1
3 3 - 1 - 1 = 1
4 4 - 1 - 1 = 2
5 5 - 1 - 1 = 3
6 6 - 1 - 1 = 4
7 7 - 2 - 1 = 4
8 8 - 2 - 1 = 5
9 9 - 2 - 1 = 6
10 10 - 2 - 1 = 7
11 11 = 7    주석 2 에 의해

 


 

 

이제는 위 함수들이 어떻게 사용되고 왜 필요한지를 이해하기 위해 사용되는 flow 를 보자.

 

예시
+1,234,567원 으로 TextField 가 존재한다고 생각하자.

 

flow

1. 사용자가  빨간선을 부분을 클릭해서 커서의 위치가 변경해야한다.

+1,234,|567원

 

2. transformedToOriginal 함수가 호출되며 파라미터로 들어오는 offset 은 7 이다.

  ▪ 내부 구현에 의해 4를 반환한다.

 

3. originalToTransformed 함수가 호출되며 파라미터로 들어오는 offset 은 위에서 반환한 4 이다.

 ▪ 내부 구현에 의해 7 을 반환한다.

 

4. 변형된 Text 의 7 번째 자리에 커서가 위치한다.

 

5. 커서가 깜빡일 때마다 3, 4 가 계속해서 반복하여 커서 위치를 잡는다.

 

위와 같은 flow 로 위 함수들 중 하나라도 올바르게 구현하지 못하면 예상치 못한 동작이 초래된다.

 

 

▼ 전체 코드

더보기
class PriceVisualTransformation(private val type: PriceType) : VisualTransformation {
    override fun filter(text: AnnotatedString): TransformedText {
        val number = text.text
        var numberWithComma = ""
        for (i in number.indices) {
            numberWithComma += number[i]
            if (i != number.lastIndex && (number.lastIndex - i) % 3 == 0) {
                numberWithComma += ","
            }
        }
        val out = if (numberWithComma.isEmpty()) "" else "${type.symbol}${numberWithComma}원"

        val priceOffsetTranslator = object : OffsetMapping {
            val symbolLength = type.symbol.length

            override fun originalToTransformed(offset: Int): Int {
                val cursorPoint = offset

                if (text.isEmpty()) return 0
                if (cursorPoint == text.length) return numberWithComma.length + symbolLength

                val rightCursorPointCount = text.length - cursorPoint
                val commaCountToTheRight =
                    if (rightCursorPointCount % 3 == 0) {
                        rightCursorPointCount / 3 - 1
                    } else {
                        rightCursorPointCount / 3
                    }
                return (numberWithComma.length - rightCursorPointCount - commaCountToTheRight) + symbolLength
            }

            override fun transformedToOriginal(offset: Int): Int {
                val cursorPoint = offset

                if (cursorPoint in 0..1) return if (type == PriceType.None) cursorPoint else 0
                if (cursorPoint == out.length) return text.length

                val commaCount = numberWithComma.count { it == ',' }
                val rightCursorPointCount = (numberWithComma.length + symbolLength) - cursorPoint
                val commaCountToTheRight = rightCursorPointCount / 4
                val leftCommaCount = commaCount - commaCountToTheRight
                return (cursorPoint - leftCommaCount - symbolLength)
            }
        }

        return TransformedText(
            text = AnnotatedString(out),
            offsetMapping = priceOffsetTranslator
        )
    }
}

 


도움 많이 받았습니다 ! 🙇‍♂️

https://github.com/adibfara/Compose-TextField-Thousands-Separator

 

GitHub - adibfara/Compose-TextField-Thousands-Separator: Thousands-Separator implemented for Jetpack Compose's TextFields using

Thousands-Separator implemented for Jetpack Compose's TextFields using a visual transformation - GitHub - adibfara/Compose-TextField-Thousands-Separator: Thousands-Separator implemented for Jet...

github.com