일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
- 안드로이드 레트로핏 사용법
- android retrofit login
- 2022 플러터 안드로이드 스튜디오
- rxjava disposable
- Rxjava Observable
- 서비스 쓰레드 차이
- 멤버변수
- 자바 다형성
- jvm 작동 원리
- 안드로이드 라이선스 종류
- 서비스 vs 쓰레드
- android ar 개발
- 클래스
- 안드로이드 유닛 테스트
- 안드로이드 유닛테스트란
- 안드로이드 유닛 테스트 예시
- 안드로이드 라이선스
- rxjava cold observable
- 큐 자바 코드
- ANR이란
- 2022 플러터 설치
- 안드로이드 레트로핏 crud
- rxjava hot observable
- 스택 큐 차이
- ar vr 차이
- jvm이란
- 플러터 설치 2022
- 객체
- 스택 자바 코드
- 안드로이드 os 구조
- Today
- Total
나만을 위한 블로그
[Android Compose] TextField 사용법 본문
이 포스팅은 아래 디벨로퍼 링크를 정리한 글이다.
텍스트 필드 구성 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 텍스트 필드 구성 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. TextField를 사용하면 사용자가 텍스트
developer.android.com
TextField의 기본 구현은 아래와 같다.
@Composable
fun TextField(
state: TextFieldState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
labelPosition: TextFieldLabelPosition = TextFieldLabelPosition.Attached(),
label: (@Composable TextFieldLabelScope.() -> Unit)? = null,
placeholder: (@Composable () -> Unit)? = null,
leadingIcon: (@Composable () -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null,
prefix: (@Composable () -> Unit)? = null,
suffix: (@Composable () -> Unit)? = null,
supportingText: (@Composable () -> Unit)? = null,
isError: Boolean = false,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onKeyboardAction: KeyboardActionHandler? = null,
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
onTextLayout: (Density.(getResult: () -> TextLayoutResult?) -> Unit)? = null,
scrollState: ScrollState = rememberScrollState(),
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
contentPadding: PaddingValues = if (label == null || labelPosition is TextFieldLabelPosition.Above) {
TextFieldDefaults.contentPaddingWithoutLabel()
} else {
TextFieldDefaults.contentPaddingWithLabel()
},
interactionSource: MutableInteractionSource? = null
): Unit
androidx.compose.material3 | API reference | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com
TextField는 유저가 UI에 텍스트를 입력할 수 있게 한다. 일반적으로 폼, 다이얼로그에 쓰인다. 글자가 입력된 TextField는 윤곽선이 있는 TextField보다 시각적 강조가 더 강해서 다른 컨텐츠, 컴포넌트로 둘러싸여 있을 때 더 눈에 띈다...(중략)...TextField의 오버로드 버전은 TextFieldState를 써서 텍스트 컨텐츠, 커서, 선택 위치의 상태를 추적한다...(중략)
TextField는 상태 기반, 값 기반으로 나뉘어진다. 디벨로퍼에선 아래처럼 나눠 설명한다.
기능 | 값 기반 | 상태 기반 | 주 기반 혜택 |
상태관리 | onValueChange 콜백으로 상태 업데이트 onValueChange에서 알리는 변경사항에 따라 자체 상태에서 value를 업데이트해야 함 |
TextFieldState 객체를 써서 텍스트 입력 상태(값, 선택, 컴포지션) 관리 저장, 공유 가능 |
onValueChange 콜백이 삭제되서 비동기 동작 불가능 상태는 리컴포지션, 구성, 프로세스 종료 후에도 유지됨 |
시각적 변환 | 표시된 텍스트 모양을 수정하는 데 VisualTransformation을 씀 단일 단계에서 입력, 출력 형식을 모두 처리 |
상태에 커밋되기 전에 사용자 입력을 수정하는 데 InputTransformation을 쓰고 기본 상태 데이터를 바꾸지 않고 TextField 컨텐츠를 포맷하는 데 OutputTransformation 사용 | 더 이상 OutputTransformation을 써서 원본 원시 텍스트, 바뀐 텍스트 간의 오프셋 매핑을 제공할 필요 없음 |
라인 한도 | singleLine, maxLines, minLines를 허용해서 줄 수 제어 | lineLimits: TextFieldLineLimits를 써서 TextField가 차지할 수 있는 최소, 최대 줄 수 구성 | lineLimits를 제공해서 줄 제한 구성 시 모호성 삭제 |
보안 텍스트 필드 | - | SecureTextField는 비밀번호 필드를 작성하기 위한 상태 기반 TextField 위에 빌드된 컴포저블 | 보안을 내부적으로 최적화할 수 있고 textObfuscationMode를 사용한 사전 정의된 UI 제공 |
상태 기반, 값 기반으로 설명해서 헷갈리는데 쉽게 말해서 TextField의 값이 바뀔 때마다 호출되는 onValueChanged 콜백을 사용하면 값 기반, rememberTextFieldState()를 통해 TextFieldState 객체와 TextField를 사용한다면 상태 기반이다.
디벨로퍼에선 상태 기반 TextField를 사용하는 게 더 좋다고 권장하고 있다. 상태 기반 TextField를 사용하면 TextFieldState에 입력값 등의 상태를 통합적으로 저장, 관리할 수 있어서 값 기반 TextField보다 더 안정적이고 일관되게 구현할 수 있기 때문이다.
상태 기반 TextField로 이전이라는 디벨로퍼 문서에 간단한 예시가 있다.
https://developer.android.com/develop/ui/compose/text/migrate-state-based?hl=ko#value-based
상태 기반 텍스트 필드로 이전 | Jetpack Compose | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 상태 기반 텍스트 필드로 이전 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이 페이지에서는 값 기
developer.android.com
// 값 기반
@Composable
fun OldSimpleTextField() {
var state by rememberSaveable { mutableStateOf("") }
TextField(
value = state,
onValueChange = { state = it },
singleLine = true,
)
}
// 상태 기반
@Composable
fun NewSimpleTextField() {
TextField(
state = rememberTextFieldState(),
lineLimits = TextFieldLineLimits.SingleLine
)
}
요구사항에 따라 다르겠지만 신용카드 형식에 맞춰 값을 표시하는 TextField의 경우 상태 기반 TextField의 구현이 값 기반 TextField보다 간단하다.
// 값 기반
@Composable
fun OldTextFieldCreditCardFormatter() {
var state by remember { mutableStateOf("") }
TextField(
value = state,
onValueChange = { if (it.length <= 16) state = it },
visualTransformation = VisualTransformation { text ->
// Making XXXX-XXXX-XXXX-XXXX string.
var out = ""
for (i in text.indices) {
out += text[i]
if (i % 4 == 3 && i != 15) out += "-"
}
TransformedText(
text = AnnotatedString(out),
offsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
if (offset <= 3) return offset
if (offset <= 7) return offset + 1
if (offset <= 11) return offset + 2
if (offset <= 16) return offset + 3
return 19
}
override fun transformedToOriginal(offset: Int): Int {
if (offset <= 4) return offset
if (offset <= 9) return offset - 1
if (offset <= 14) return offset - 2
if (offset <= 19) return offset - 3
return 16
}
}
)
}
)
}
// 상태 기반
@Composable
fun NewTextFieldCreditCardFormatter() {
val state = rememberTextFieldState()
TextField(
state = state,
inputTransformation = InputTransformation.maxLength(16),
outputTransformation = OutputTransformation {
if (length > 4) insert(4, "-")
if (length > 9) insert(9, "-")
if (length > 14) insert(14, "-")
},
)
}
다른 내용은 해당 문서를 읽어보면 될 것이다.
이 포스팅에선 상태 기반 TextField 사용법을 먼저 확인하고 다음 포스팅에서 값 기반 TextField 사용법을 확인한다. 코드를 따라하기 전에 material3 라이브러리의 버전을 1.4.0-alpha14로 변경한다. 1.1.0 등 낮은 버전을 사용한다면 아래 코드를 사용할 수 없다.
디벨로퍼에서도 버전과 관련된 주의사항을 안내하고 있다. 아직 실험 단계기 때문에 에러가 발생할 수 있으니 실제로 사용할 거라면 많은 검토가 필요해 보인다.
TextField는 material 디자인 가이드라인을 따라서 기본 스타일이 Filled다. 아래는 이 포스팅에서 사용할 기본 코드고 기본적인 TextField를 만들면 아래와 같이 보인다.
package com.example.composepractice
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TestTextField(innerPadding)
}
}
}
}
}
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
Column(
modifier = Modifier.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = rememberTextFieldState(
initialText = "안녕하세요"
),
label = {
Text("label")
},
)
}
}
@Preview(showBackground = true)
@Composable
private fun MainPreview() {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TestTextField(innerPadding)
}
}
}
TextField를 OutlinedTextField로 바꾸면 아래처럼 보인다.
아래는 textStyle, lineLimits 같은 속성을 사용해 TextField에 최대 2줄만 표시하는 예시다.
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
Column(
modifier = Modifier.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = rememberTextFieldState(
initialText = "안녕하세요\n식사는 하셨나요\n이 줄은 안 보임"
),
lineLimits = TextFieldLineLimits.MultiLine(
maxHeightInLines = 2
),
placeholder = {
Text("")
},
label = {
Text("여기에 입력해주세요")
},
modifier = Modifier.padding(20.dp)
)
}
}
디자인이 material TextField, OutlinedTextField를 요구한다면 BasicTextField보다 TextField를 쓰는 게 좋다. material 디자인이 필요없다면 BasicTextField를 사용해야 한다.
라인 한도 구성
TextField는 하나의 축을 따라 스크롤할 수 있게 한다. 스크롤 동작은 lineLimits에 따라 결정된다. 하나의 행에 구성된 TextField는 가로 스크롤되지만 여러 행의 TextField는 세로 스크롤된다.
TextFieldLineLimits를 써서 만들려는 TextField에 적합한 라인 구성을 선택한다.
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
Column(
modifier = Modifier.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = rememberTextFieldState(),
lineLimits = TextFieldLineLimits.SingleLine
)
}
}
SingleLine으로 설정하면 아래 특징을 갖는다.
- 텍스트가 줄바꿈되지 않고 허용되지 않는다
- 항상 높이가 고정된다
- 한 번에 표시될 수 없는 많은 텍스트를 입력하면 가로 스크롤된다
아래는 MultiLine을 사용한 예시다.
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
Column(
modifier = Modifier.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = rememberTextFieldState(
initialText = "Hello\nWorld\nHello\nWorld"
),
lineLimits = TextFieldLineLimits.MultiLine(
minHeightInLines = 1,
maxHeightInLines = 4
)
)
}
}
MultiLine으로 설정하면 아래 특징을 갖는다.
- minHeightInLines, maxHeightInLines 매개변수를 쓸 수 있게 된다
- TextField의 높이가 minHeightInLines 이상이다
- 텍스트가 오버플로되면 래핑된다
- 텍스트에 더 많은 줄이 필요하면 필드가 maxHeightInLines에 설정한 높이가 될 때까지 커지고 세로 스크롤된다
코드를 좀 바꿔서 테스트한다. maxHeightInLines를 2로 바꿔서 최대 2줄만 표시되게 하고 더 많은 줄의 텍스트를 초기값으로 설정하면 아래처럼 보인다.
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
Column(
modifier = Modifier.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = rememberTextFieldState(
initialText = "Hello\nWorld\n이 줄부터\n안보여요\n이 줄부터\n안보여요\n이 줄부터\n안보여요"
),
lineLimits = TextFieldLineLimits.MultiLine(
minHeightInLines = 1,
maxHeightInLines = 2
)
)
}
}
스크롤하면 그 때부터 한글들이 보이기 시작한다.
브러시 API로 스타일 입력
이전 포스팅인 Text 사용법에서 Brush라는 객체를 사용해 그라데이션을 입혔는데 TextField에서도 쓸 수 있다.
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
val colorBrush = remember {
Brush.linearGradient(
colors = listOf(
Color.Red,
Color.Yellow,
Color.Green,
Color.Blue,
Color.Magenta
)
)
}
Column(
modifier = Modifier.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = rememberTextFieldState(
initialText = "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book"
),
textStyle = TextStyle(
brush = colorBrush
)
)
}
}
인터랙션 모드를 통해 간단하게 확인해 보면 입력하는 텍스트들에 무지개 색이 입혀져서 위 캡쳐처럼 보이게 된다.
상태 관리
TextField는 컨텐츠, 현재 selection에 TextFieldState라는 전용 상태 홀더 클래스를 쓴다. TextFieldState는 아키텍처에 적합한 곳에 호이스팅되게 설계됐고 주요 속성이 2개 있다.
- initialText : TextField의 컨텐츠
- initialSelection : 커서 or 선택 항목이 현재 있는 위치
TextFieldState가 onValueChange 콜백 등 다른 접근법과 다른 부분은 전체 입력 흐름을 완전 캡슐화하는 것이다. 여기엔 올바른 지원 데이터 구조 사용, 필터 및 포매터 인라인, 여러 소스에서 발생하는 모든 수정사항 동기화가 포함된다.
TextFieldState는 컴포즈 파운데이션 모듈에 속하지만 UI 종속 항목이 없는 상태 보유자로 설계됐다. 컴포즈의 스냅샷 시스템에서 제공하는 데이터 구조만 쓰기 때문에 디벨로퍼에선 뷰모델에서 TextFieldState 객체를 인스턴스화하고 갖고 있을 것을 권장한다.
이 TextFieldState를 써서 TextField에서 상태를 호이스팅할 수 있다. 이 때 rememberTextFieldState()를 쓴다. 이 함수는 컴포저블에서 TextFieldState 객체를 만들고 State 객체를 기억하며 기본 저장, 복원 기능을 제공한다.
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
val userNameState = rememberTextFieldState()
Column(
modifier = Modifier.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = userNameState,
lineLimits = TextFieldLineLimits.SingleLine,
placeholder = {
Text("사용자 이름을 입력하세요")
}
)
}
}
rememberTextFieldState()에는 빈 매개변수가 있거나 초기화 시 텍스트 값을 표시하려고 전달된 초기값이 있을 수 있다. 후속 리컴포지션에서 다른 값이 전달되면 State 값이 업데이트되지 않는다. 초기화 후 State를 업데이트하려면 TextFieldState에서 수정 메서드를 호출한다.
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
Column(
modifier = Modifier.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = rememberTextFieldState(initialText = "Username"),
lineLimits = TextFieldLineLimits.SingleLine,
)
}
}
이렇게 하면 초기 텍스트로 "Username"이 표시된다.
TextFieldBuffer로 텍스트 수정
TextFieldBuffer는 StringBuilder와 비슷한 기능을 가진 수정 가능한 텍스트 컨테이너다. 텍스트 컨텐츠, 현재 selection 값을 모두 갖고 있다.
TextFieldBuffer는 TextFieldState.edit, InputTransformation.transformInput, OutputTransformation.transformOutput 같은 함수의 리시버 범위로 자주 쓰인다. 이런 함수에선 필요하면 TextFieldBuffer를 읽거나 업데이트할 수 있다. 그 후 변경사항은 TextFieldState에 커밋되고 OutputTransformation은 렌더링 파이프라인으로 전달된다.
append, insert, replace, delete 같은 표준 편집 기능을 통해 버퍼의 컨텐츠를 수정할 수 있다. selection 상태를 바꾸려면 selection 변수를 설정하거나 placeCursorAtEnd, selectAll 같은 유틸 함수를 사용하라.
selection은 TextRange로 표시되며 시작 색인은 포함되고 종료 색인은 제외된다. 시작 값, 끝 값이 같은 TextRange는 현재 선택된 문자가 없는 커서 위치를 나타낸다.
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
val phoneNumberState = rememberTextFieldState()
LaunchedEffect(phoneNumberState) {
phoneNumberState.edit { append("123456789") }
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = phoneNumberState,
inputTransformation = InputTransformation {
if (asCharSequence().isDigitsOnly()) {
revertAllChanges()
}
},
outputTransformation = OutputTransformation {
if (length > 0) insert(0, "(")
if (length > 4) insert(4, ")")
if (length > 8) insert(8, "-")
}
)
}
}
TextFieldState에서 상태 수정
State 변수를 통해 State를 직접 수정할 수 있는 방법이 몇 개 있다.
- edit : 상태 content를 수정할 수 있고 TextFieldBuffer 함수를 제공해서 insert, replace, append 등 함수를 쓸 수 있다
val userNameState = rememberTextFieldState("I Love Android")
// 14번째 char에 !를 삽입함. 예문에선 'd' 뒤에 '!'가 추가됨
userNameState.edit { insert(14, "!") }
// 7~14번 char를 "Compose"로 바꿈. 예문에선 Android가 Compose로 바뀜
userNameState.edit { replace(7, 14, "Compose") }
// 기존 예문의 맨 뒤에 "!!!"를 추가함. 이미 '!'가 1개 있어서 이 줄이 끝나면 '!'는 총 4개가 됨
userNameState.edit { append("!!!") }
// 모든 텍스트를 선택 상태로 바꿈
// 갤럭시에서 텍스트를 꾹 누르면 표시되는 것과 같은 효과가 나타남
userNameState.edit { selectAll() }
- setTextAndPlaceCursorAtEnd : 현재 텍스트를 지우고 지정된 텍스트로 바꾼 후 커서를 끝에 설정함
val userNameState = rememberTextFieldState("I Love Android")
userNameState.edit { insert(14, "!") }
userNameState.edit { replace(7, 14, "Compose") }
userNameState.edit { append("!!!") }
userNameState.edit { selectAll() }
// 프리뷰에서도 위 코드까지로 인한 텍스트들이 모두 제거되고 아래 텍스트만 표시된다
// 커서는 텍스트 어느 부분을 클릭해도 맨 뒤에 생성
// 이는 커서가 나타난 후에도 동일하게 적용됨
userNameState.setTextAndPlaceCursorAtEnd("모든 텍스트 제거하고 맨 뒤에 커서 위치")
- clearText : 모든 텍스트를 지운다. XML의 setText("")과 같은 효과를 낸다.
이외에 다양한 함수들이 더 있으니 필요하다면 아래 디벨로퍼를 참고한다.
TextFieldState | API reference | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com
유저 입력 수정
입력 변환(InputTransformation)을 쓰면 유저가 입력하는 동안 TextField 입력을 필터링할 수 있고, 출력 변환(OutputTransformation)은 유저 입력이 화면에 표시되기 전에 형식을 지정한다. XML의 TextWatcher를 생각하면 편할 듯 하다.
입력 변환으로 유저 입력 필터링
입력 변환을 쓰면 유저 입력을 필터링할 수 있다. TextField가 미국 전화번호를 쓰는 경우 10자리 숫자만 허용해야 한다.
InputTransformation의 결과는 TextFieldState에 저장된다.
InputTransformation.transformInput() 호출 안에서 상태를 바꾸지 마라. 대신 리시버 범위 TextFieldBuffer를 써서 텍스트를 수정하라.
일반적인 InputTransformation 사용 사례를 위한 기본 제공 필터가 있다. 길이를 제한하려면 InputTransformation.maxLength()를 써라.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.maxLength
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TestTextField(innerPadding)
}
}
}
}
}
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 1줄만 입력 가능하며 최대 10자 입력 가능
TextField(
state = rememberTextFieldState(),
lineLimits = TextFieldLineLimits.SingleLine,
inputTransformation = InputTransformation.maxLength(10),
)
}
}
커스텀 InputTransformation
InputTransformation은 단일 함수 인터페이스(SAM 인터페이스, 추상 메서드가 하나뿐인 인터페이스)다. 커스텀 InputTransformation을 구현할 때는 TextFieldBuffer.transformInput을 재정의해야 한다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.maxLength
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
import androidx.core.text.isDigitsOnly
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TestTextField(innerPadding)
}
}
}
}
}
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = rememberTextFieldState(),
lineLimits = TextFieldLineLimits.SingleLine,
inputTransformation = CustomInputTransformation(),
)
}
}
class CustomInputTransformation: InputTransformation {
override fun TextFieldBuffer.transformInput() {
// 이 함수를 재정의
}
}
전화번호의 경우 숫자만 입력 가능하게 맞춤 InputTransformation을 추가한다.
class DigitOnlyInputTransformation: InputTransformation {
override fun TextFieldBuffer.transformInput() {
// 주석 처리된 코드와 처리되지 않은 코드는 모두 같은 역할을 한다
// 안드로이드 스튜디오 Narwhal Feature Drop 2025.1.2 Patch 1 기준으로 아래와 같이 바꾸라고 IDE가 권장한다
// if (!TextUtils.isDigitsOnly(asCharSequence())) {
// revertAllChanges()
// }
if (!asCharSequence().isDigitsOnly()) {
revertAllChanges() // 숫자가 아닌 값을 입력해도 입력되지 않음. 숫자만 입력됨
}
}
}
InputTransformation 연결
텍스트 입력에 여러 필터를 연결하려면 then 확장 함수를 써서 InputTransformation을 연결한다. 필터는 순차 실행된다.
결국 필터링될 데이터에 쓸데없는 변환이 적용되지 않게 가장 선택적인 필터를 먼저 적용하는 게 좋다.
이 부분의 예시 코드에서 maxLength()에 6을 넣어서 최대 6자리까지만 입력 가능한데 최대 10자리를 허용한다고 잘못 작성돼 있다. 10을 넣으면 디벨로퍼가 말하는 대로 작동한다.
표시되기 전에 입력 형식 지정
OutputTransformation을 쓰면 렌더링되기 전에 유저 입력을 포맷할 수 있다. InputTransformation과 다르게 OutputTransformation으로 수행된 서식은 TextFieldState에 저장되지 않는다. 미국 전화번호를 만든다면 적절한 위치에 괄호, 대시를 추가해야 하는데 이는 값 기반 TextField에서 VisualTransformation을 처리하는 업데이트된 방식으로 오프셋 매핑을 계산할 필요 없다는 게 주요 차이점이다.
OutputTransformation도 단일 추상 메서드(SAM) 인터페이스라 맞춤 OutputTransformation을 구현하려면 transformOutput()을 재정의해야 한다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.insert
import androidx.compose.foundation.text.input.maxLength
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.then
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
import androidx.core.text.isDigitsOnly
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TestTextField(innerPadding)
}
}
}
}
}
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = rememberTextFieldState(),
lineLimits = TextFieldLineLimits.SingleLine,
inputTransformation = InputTransformation.maxLength(10)
.then(DigitOnlyInputTransformation()),
outputTransformation = AmericanPhoneNumberOutputTransformation()
)
}
}
class AmericanPhoneNumberOutputTransformation: OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
if (length > 0) insert(0, "(")
if (length > 4) insert(4, ")")
if (length > 8) insert(8, "-")
}
}
class DigitOnlyInputTransformation: InputTransformation {
override fun TextFieldBuffer.transformInput() {
if (!asCharSequence().isDigitsOnly()) {
revertAllChanges()
}
}
}
Transformation의 작동 방식
아래 다이어그램은 텍스트 입력에서 transform을 거쳐 output으로 이어지는 흐름을 보여준다.
- 입력이 입력 소스에서 수신됨
- 입력은 TextFieldState에 저장되는 InputTransformation을 통해 필터링됨
- 입력은 서식을 위해 OutputTransformation을 통해 전달됨
- 입력이 TextField에 표시됨
키보드 옵션 설정
TextField를 쓰면 키보드 레이아웃 같은 키보드 구성 옵션을 설정하거나, 키보드에서 지원하는 경우 자동 수정을 사용 설정할 수 있다. 소프트웨어 키보드가 제공된 옵션을 준수하지 않으면 일부 옵션이 보장되지 않을 수 있다.
아래는 지원되는 키보드 옵션 리스트다.
- capitalization
- autoCorrect
- keyboardType
- imeAction
이제 KeyboardOptions 클래스엔 TextFieldState와 통합된 TextField 컴포넌트에만 쓰는 새 Boolean 매개변수 showKeyboardOnFocus가 포함된다.
이 옵션은 TextField가 직접적인 유저 상호작용이 아닌 다른 방식(프로그래매틱 방식 등)을 통해 포커스 획득 시 소프트 키보드 동작을 관리한다.
KeyboardOptions.showKeyboardOnFocus가 true로 설정되면 TextField가 간접적으로 포커스를 얻으면 소프트웨어 키보드가 자동 표시되지 않는다. 이 경우 유저는 키보드를 표시하기 위해 TextField 자체를 명시적으로 탭해야 한다.
키보드 상호작용 로직 정의
안드로이드 소프트웨어 키보드의 작업 버튼(=엔터키)을 쓰면 앱 내에서 대화형 응답이 가능하다.
유저가 이 작업 버튼을 탭할 때 발생하는 상황을 정의하려면 onKeyboardAction 매개변수를 써라. 이 매개변수는 KeyboardActionHandler라는 선택적 함수형 인터페이스를 허용한다. KeyboardActionHandler 인터페이스엔 단일 메서드 onKeyboardAction()이 포함돼 있다. 이 함수의 구현을 제공하면 엔터키 클릭 시 실행되는 맞춤 로직을 도입할 수 있다.
여러 표준 키보드 작업 유형엔 기본 제공 동작이 있다. ImeAction.Next, ImeAction.Previous를 작업 유형으로 선택하면 기본적으로 포커스가 다음 / 이전 입력란으로 이동한다. ImeAction.Done으로 설정되면 키보드를 닫는다.
이 기능은 자동 실행되고 KeyboardActionHandler를 제공할 필요 없다.
이외에도 커스텀 동작을 구현할 수 있다. KeyboardActionHandler를 제공하면 onKeyboardAction()이 performDefaultAction()을 수신한다. 커스텀 로직 안에서 언제든 performDefaultAction()을 호출해서 현재 IME 작업과 연결된 표준 기본 동작도 트리거할 수 있다.
TextField(
state = textFieldViewModel.usernameState,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
onKeyboardAction = { performDefaultAction ->
textFieldViewModel.validateUsername()
performDefaultAction()
}
)
위 코드는 userName 필드가 있는 등록 화면의 일반 사용 사례다. 이 필드에선 엔터키에 ImeAction.Next가 선택된다. 이 선택사항을 사용하면 다음 비밀번호 필드로 이동할 수 있다.
이외에도 유저가 비밀번호를 치는 동안 유저 이름에 대한 백그라운드 유효성 검사 프로세스를 시작해야 한다. ImeAction.Next에 내재된 기본 포커스 전환 동작이 커스텀 검증 로직과 같이 유지되게 performDefaultAction()이 호출된다. 이걸 호출하면 기본 포커스 관리 시스템이 포커스를 적절한 다음 UI 요소로 이동하게 암시적으로 트리거되서 예상되는 네비게이션 흐름이 유지된다.
안전한 비밀번호 필드 만들기
SecureTextField는 비밀번호 필드를 작성하기 위해 상태 기반 TextField 위에 빌드된 컴포저블이다. 기본적으로 문자 입력을 숨기고 잘라내기, 복사 작업을 사용 중지해서 비밀번호 TextField를 만들 때 쓰는 게 좋다.
SecureTextField에는 유저가 문자 입력을 보는 방식을 제어하는 textObfuscationMode가 있고 이것의 옵션은 3개 있다.
- Hidden : 모든 입력을 숨김. 데스크톱 플랫폼의 기본 동작
- Visible : 모든 입력 표시
- RevealLastTyped : 마지막 문자를 제외한 모든 입력 숨김. 핸드폰에서의 기본 동작
아래는 이 문서에 추가 링크로 존재하는 문서들의 내용을 요약한 것이다.
TextField에서 전화번호 자동 형식 지정
아래는 문서에서 제공하는 예시 코드를 수정한 것이다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TestTextField(innerPadding)
}
}
}
}
}
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
var phoneNumber by rememberSaveable { mutableStateOf("") }
val numericRegex = Regex("[^0-9]")
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
value = phoneNumber,
onValueChange = { value: String ->
val stripped = numericRegex.replace(value, "")
phoneNumber = if (stripped.length >= 10) {
stripped.substring(0 .. 9)
} else {
stripped
}
},
label = Text(text = "전화번호 입력"),
visualTransformation = NanpVisualTransformation(),
KeyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
)
)
}
}
class NanpVisualTransformation: VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val trimmed = if (text.text.length >= 10) text.text.substring(0 .. 9) else text.text
var out = if (trimmed.isNotEmpty()) "(" else ""
for (i in trimmed.indices) {
if (i == 3) out += ")"
if (i == 6) out += "-"
out += trimmed[i]
}
return TransformedText(
AnnotatedString(out),
phoneNumberOffsetTranslator
)
}
private val phoneNumberOffsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int =
when (offset) {
0 -> 0
in 1 .. 3 -> offset + 1
in 4 .. 6 -> offset + 3
else -> offset + 4
}
override fun transformedToOriginal(offset: Int): Int =
when (offset) {
0 -> 0
in 1 .. 5 -> offset - 1
in 6 .. 10 -> offset - 3
else -> offset - 4
}
}
}
그러나 값 기반 TextField를 사용하는 방식이고 지금까지 확인한 상태 기반 TextField가 아니다. 정규식도 사용하고 이곳저곳에서 생소한 로직을 쓰고 있는데 컴파일 에러까지 발생한다.
이걸 상태 기반 TextField로 바꾸면 아래와 같다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.insert
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.text.isDigitsOnly
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TestTextField(innerPadding)
}
}
}
}
}
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
val textFieldState = rememberTextFieldState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = textFieldState,
label = { Text(text = "전화번호 입력") },
inputTransformation = DigitOnlyInputTransformation(),
outputTransformation = NanpOutputTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
)
)
}
}
class DigitOnlyInputTransformation: InputTransformation {
override fun TextFieldBuffer.transformInput() {
if (!asCharSequence().isDigitsOnly()) {
revertAllChanges()
}
}
}
class NanpOutputTransformation: OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val originalText = toString()
val trimmed = if (originalText.length >= 10) originalText.substring(0 .. 9) else originalText
if (trimmed.isNotEmpty()) {
replace(0, length, "")
var formatted = "("
for (i in trimmed.indices) {
if (i == 3) formatted += ")"
if (i == 6) formatted += "-"
formatted += trimmed[i]
}
insert(0, formatted)
}
}
}
숫자만 입력 가능하던 정규식은 이전에 본 InputTransformation을 재사용해서 제거했다. OutputTransformation은 디벨로퍼의 예시를 참고해서 이름을 바꾸고 Translator 안의 로직을 transformOutput() 안으로 옮겼다. 스페이스 바 하나만큼의 공백이 빠졌고 전화번호를 완성한 후 키보드에서 백스페이스를 눌러 지우면 전체 텍스트가 지워진다. 이 글을 찾아 볼 정도라면 어떻게 고치는지 알고 있을 것이다. 절대 내가 귀찮아서 그런 게 아니다
유저 전환에 따라 비밀번호 표시 또는 숨기기
이 문서의 예시 코드는 아래와 같다.
@Composable
fun PasswordTextField() {
val state = remember { TextFieldState() }
var showPassword by remember { mutableStateOf(false) }
BasicSecureTextField(
state = state,
textObfuscationMode =
if (showPassword) {
TextObfuscationMode.Visible
} else {
TextObfuscationMode.RevealLastTyped
},
modifier = Modifier
.fillMaxWidth()
.padding(6.dp)
.border(1.dp, Color.LightGray, RoundedCornerShape(6.dp))
.padding(6.dp),
decorator = { innerTextField ->
Box(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 16.dp, end = 48.dp)
) {
innerTextField()
}
Icon(
if (showPassword) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription = "Toggle password visibility",
modifier = Modifier
.align(Alignment.CenterEnd)
.requiredSize(48.dp).padding(16.dp)
.clickable { showPassword = !showPassword }
)
}
}
)
}
의존성은 문제가 없지만 이 상태로는 컴파일 에러가 발생한다. 이것을 outputTransformation을 사용하도록 바꾸면 아래와 같다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldBuffer
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TestTextField(innerPadding)
}
}
}
}
}
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
val state = rememberTextFieldState()
var showPassword by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TextField(
state = state,
label = { Text("비밀번호") },
outputTransformation = if (showPassword) {
null
} else {
PasswordObfuscationTransformation()
},
trailingIcon = {
IconButton(
onClick = { showPassword = !showPassword }
) {
Icon(
imageVector = if (showPassword) {
Icons.Filled.Visibility
} else {
Icons.Filled.VisibilityOff
},
contentDescription = "비밀번호 가시성 변경"
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password
),
modifier = Modifier
.fillMaxWidth()
.padding(6.dp)
)
}
}
class PasswordObfuscationTransformation : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val originalText = toString()
replace(0, length, "•".repeat(originalText.length))
}
}
유저가 입력할 때 입력값 검증
이 문서의 예시 코드는 아래와 같다.
class EmailViewModel : ViewModel() {
var email by mutableStateOf("")
private set
val emailHasErrors by derivedStateOf {
if (email.isNotEmpty()) {
// Email is considered erroneous until it completely matches EMAIL_ADDRESS.
!android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
} else {
false
}
}
fun updateEmail(input: String) {
email = input
}
}
@Composable
fun ValidatingInputTextField(
email: String,
updateState: (String) -> Unit,
validatorHasErrors: Boolean
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
value = email,
onValueChange = updateState,
label = { Text("Email") },
isError = validatorHasErrors,
supportingText = {
if (validatorHasErrors) {
Text("Incorrect email format.")
}
}
)
}
@Preview
@Composable
fun ValidateInput() {
val emailViewModel: EmailViewModel = viewModel<EmailViewModel>()
ValidatingInputTextField(
email = emailViewModel.email,
updateState = { input -> emailViewModel.updateEmail(input) },
validatorHasErrors = emailViewModel.emailHasErrors
)
}
역시 값 기반 TextField를 쓰고 있다. 뷰모델 로직은 딱히 고칠 게 없으니 UI만 수정했다.
이렇게 수정하면 디벨로퍼의 예시 GIF와 동일하게 작동한다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
TestTextField(innerPadding)
}
}
}
}
}
@Composable
fun TestTextField(
innerPadding: PaddingValues,
) {
val viewModel: TestViewModel = hiltViewModel()
val emailState = rememberTextFieldState()
LaunchedEffect(emailState) {
snapshotFlow { emailState.text.toString() }
.collect { text ->
viewModel.updateEmail(text)
}
}
LaunchedEffect(viewModel.email) {
if (emailState.text.toString() != viewModel.email) {
emailState.setTextAndPlaceCursorAtEnd(viewModel.email)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
ValidatingInputTextField(
state = emailState,
validatorHasErrors = viewModel.hasEmailError
)
}
}
@Composable
fun ValidatingInputTextField(
state: androidx.compose.foundation.text.input.TextFieldState,
validatorHasErrors: Boolean
) {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
state = state,
label = { Text("이메일") },
isError = validatorHasErrors,
supportingText = {
if (validatorHasErrors) {
Text("올바르지 않은 이메일 형식입니다")
}
}
)
}
예시 코드를 고친 것이기 때문에 에러가 있을 수 있으니 실제로 사용하지 말고 리팩토링해서 사용하는 게 좋다.
이외에도 디벨로퍼를 찾아보면 다른 예시들이 있으니 그것들도 상태 기반 TextField로 바꾸는 연습을 해 보면 좋을 것이다.
https://developer.android.com/develop/ui/compose/quick-guides/collections/display-text?hl=ko
표시 텍스트 | Jetpack Compose | Android Developers
텍스트를 표시하는 방식을 맞춤설정하여 앱의 사용성 및 미적 매력을 향상하세요.
developer.android.com
https://developer.android.com/develop/ui/compose/quick-guides/collections/request-user-input?hl=ko
사용자 입력 요청 | Jetpack Compose | Android Developers
사용자가 텍스트 및 기타 입력을 할 수 있도록 하여 앱을 대화형으로 만듭니다.
developer.android.com
'Android > Compose' 카테고리의 다른 글
[Android Compose] 폰트 적용하기 (0) | 2025.08.31 |
---|---|
[Android Compose] Text 사용법 (0) | 2025.07.27 |
[Android Compose] 드래그로 사진 선택 구현하는 법 (0) | 2025.01.26 |
[Android Compose] Supabase를 활용한 CRUD 구현 - 2 - (0) | 2025.01.11 |
[Android Compose] Supabase를 활용한 CRUD 구현 - 1 - (0) | 2025.01.06 |