시작하며
BasiceTextField에는 value/onValueChange를 사용하는 버전과 TextFieldState를 사용하는 버전이 존재한다. 이 글에서는 TextFieldState를 사용하는 BasicTextField에 대해 자세히 알아본다. TextFieldState에 대한 내용은 아래 글에서 설명하였다.
[Jetpack Compose] TextFieldState 사용하기
시작하며Compose의 TextField로는 String과 TextFieldValue를 사용하는 두 개의 버전이 존재한다. TextFieldValue는 selection을 포함하고 있는 클래스로 String을 사용하는 기본 TextField에 커서 위치를 조절할 수 있
developuzzle.tistory.com
코드 설명
// value/onValueChange BasicTextField
@Composable
fun BasicTextField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = TextStyle.Default,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
visualTransformation: VisualTransformation = VisualTransformation.None,
onTextLayout: (TextLayoutResult) -> Unit = {},
interactionSource: MutableInteractionSource? = null,
cursorBrush: Brush = SolidColor(Color.Black),
decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit =
@Composable { innerTextField -> innerTextField() }
)
// TextFieldState BasicTextField
@Composable
fun BasicTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
inputTransformation: InputTransformation? = null,
textStyle: TextStyle = TextStyle.Default,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onKeyboardAction: KeyboardActionHandler? = null,
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
onTextLayout: (Density.(getResult: () -> TextLayoutResult?) -> Unit)? = null,
interactionSource: MutableInteractionSource? = null,
cursorBrush: Brush = BasicTextFieldDefaults.CursorBrush,
outputTransformation: OutputTransformation? = null,
decorator: TextFieldDecorator? = null,
scrollState: ScrollState = rememberScrollState()
)
기존의 value/onValueChange를 사용하는 BasicTextField와 비교하여 사용 방법이 달라진 부분과 새롭게 추가된 부분 위주로 설명할 예정이다.
- state
value = text,
onValueChange = { text = it }
->
state = textFieldState
- 기존의 value에 값을 입력해 주고 onValueChange에서 매개 변수를 사용해 변수를 변경하는 방식에서 value와 onValueChange가 state로 통합되어 별도의 값 변경 콜백을 설정하지 않아도 된다.
- 값 변경 콜백이 삭제됨에 따라, 이전의 onValueChange에서의 조건을 걸거나 값을 변환해 변수에 할당하던 방법은 사용할 수 없게 되었다. 대신 inputTransformation이나 snapshotFlow로 변경해서 사용해야 한다. 두 가지 방법은 아래에서 더 자세히 설명하겠다.
- lineLimits
singleLine = true
->
lineLimits = TextFieldLineLimits.SingleLine
minLines = 1
maxLines = 3
->
lineLimits = TextFieldLineLimits.MultiLine(1, 3)
- singleLine과 minLines, maxLines가 삭제되고 lineLimits로 통합되었으며, 파라미터의 타입도 Int에서 TextFieldLineLimits로 변경되었다.
- SingleLine 또는 MultiLine()으로 사용할 수 있으며 MultiLine에 minLines와 maxLines를 Int로 입력하면 이전과 동일하게 사용할 수 있다. 아무 설정을 하지 않았을 때의 기본 값은 MultiLine()으로 최소/최대 라인 수 미설정 상태이다.
- onKeyboardAction
keyboardActions = KeyboardActions(
onDone = { /* 완료 버튼을 눌렀을 때 실행할 함수 */ }
)
->
onKeyboardAction = { /* 완료 버튼을 눌렀을 때 실행할 함수 */ }
- keyboardActions가 onKeyboardAction으로 변경되었고, 파라미터의 타입도 KeyboardActions에서 KeyboardActionHandler으로 변경되었다.
- keyboardActions에서는에서는 KeyboardActions 객체를 생성하고 onDone(onGo, onNext 등등)에 실행할 함수를 입력해야 했지만 onKeyboardAction에서는 KeyboardActionHandler가 함수형 인터페이스기 때문에 키보드 버튼 타입에 따라 설정할 필요 없이 바로 함수를 입력하면 된다.
- onTextLayout
onTextLayout = { layoutResult ->
val textWidth = layoutResult.size.width
val textHeight = layoutResult.size.height
println("텍스트 너비: $textWidth, 높이: $textHeight")
}
->
onTextLayout = { getResult ->
val layoutResult = getResult()
layoutResult?.let {
val widthInDp = it.size.width.toDp()
val heightInDp = it.size.height.toDp()
println("텍스트 너비 (dp): $widthInDp, 높이 (dp): $heightInDp")
}
}
- 매개변수가 변경되며 수신 객체 타입으로 density가 추가되어 밀도 관련 정보를 바로 사용할 수 있게 되었다.
- 반환 타입이 TextLayoutResult를 직접 반환하는 방식에서 getResult()를 사용해 지연하여 반환하는 방식으로 변경되어 성능 최적화가 이루어졌다.
- decorator
decorator = { innerTextField ->
Box(
modifier = Modifier
.border(1.dp, Color.Gray, shape = RoundedCornerShape(8.dp))
.padding(8.dp)
) {
innerTextField()
}
}
decorationBox의 기능이 함수형 인터페이스의 TextFieldDecorator의 추상 메서드인 Decoration 함수로 이전되었으며 그에 따라 파라미터명이 decorationBox에서 decorator로 변경되었지만 사용 방법은 전과 동일하기에 파라미터명만 decorator로 바꿔주면 된다.
- inputTransformation
inputTransformation = { takeIf { it.length > 18 }?.revertAllChanges() }
- onValueChange에서의 텍스트 처리를 대체하는 파라미터로 함수형 인터페이스기 때문에 괄호 안에서 TextFieldBuffer를 사용할 수 있다.
- 예시는 Long의 최댓값을 넘지 않도록 하기 위해 18자리까지만 입력되도록 설정한 것이며 revertAllChanges()는 TextFieldBuffer의 멤버 함수이다. 하단의 레퍼런스에서 추가 예제를 확인할 수 있다.
- 주의할 점은 originalText와 originalSelection은 입력 전 원본을 뜻한다. 공백에 1을 입력한다고 하자. 괄호 안에 로그를 작성해 프로퍼티들을 확인해 보면 length는 1, selection은 TextRange(1, 1), changes는 ChangeList(changes=[(0,0)->(0,1)])라고 나오지만 originalText는 공백, originalSelection은 TextRange(0, 0)으로 나온다. 즉, InputTransformation에서는 사용자의 입력은 알 수 있지만 입력한 값에 대해서는 알 수 없다.
- outputTransformation
outputTransformation = {
val originalText = originalText.toString()
val formattedText = originalText.filter { it.isDigit() }.takeIf { it.isNotEmpty() }?.let { "%,d".format(it.toLong()) } ?: ""
if (formattedText != originalText) replace(0, length, formattedText)
},
- visualTransformation을 대체하는 파라미터로 inputTransformation과 동일하게 함수형 인터페이스기 때문에 괄호 안에 사용하면 되며 예시는 세 자릿수마다 쉼표를 추가하여 포맷팅 하는 방법이다.
- inputTransformation과의 차이점은 input... 은 텍스트가 입력되면 실행되고 output... 은 입력된 텍스트가 화면에 표시될 때 실행된다. 그렇기에 여기서의 originalText는 입력된 값을 반환하며 changes는 사용할 수 없다. 1을 입력했을 때 input... 에서는 공백으로 표시되지만, output... 에서는 1로 표시되며 changes는 어떤 값을 입력하던 ChangList(changes=[])으로 나온다.
- outputTransformation에서 사용하는 TextFieldBuffer의 멤버 함수들은 원본을 변화시키지 않고 화면에 표시되는 값만 변화시킨다. 이에 따라 삭제 동작에서 약간의 이슈가 발생하는데 예시 코드를 사용하고 1000을 입력하면 1,000으로 화면에 표시된다. 하지만 실제 원본 값은 여전히 1000이므로, 삭제를 한 번 누르면 전체가 삭제된다. 1,000에서 100으로 변하지 않고 공백으로 변한다는 말이다.
- scrollState
lineLimits가 SingleLine이라면 수평 스크롤, 그렇지 않으면 수직 스크롤 상태를 가진다. 이를 이용해 스크롤바를 제작할 수 있다.
snapshotFlow
inputTransformation에서 입력된 값을 알 수 없기 때문에 기존 onValueChange처럼 변화된 값에 대한 처리를 하기 어렵다. 이에 대한 방법으로는 snapshotFlow를 사용해 값을 실시간으로 검사하는 방법을 사용할 수 있다.
// ViewModel에서
viewModelScope.launch {
snapshotFlow { price.text }.collect { text ->
val processed = text.filter { it.isDigit() }.takeIf { it.isNotEmpty() }?.toString()?.toLong() ?: ""
price.setTextAndPlaceCursorAtEnd(processed.toString())
}
}
// Composable에서
LaunchedEffect(Unit) {
snapshotFlow { price.text }.collect { text ->
val processed = text.filter { it.isDigit() }.takeIf { it.isNotEmpty() }?.toString()?.toLong() ?: ""
price.setTextAndPlaceCursorAtEnd(processed.toString())
}
}
outputTransformation에서는 숫자로 필터링해서 결과가 존재한다면 포맷팅을 진행하였지만 여기서는 숫자만 입력받기 위해 필터링 후에 항상 값을 변경한다. 작동 순서는 outputTransformation이 snapshotFlow보다 먼저 작동하므로 outputTransformation에 숫자가 아닌 값이 입력될 수 있어 snapshowFlow에서와 마찬가지로 toLong에서 문제가 생기지 않도록 숫자 필터링을 먼저 해주었다.
사용 방법
BasicTextField(
state = price,
textStyle = textFieldTextStyle, // 커스텀
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
onKeyboardAction = { focusManager.clearFocus() },
lineLimits = TextFieldLineLimits.SingleLine,
interactionSource = state.priceInteraction, // remember { MutableInteractionSource() }로 대체
inputTransformation = { takeIf { it.length > 18 }?.revertAllChanges() },
outputTransformation = {
val originalText = originalText.toString()
val formattedText = originalText.filter { it.isDigit() }.takeIf { it.isNotEmpty() }?.let { "%,d".format(it.toLong()) } ?: ""
if (formattedText != originalText) replace(0, length, formattedText)
},
decorator = { innerTextField ->
val isFocused by state.priceInteraction.collectIsFocusedAsState()
val color by animateColorAsState(Color(0xFFE0E0E0).takeIf { !isFocused } ?: Color.Black)
Column(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.drawBehind {
drawLine(
color = color,
start = Offset(0f, size.height - 0.5.dp.toPx()),
end = Offset(size.width, size.height - 0.5.dp.toPx()),
strokeWidth = 1.dp.toPx()
)
}
) {
Text(
text = "가격",
fontSize = 12.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.Medium,
color = animateColorAsState(Color(0xFFA0A0A0).takeIf { !isFocused } ?: Color.Black).value
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
Text(
text = currency.value,
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { state.priceExpanded.value = true }
)
)
Box(
content = { innerTextField() },
modifier = Modifier
.weight(1f)
)
}
}
}
)
outputTransformation에 있는 gif의 전체 코드이다. 주의할 점은 앞에서 설명한 것처럼 outputTransformation이 snapshotFlow보다 먼저 동작하기 때문에 숫자가 아닐 경우에 대한 예외 처리가 필요하다는 점이다. 필자는 isDigit()와 isNotEmpty()로 처리하였다.
Reference
https://developer.android.com/reference/kotlin/androidx/compose/foundation/text/input/TextFieldState
TextFieldState | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com
androidx.compose.foundation.text | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com