Shunz Android Dev Note

Compose BasicTextField2 - 꿈의 텍스트필드 [1/2] 본문

Compose

Compose BasicTextField2 - 꿈의 텍스트필드 [1/2]

_Shun_ 2023. 11. 22. 22:10

 

 

2부로 구성된 이 게시글은 Jetpack Compose에서 제공하는 TextField에 대하여 자세히 다룹니다. 과거, 현재 그리고 미래에 어떻게 변화가 되는지 전반적으로 다루도록 합니다. 새롭게 도입된 BasicTextField2에 대하여 알아보도록 해봐요 :-)

 

 

요약

구글의 Compose F/W팀에 속한 Text 관련 개발 팀은 차세대 TextField API를 개발하고 있습니다. 지금 당장 사용 해볼 수 있는데요. BasicTextField2는 text2 패키지에 속해 있는 최신 Foundation 1.6.0 알파 버전으로 이용이 가능합니다. 2부 게시글의 마지막에 명시한 다양한 채널을 통해서 개발팀에게 피드백을 남겨주세요.

 


경고

Compose는 아래와 같은 레이어로 빌드가 됩니다. 

Material -> Foundations -> UI

 

TextField와 OutlinedTextField는 foundations 레이어에 있는 BasicTextField2의 위에서 스타일을 추가 할 수 있는 Material 구성 요소입니다. 이 글에서는 BasicTextField에서 BasicTextField2로 어떤점들이 개선되는지를 중심으로 설명하겠습니다. 참고로, BasicTextField2라는 네이밍은 API가 개발되는 동안에 임시로 사용하는 이름임을 알아주시기 바랍니다.

Compose 레이어와 각각의 API에 대한 블록 다이어그램

 

과거

Jetpack Compose에서 텍스트 필드를 구현하기 위한 APIs로 아래와 같이 TextField를 사용하였습니다.

var textField by rememberSaveable { mutableStateOf("") }
TextField(
  value = textField,
  onValueChange = { textField = it },
)

 

이 간단한 API는 다음과 같이 몇가지 단점을 가지고 있습니다.

 

onValueChange Callback을 사용하여 BasicTextField 상태를 업데이트하는 프로세스에 비동기 동작을 도입하는 것은 너무 쉽기 때문에 불규칙하고 예기치 않은 동작을 유발합니다. 이 문제를 설명하기에는 조금 복잡하기 때문에 본 게시글에서 자세히 다루지는 않고, 아래 블로그 게시글 링크로 대체하겠습니다.

https://medium.com/androiddevelopers/effective-state-management-for-textfield-in-compose-d6e5b070fbe5

 

Effective state management for TextField in Compose

TL;DR — The Compose roadmap reflects the work that the team is doing on multiple fronts, in this case Text Editing Improvements and…

medium.com

 

 

VisualTransformation은 개발자에게 많은 혼란을 주고, 버그를 양산하는 큰 원인입니다.
전화번호 문자열의 포매팅을 예로 들 수 있습니다. 일반적으로 전화번호를 입력할 때 공백, 대시 그리고 괄호를 추가하여 전화번호를 수정해야 합니다. VisualTransformation API를 사용하여 이를 구현하려면 초기 문자와 변환된 문자의 위치 간의 매핑을 지정해야 합니다.

 

 

이러한 매핑 코드를 작성하는 과정은 쉽지 않습니다.

VisualTransformation을 확장한 전화번호 포매팅

 

private val phoneNumberFilter = VisualTransformation { text ->
    val trimmed = if (text.text.length >= 10) text.text.substring(0..9) else text.text
    val filled = trimmed + "_".repeat(10 - trimmed.length)
    val res = "(" + filled.substring(0..2) + ") " + filled.substring(3..5) + "-" +
        filled.substring(6..9)
    TransformedText(AnnotatedString(text = res), phoneNumberOffsetTranslator(text.text))
}

 

ValidatingOffsetMapping 클래스가 도입되었는데 그것의 목적은 VisualTransformation을 검증하는것과 매핑이 정확하지 않으면 의미 있는 에러 문구와 함께 Exception을 던지는 것입니다. 이는, 개발자에게 더 많은 정보를 제공하여 Crash 분석을 용이하게 하는 편리한 디버깅 도구로 개발이 되었습니다. 이러한 업데이트 이전에는 문제를 찾는 데 도움이 되는 정보가 거의 없는 상태에서 Crash가 발생되었고 디버깅하기에 다소 불편하였습니다. 하지만 이 업데이트가 근본적인 문제를 해결하지는 못했습니다.

 

이 밖에도, 전반적으로 개선되어야 할 TextField API가 많이 존재합니다. 예를 들어, TextField에서 한줄만 표시하도록 하는 설정은 매우 쉽습니다. 우리는 단지 "singleLine = true" 만 추가하면 됩니다. 하지만, 아래처럼 코드를 작성한다면 결과가 어떻게 되는지 불분명합니다. 가독성에 매우 좋지 않죠.

TextField(
  value = textField,
  onValueChange = { textField = it },
  singleLine = true,
  minLines = 2,
  maxLines = 4
)

 

 

게다가 현재 API는 편집 과정에서 정확히 무엇이 바뀌었는지 쉽게 식별할 수 없기 때문에 개발자에게 전체적인 diff를 남겨줍니다(숙제를 남겨줍니다.). 각 사용자가 문서 협업 도구에서 변경한 내용의 목록을 보여주고 싶다고 상상해 보세요.

TextField(
  value = text,
  // 여기에서 정확히 무엇이 바뀌었나요? 이전 값과 새 값만 있습니다.
  onValueChange = { newFullText -> /***/ }
)

 

이 모든 것과 몇 가지 다른 제한 사항을 고려하여, Compose 개발팀은 이상적인 텍스트 필드 API의 모양을 상상하면서 일반적인 텍스트 필드 사용 사례를 브레인스토밍하기 위해 모였습니다.

 

다음 섹션에서는 이러한 아이디어가 어떻게 BasicTextField2로 실현되고 구현되었는지 살펴보겠습니다.

 

현재

상태 정의

 

회원가입을 하는 화면입니다. username 필드의 경우 텍스트 필드를 정의하기 전에 다음과 같은 작업을 수행합니다:

var username by rememberSaveable { mutableStateOf("") }
BasicTextField(
    value = username,
    onValueChange = { username = it },
)

 

 

새로운 API인 BasicTextField2는 아래와 같이 동작합니다.

val username = rememberTextFieldState()
BasicTextField2(state = username)

 

더이상 콜백을 가지고 있지 않은것을 확인 할 수 있습니다. 이점은 위에서 설명한 비동기 작업을 할때 발생하는 실수를 하지 않도록 도와줍니다.

 

 

rememberTextFieldState를 사용하여 TextFieldState 유형의 state 변수를 정의합니다. 편의상 initialText를 구성할 수 있고, 초기 선택 및 커서 위치를 설정할 수 있습니다. 이 API로 정의된 상태는 재구성, 설정 및 프로세스 중단 후에도 유지됩니다.

rememberTextFieldState를 사용하는것은 state를 인스턴스화하는 친숙한 방법입니다. 예를 들어, ScrollState를 위한 LazyListState 또는 rememberScrollState를 정의하여 rememberLazyListState를 사용하는 것입니다.

 

만약 여러분이 state에 비즈니스 룰을 적용하고 싶다거나, ViewModel에 상태 호이스팅을 적용하고 싶다면 아래처럼 TextFieldState 타입의 변수를 정의해야 합니다.

// ViewModel의 멤버 변수 코드
val username = TextFieldState()

// Compose 코드
BasicTextField2(state = viewModel.username)

 

 

스타일링

BasicTextField2의 Material 래퍼들은 아직 준비되지 않았습니다. (BasicTextField2는 foundations 레이어에 위치하고 있습니다.) 색상, 폰트 사이즈, 라인 높이 등을 변경하기 위해서는 TextStyle 코드 블록을 사용해보세요. 또는, modifier 시스템도 사용해보세요. 예를들어, border modifier을 사용하면 아래와 같은 코드를 통해 스타일을 구현 할 수 있습니다. 혹은 decorator 파라메터를 사용하면 텍스트 필드의 컨테이너를 조금 더 보기 좋게 커스텀 할 수 있습니다. (OutlinedTextField가decorationBox을 사용하여 이를 구현할 수 있는것과 유사합니다).

val username = rememberTextFieldState()
BasicTextField2(state = username,
    textStyle = textStyleBodyLarge,
    modifier = modifier.border(...),
    decorator = @Composable {}
)

 

라인수 제한

새로운 API는 싱글/멀티 라인을 구성하기 위한 파라메터의 모호성을 제거하였습니다. 이는 TextFieldLineLimits 타입의 lineLimits 파라메터에 의해 제공이 됩니다.

BasicTextField2(
    /***/
    lineLimits = TextFieldLineLimits.SingleLine
)

BasicTextField2(
    /***/
    lineLimits = TextFieldLineLimits.Multiline(5, 10)
)

 

SingleLine : 텍스트 필드는 항상 한줄로 표시됩니다. 즉, 개행 문자를 무시하고, 문자열이 긴 경우에는 수평 스크롤바가 생성되어 이를 표시합니다.

 

Multiline : 최소, 최대 라인수를 정의합니다. 텍스트는 minHeightInLines 로 시작하여 필드 끝에 도달하면 텍스트는 다음 줄로 넘어갑니다. 계속 입력하면 텍스트는 maxHeightInLines 가 될때까지 증가합니다. 계속해서 입력을 하면 세로 스크롤이 활성화가 됩니다.

 

상태 관찰

username 상태를 어떻게 검증하는지 살펴보겠습니다.

API : textAsFlow

 

선택한 사용자의 이름을 사용할 수 없을 때 오류를 표시하고 싶다고 가정해 보겠습니다.

 

 

아래와 같은 코드로 작성을 할 수 있을 것입니다.

// ViewModel 에 구현된 코드들

// TextFieldState를 composable의 바깥 영역인 viewmodel로 호이스팅합니다.
val username = TextFieldState()

// Observe changes to username text field state
val userNameHasError: StateFlow<Boolean> =
    username.textAsFlow()
        .debounce(500)
        .mapLatest { signUpRepository.isUsernameAvailable(it.toString()) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = false
        )

 

username에 대한 유효성 검사 결과에 따라 true/false를 나타내는 userNameHasError를 정의합니다.

 

snapshotFlow API를 사용하여 TextFieldState 내부의 가변 상태 텍스트와 비동기적인 검증을 호출할 수 있는 모든 새로운 값을 관찰 할 수 있습니다.

snapshotFlow { username.text }

 

snapshotFlow로 컴포즈 상태값을 관찰하는 이 방법은 매우 흔하게 사용되는 일반적인 기법입니다. 그래서 BasicTextField2 API는 단순화를 위해 동일한 작업을 수행하는 textAsFlow 메서드를 제공하므로 우리는 이 확장 함수를 호출할 수 있습니다.

Flow API에서 debound 메서드를 사용하여 검증을 시작하기 전에 대기하고 사용자가 입력을 마칠 수 있는 버퍼 시간을 제공합니다.

그런다음 mapLatest 메서드를 사용하여 새 문자를 입력할 때마다 유효성 검사를 호출합니다. 마지막으로 stateIn은 이 상태를 StateFlow로 변환합니다.

그런 다음 UI에서 userNameHasError 값을 읽습니다. 이 상태가 사실이면 사용자 이름 아래에 Text가 표시됩니다.

// Compose
    if (signUpViewModel.userNameHasError) {
    // 에러 레이블 표시
}

 

API: forEachTextValue

입력한 각 문자에 대해 비동기 작업을 수행하는 또 다른 편리한 대안은 textAsFlow().collectLatest(block)를 사용하고 비동기로 유효성 검사를 수행하는 것입니다. 이에 대한 또 다른 확장 기능인 forEachTextValue는 각 값의 변경에 대한 요청을 수행할 수 있는 중단 함수 스코프(suspend function scope)를 제공합니다.

 

validateUsername 메서드에 중단 함수를 적용하여 우리는 샘플 코드를 다시 작성 할 수 있습니다.

그런 다음 각각의 새 텍스트 값에 대해 비동기 요청을 수행하고 userNameHasError 값을 설정합니다. 이런 동작은 viewModel 안에서 일어납니다. 컴포즈에서 타이핑 이벤트를 감지하기 위하여 LaunchedEffect를 정의합니다. 그리고 뷰를 변경하기 위하여 userNameHasError 상태를 관찰합니다.

 

코드는 아래와 같은 형태로 다시 작성을 할 수 있습니다.

// ViewModel
var userNameHasError by mutableStateOf(false)
suspend fun validateUsername() {
    username.forEachTextValue {
        userNameHasError = signUpRepository.isUsernameAvailable(it.toString())
    }
}


// Compose
LaunchedEffect(Unit) {
    signUpViewModel.validateUsername()
}

if (signUpViewModel.userNameHasError) {
    // show error label
    Text(
        text = "Username not available. Please choose a different one."
    )
}

 

 

onValueChange 재정의

 

상태 변화를 관찰하는 또 다른 방법을 살펴 보겠습니다. BasicTextField2는 아래와 같은 방법처럼 상태를 정의하는것을 허용합니다.

var username by rememberSaveable { mutableStateOf("") }

BasicTextField2(
    value = username,
    onValueChange = {
        username = it
    }
)

 

이는 현재 API BasicTextField v1과 정확히 동일한 모양으로, 문자열만 있는 단순한 경우에 대한 편의를 위해 이 두 API 간의 전환을 용이하게 합니다. 이 코드는 서두에서 설명한 TextField의 단점을 잘못해서 그대로 재현할 수도 있을텐데요. 어떻게 개선할 수 있는지 궁금하지 않으세요?

BasicTextField1 과 BasicTextField2가 비슷한 API 처럼 보이지만, 매우 다르게 동작을 합니다.

BasicTextField2(
    // BasicTextField2에 포커스가 되어 있으면 무시됩니다.
    value = username,
    onValueChange = {
        // ***  잠재적인 비동기 작업 *** //

        username = it
    }
)

 

람다 내부에서 발생하는 모든 일은 항상 호출되므로, 어떠한 비동기 작업을 하더라도 콜백은 항상 호출이 됩니다. 하지만, 텍스트 필드가 입력이 진행되는 동안에 프로그램적인 변경과 관련한 상태는 업데이트 하지 않고, 오직 사용자로부터 입력하는 이벤트에만 관심을 둡니다. 결과적으로 사용자는 람다 내부에 적용된 어떤 변경 사항도 UI에 반영되지 않을 것입니다. 필드는 항상 컨트롤을 사용하여 비동기 문제를 방지하고 신속하고 반응성이 좋습니다. 필드가 포커스를 잃었을 때에만 마지막으로 수행한 모든 프로그램 변경 사항이 적용됩니다.

이 내부 메커니즘은 편집 과정에서 텍스트 필드 상태의 무결성을 보장합니다. 왜냐하면 필요한 경우, 프로그램적 변경 사항을 무시하고 항상 하나의 진실된 소스(IME 또는 개발자)를 유지하기 때문입니다. 그와 반대로, 특정 시점에서 변경 사항이 UI에 반영될 것으로 예상 할 수 있지만 그렇지 않을 것입니다.

 

문자열 값이 상태를 나타내기에 충분하고 텍스트 필드에 초점이 맞춰진 상태에서 제어가 필요하지 않을 때(예: 이동 중인 텍스트, 선택 또는 커서 수정), 가장 간단한 경우에만 이 API 형태을 사용하는것이 좋습니다.

더 복잡한 경우에는 TextFieldState에서 이미 다른 API 형태를 사용해보세요.

 

프로그램적으로 텍스트 편집

텍스트 필드의 내용을 수동으로 조작해야 하거나 조작하고자 하는 경우가 많습니다. 가장 간단한 예는 텍스트 필드의 내용을 지우는 버튼을 구현하는 코드입니다.

 

Edit 세션에 접근하여 텍스트 필드 내용을 프로그램적으로 변경하기 위하여 TextFieldBuffer라는 새로운 API를 사용합니다.

class TextFieldBuffer : Appendable {

    val length: Int

    val hasSelection: Boolean
    var selectionInChars: TextRange


    fun replace(...)
    fun append(...)
    fun insert(...)
    fun delete(...)

    fun selectAll(...)
    fun selectCharsIn(...)
    fun placeCursorAfterCharAt(...)

}

 

이 클래스는 텍스트의 길이나 선택 영역등의 상태에 대한 정보를 가지고 있습니다. 또한, 문자열의 치환, 추가, 삭제도 할 수 있고, 커서의 위치도 변경할 수 있습니다.

 

Clear 버튼을 구현하기 위해, 우리는 아래와 같은 코드를 작성할 수 있습니다.

// ViewModel
val username = TextFieldState()

fun clearField() {
    username.edit {
        // TextFieldBuffer 영역입니다.
       delete(0, length)
    }
}

 

여기서 우리는 편집 기능을 사용하여 TextFieldBuffer에 접근하고 거기서 우리가 설명한 모든 메소드에 접근할 수 있습니다. 위의 예제에서 우리는 텍스트 필드 상태의 내용을 삭제하고 있습니다. 그리고 이 메소드는 매우 일반적이어서 BasicTextField2에서 clearText라는 확장 함수로도 제공을 해주고 있습니다. 이를 사용하면 다음과 같이 조금 더 코드를 간소화 시킬 수 있습니다.

// ViewModel
val username = TextFieldState()

fun clearField() {
    username.clearText()
}

 

또 다른 예로, 마크다운 텍스트 편집기(예: Github의 PR 설명)가 있다고 가정하고, 선택한 텍스트를 "**" 문자로 래핑하여 이 텍스트가 굵게 표시되도록 합니다:

// Compose
val markdownText = rememberTextFieldState()
BasicTextField2(
    state = markdownText
)

// ...
Button(
    onClick = {
        markdownText.edit {
            if (selectionInChars.length > 0) {
                insert(selectionInChars.start, "**")
                insert(selectionInChars.end, "**")
            }
        }
    },
) {
    Text(text = "B", fontWeight = Bold)
}

 

TextFieldBuffer에 대한 markdown mutator를 적는 방법에 대한 자세한 내용은 이 글을 참조하십시오.

 

또는 에러가 발생 했을때 모든 텍스트를 선택하도록 할 수 있습니다.

// ViewModel
val username = TextFieldState()

val userNameHasError: StateFlow<Boolean> =
    username.textAsFlow()
        .mapLatest {
            val hasError = signUpRepository.isUsernameAvailable(it.toString())
            if (hasError) highlight()
            return@mapLatest hasError
        }
        .stateIn(...)


fun highlight() {
    username.edit { selectAll() }
}

 

 

이 시점에서 UI에 텍스트를 편집하도록 명령하는 이러한 방식이 선언형 UI 패러다임에 어긋난다는 것을 깨달을 수 있습니다. UI를 업데이트할 목적으로 실제 상태를 관찰하는 것은 없습니다. 이것은 전적으로 내부 구성 요소에 맡깁니다.

 

계속...

 

지금까지 다룬 방법은 아래와 같습니다.

  • 상태 동기화 문제를 방지하기 위해 새로운 API로 상태를 관리
  • 데코레이터와 라인 제한을 포함한 기본 스타일링
  • 상태를 관찰하고 비즈니스 규칙을 적용할 때 일반적인 시나리오를 해결
  • TextFieldBuffer를 사용하여 프로그래밍 방식으로 텍스트 필드 값 편집

Github Repo에서 이 게시물에 사용된 모든 코드를 찾아보세요.

 

 


 

 

본 포스팅은 이 게시글을 번역한 글입니다.

해당 게시글은 droidcon 컨퍼런스를 토대로 작성되었습니다.