요구사항은 다음과 같다.
- 앞에 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 변수를 가지게 된다.
우리의 역할은 변형된 Text 와 offsetMapping 을 구현해서 반환값에 넘겨주면 된다.
구현
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 은 다음과 같다.
반환해야하는 값은 변형된 Text 의 커서 위치
"+1,234,567원" 일때의 반환해야 하는 Int 값의 범위는 다음과 같다.
위 예시에서 각 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 은 다음과 같다.
어디서 본 듯한 사진이다. -> originalToTransformed 의 설명에서 본 반환값의 범위이다.
여기선 반대로 해당 범위들이 파라미터 offset 으로 올 수 있다.
그럼 추측할 수 있는 사항은 반환값의 범위이다.
보다시피 다음과 같다.
그럼 각 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
'MoneyMong' 카테고리의 다른 글
Compose Popup 이 특정 기기에서 버그가 발생했다 (0) | 2024.08.01 |
---|---|
기존 화면을 기반한 Onboarding 화면을 구현해보자 (0) | 2024.06.20 |
디자인 시스템 컴포넌트에 디자인이 추가됐다!? (0) | 2024.05.06 |
Markdown 을 파싱해서 compose 로 그린 이야기 (0) | 2024.02.29 |