앱 기본사항

Compose에서 안정적인 텍스트 편집 환경 빌드

스피커

  • 아나스타샤 소볼레바

    소프트웨어 엔지니어

  • 하오위 장

    선임 소프트웨어 엔지니어

정보

강연 소개

Compose에서 안정적인 텍스트 편집을 구현하기 위한 여정은 BasicTextField를 완전히 다시 작성하는 것으로 마무리되었습니다. 버퍼 기반 아키텍처로 전환하면서 IME 경합 상태가 완전히 사라졌습니다. 이 세션에서는 기존 입력 동기화를 중단해야 했던 이유와 TextFieldState가 키보드 업데이트를 동기화 상태로 유지하는 방법을 알아봅니다. 그런 다음 형식 지정 편집을 심층 분석하여 TrackedRange의 메커니즘, 편집 블록의 성능 비용, 스타일 경계 뒤에 숨겨진 실제 문제점을 살펴보고 마크다운 렌더링을 미리 살펴봅니다.

요약

  • TextFieldState API 오버로드는 Compose의 콜백 기반 오버로드를 대체합니다. 사용자 입력과 동기식으로 업데이트되므로 소프트웨어 키보드의 타이밍 문제와 불필요한 재구성이 발생하지 않습니다.
  • 입력 및 출력 변환을 사용하면 TextFieldBuffer를 사용하여 텍스트를 조작할 수 있습니다. 수정사항을 커밋하기 전에 가로채거나 오프셋 매핑을 수동으로 계산하지 않고 표시 텍스트의 형식을 지정할 수 있습니다.
  • TextFieldState 호이스팅은 아키텍처 요구사항에 따라 다릅니다. 컴포저블에 로컬로 보관하거나 비즈니스 로직 액세스를 위해 ViewModel에 직접 저장할 수 있습니다.
  • Compose 1.12에서는 TextFieldBuffer에 서식 있는 텍스트 수정 API를 추가합니다. 맞춤설정 가능한 ExpandPolicy 옵션으로 스타일을 적용할 수 있으며, 사용자가 수정하면 기존 스타일 범위가 자동으로 업데이트됩니다.
  • Jetpack Compose용 새 마크업 텍스트 라이브러리가 현재 개발 중입니다. 실시간 편집 및 스트리밍을 위한 증분 파싱 및 렌더링을 통해 마크다운과 같은 형식을 지원합니다.

리소스

Gemini Notebook

주제

  • jetpack-compose
  • textfieldstate
  • text-editing
  • basictextfield
  • formatted-text
  • input-transformation

스크립트

Compose의 텍스트 편집기 소개

  • Hi, everyone!

  • 강연에 참석해 주셔서 감사합니다.

  • 제 이름은 아나스타샤이고, 동료인 하오위도 함께 있습니다.

  • 오늘은 안정적인 텍스트 편집 환경을 빌드하는 방법을 보여드리겠습니다.

  • 간단한 로그인 화면, 입력 시 검색되는 검색창, 복잡한 메시지 앱 등 무엇을 빌드하든 텍스트 필드는 사용자가 Android 앱과 상호작용하는 방식의 핵심입니다.

  • 따라서 대부분의 개발자는 이미 Material 텍스트 필드나 윤곽선이 있는 텍스트 필드를 사용하고 있을 것입니다.

    TextField(state = rememberTextFieldState())
    OutlinedTextField(state = rememberTextFieldState())
  • 자체 디자인 시스템을 빌드하거나 간단한 편집 상자가 필요한 경우를 위한 BasicTextField도 있습니다.

    BasicTextField(state = rememberTextFieldState())
  • 비밀번호를 입력하도록 특별히 설계된 SecureTextField도 있습니다.

    SecureTextField(state = rememberTextFieldState())
  • BasicTextField는 스타일이 지정되지 않은 구성요소이며 실제로 Compose의 다른 모든 텍스트 필드를 지원합니다.

  • 지난 몇 년 동안 두 가지 API 형태가 병렬로 존재한다는 것을 눈치채셨을 것입니다. 하나는 값과 값 변경 콜백을 사용하고 다른 하나는 TextFieldState 객체를 사용합니다.

    @Composable
    public fun BasicTextField(
        state: TextFieldState,
    )
  • 그렇다면 어떤 것을 추천하는지 궁금하실 수 있습니다.

  • TextFieldState 오버로드를 사용하는 것이 좋습니다.

  • 이 세션에서는 API, 기능, 성능 측면에서 이점이 무엇인지 설명합니다.

  • Compose 1.7부터 안정화되었습니다.

  • 콜백 오버로드의 경우 Compose 1.13에서 BasicTextField 및 Material 래퍼 전반에 걸쳐 소프트 지원 중단됩니다.

    @Composable
    public fun BasicTextField(
        value: String,
        onValueChange: (String) -> Unit
    )
    
    @Composable
    public fun BasicTextField(
        value: TextFieldValue,
        onValueChange: (TextFieldValue) -> Unit
  • 즉, 중요한 보안 문제나 접근성 문제가 아닌 한 새로운 기능을 추가하거나 버그를 수정하지 않을 것입니다.

  • 값 변경 오버로드의 스테이트리스(Stateless) 값은 동기 입력에 매우 효과적이었습니다.

  • 하지만 비동기 스트림(예: StateFlow)과 페어링하자마자 IME 또는 소프트웨어 키보드(기술 용어)에 타이밍 문제가 발생했습니다.

  • 이로 인해 이 녹화에서 볼 수 있듯이 문자가 누락되거나 중복되고 커서가 점프하는 현상이 발생했습니다.

TextFieldState 소개

  • 이 문제를 근본적으로 해결하기 위해 TextFieldState를 도입했습니다.

  • 사용자가 입력할 때 비동기 왕복을 기다리지 않고 동기식으로 업데이트되므로 IME에 필요한 기능입니다.

  • TextFieldState에는 읽기 전용 텍스트 외에도 활성 편집 세션을 만드는 다른 속성이 있습니다.

  • 선택, 실행취소, 다시 실행 상태, 서식 있는 텍스트 편집을 위한 스팬, 스팬 스타일, 단락 스타일이 있습니다. 이에 관해서는 이 강연의 뒷부분에서 자세히 설명하겠습니다.

  • TextFieldState의 내부 속성은 Compose 스냅샷 시스템에서 지원합니다.

  • 즉, Compose의 어딘가에서 TextFieldState 객체의 텍스트나 선택 항목을 읽으면 읽은 특정 범위가 업데이트를 구독합니다.

  • 이를 통해 BasicTextField는 레이아웃 또는 그리기 단계에서만 상태를 읽을 수 있으며, 일부와 함께 사용하면 사용자가 입력할 때 불필요한 리컴포지션을 제거합니다.

  • 다른 변경사항과 함께 TextFieldState 오버로드의 성능이 크게 개선되었습니다.

  • 이제 TextFieldState의 텍스트가 읽기 전용 속성인 경우 입력란을 프로그래매틱 방식으로 변경하려면 어떻게 해야 할까요?

  • 이때 TextFieldBuffer가 유용합니다.

  • 변경 가능한 객체로, 변경을 위한 API를 제공합니다.

  • 따라서 프로그래매틱 방식으로 변경하려면 TextFieldState의 edit 함수를 호출합니다.

  • 이 람다 내에서 TextFieldBuffer를 가져옵니다.

  • TextFieldBuffer는 필요에 따라 문자를 삽입, 교체, 삭제할 수 있는 여러 함수를 노출합니다.

  • 수정과 관련된 한 가지 간단한 팁은 연속으로 여러 번 호출하지 않는 것입니다.

    // Often generated by AI agents:
    state.edit { insert(5, ",") }
    state.edit { delete(12, 13) }
    state.edit { replace(0, 5, "goodbye") }
    state.edit { selection = TextRange(9, 16) }
  • 대신 모든 작업을 단일 수정 호출로 일괄 처리하세요.

    // Batch all operations in a single atomic edit:
    state.edit {
    insert(5, ",")
    delete(12, 13)
    replace(0, 5, "goodbye")
    selection = TextRange(9, 16)
    }
  • 이렇게 하면 Compose는 TextFieldBuffer의 단일 인스턴스만 만들고, 이를 제자리에서 변경한 다음, IME 및 스냅샷 시스템과 한 번 동기화합니다.

  • 또 다른 팁은 필요한 대부분의 일반적인 일괄 작업에 대해 실제로 TextFieldState에 확장 함수가 있다는 것입니다.

    fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
    edit {
    replace(0, length, text)
    placeCursorAtEnd()
    }
    }
  • 따라서 이러한 작업을 일괄 처리하는 것에 대해 생각할 필요가 없습니다.

  • 그중 하나를 호출하면 됩니다.

  • 예를 들면 clearText 및 setTextAndPlaceCursorAtEnd입니다.

    fun TextFieldState.clearText() {
    edit {
    delete(0, length)
    placeCursorAtEnd()
    }
    }

입력 필터링 및 형식 지정

  • 그렇다면 사용자 입력을 필터링하는 것은 어떨까요?

  • 예를 들어 글자 수 제한을 적용하거나 숫자만 허용하는 경우입니다.

  • 이전 API에서는 텍스트가 이미 커밋된 후에 onValueChange의 필터링이 실행되었습니다.

    onValueChange { newString ->
      // full diffing new String vs old String
    }
  • 따라서 입력을 거부하거나 변경하는 것은 키보드와 싸우는 것과 같았습니다.

  • 또한 onValueChange 콜백은 최종 문자열만 제공했습니다.

  • 따라서 변경사항을 파악하려면 전체 문자열 비교를 수행해야 했습니다.

  • 대신 새 오버로드에는 입력 변환이 있습니다.

    @Stable
    public fun interface InputTransformation {
        public fun TextFieldBuffer.transformInput()
    }
  • 따라서 사용자 입력이 텍스트 필드 상태에 도달하기 전에 가로채고 동일한 TextFieldBuffer API를 제공합니다.

  • 이것이 입력 변환을 사용하여 숫자 전용 필터링을 구현하는 방법입니다. 기본적으로 변경사항을 가져와서

    // Intercept and filter input during typing
    inputTransformation = InputTransformation {
        val text = asCharSequence()
        if (!text.all { it.isDigit() }) {
            // Reject non-digit characters
            revertAllChanges()
        }
    }
  • 숫자가 아닌 문자가 있는지 확인한 다음, 숫자가 아닌 문자가 있으면 TextFieldBuffer에서 revertAllChanges를 호출하여 입력을 거부합니다.

  • 이 입력 변환의 핵심 기능은 ChangeList입니다.

  • 따라서 전체 문자열을 비교하는 대신 정확한 수정 범위를 검사한 다음 필요에 따라 해당 위치에서 수정하거나 입력을 완전히 거부할 수 있습니다.

  • Compose는 키보드와 커서를 자동으로 동기화합니다.

  • 마지막으로, 입력의 시각적 서식을 지정하려면 어떻게 해야 할까요?

  • 예를 들어 신용카드 번호나 전화번호에 괄호나 대시를 추가하려는 경우입니다.

  • 와

  • 이러한 데이터를 데이터베이스에 저장하고 싶지 않으시죠?

  • 이전에는 시각적 변환 API를 사용했습니다.

  • 이 경우 원시 입력에서 시각적 출력으로, 그리고 다시 원시 입력으로 양방향 매핑을 구현해야 했습니다.

  • 이를 제대로 구현하기가 매우 어려웠고, 이로 인해 런타임 예외가 발생하는 경우가 많았습니다.

  • 이제 출력 변환이 있고, 내부적으로는 동일한 TextFieldBuffer를 사용하므로 거의 동일한 API를 얻을 수 있습니다.

    @Stable
    public fun interface OutputTransformation {
        public fun TextFieldBuffer.transformOutput()
    }
  • 이 전화번호 예시에서는 특정 색인에 대시를 삽입하기 위해 TextFieldBuffer의 insert 함수를 호출하기만 하면 시스템이 양방향 오프셋 매핑을 계산합니다.

    // Raw data untouched; formatting is purely visual
    outputTransformation = OutputTransformation {
        if (length > 6) {
            insert(6, "-")
        }
        if (length > 3) {
            insert(3, "-")
        }
    }
  • 따라서 수동 계산이 필요 없고 비정상 종료 횟수가 크게 줄어듭니다.

TextFieldState 호이스팅 및 이전

  • 이제 가장 중요한 부분입니다. 실제로 어떻게 이전하고 TextFieldState를 어디에 호이스팅하나요?

  • 실제로 거의 모든 사용 사례는 제가 설명할 카테고리 중 하나에 속합니다.

  • 그렇지 않은 경우 일반적으로 비정상적인 아키텍처가 있다는 의미이지만, 이러한 예시는 여전히 마이그레이션을 안내합니다.

  • 가장 간단한 방법은 텍스트 필드가 있는 컴포저블 내부에 TextFieldState를 유지하는 것입니다.

    @Composable
    fun PromoCodeInput(onApply: (String) -> Unit) {
        // State hoisted locally inside the composable
        val state = rememberTextFieldState()
    
        OutlinedTextField(
            state = state,
            inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
            trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
        )
    }
  • 텍스트 입력란이 독립적인 입력란인 경우, 예를 들어 프로모션 코드 입력란인 경우 사용자가 제출 버튼을 누를 때만 해당 텍스트에 액세스하면 됩니다.

  • 따라서 뷰 모델은 변경사항이 있을 때마다 사용자 입력을 가로챌 필요가 없습니다.

  • 따라서 로컬에서 rememberTextFieldState를 호출하기만 하면 됩니다. rememberTextFieldState는 프로세스 종료 후 상태 복원에도 도움이 됩니다.

  • 그런 다음 입력 변환을 사용하여 필터링하고, TextFieldState 객체에서 텍스트를 가져오고, 사용자가 제출하면 람다를 통해 전달합니다.

  • 그렇다면 문자 수 카운터나 제출 버튼과 같은 동위 UI 요소도 텍스트를 읽거나 변경해야 하는 경우에는 어떻게 해야 할까요?

  • 이렇게 하면 TextFieldState 객체를 공통 컴포저블 함수, 즉 공통 상위 컴포저블 함수로 호이스팅하는 경우가 발생합니다.

    @Composable
    fun PostCommentBar(onSubmit: (String) -> Unit) {
        // to share between UI components
        val commentState = rememberTextFieldState()
    
        Column {
            CommentTextField(commentState)
            Row {
                Text("${commentState.text.length} / 280")
                SubmitButton(commentState)
            }
        }
  • 또한 TextFieldState는 스냅샷 시스템에서 지원되므로 상위 요소를 통해 전달해도 상위 요소의 리컴포지션이 트리거되지 않습니다.

  • 상태의 텍스트를 실제로 읽는 위치만 업데이트됩니다.

  • 좋습니다.

  • 따라서 TextFieldState, 입력 및 출력 변환이 내부적으로 상태를 조정해야 하는 복잡한 UI 사용 사례의 경우, 이를 별도로 호이스팅하고 컴포저블 함수에서 함께 연결하려고 시도하는 대신 상태 홀더 객체를 사용하세요.

    @Composable
    fun PostEditor() {
    
        // Hoisting and wiring related pieces separately in the composable
        val textFieldState = rememberTextFieldState()
        val tagRanges = remember { mutableStateListOf<TextRange>() }
        val inputTransform = remember { TagInputTransformation(tagRanges) }
        val outputTransform = remember { TagOutputTransformation(tagRanges) }
    
        BasicTextField(
            state = textFieldState,
            inputTransformation = inputTransform,
            outputTransformation = outputTransform,
        )
    }
  • 인라인 해시태그를 처리한다고 가정해 보겠습니다.

  • TextFieldState는 원시 텍스트와 커서 위치를 보유합니다.

  • 입력 변환은 해시태그를 감지하는 역할을 하며, 입력 변환을 통해 원자적 태그 삭제를 수행할 수도 있습니다.

  • 출력 변환은 시각적 스타일을 추가하고 해시태그를 삭제하는 데도 도움이 될 수 있습니다.

  • 이 모든 것이 단일 상태 홀더 객체에 함께 패키징됩니다. 이를 TagEditorState라고 부릅니다.

    @Stable
    class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
    
        private val tagRanges = mutableStateListOf<TextRange>()
    
        // Transformations coordinate state internally
        val inputTransformation = TagInputTransformation(tagRanges)
        val outputTransformation = TagOutputTransformation(tagRanges)
    
        fun addTag(tag: String) { ... }
    
    }
  • 그런 다음 이 경우 tagRanges과 같은 중간 계산을 모두 재사용할 수 있습니다.

  • 그런 다음 이 상태 홀더 객체를 컴포저블에 전달하기만 하면 됩니다.

    @Stable
    class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
    
    @Composable
    fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
        BasicTextField(
            state = state.textFieldState,
            inputTransformation = state.inputTransformation,
            outputTransformation = state.outputTransformation,
        )
    }
  • 또 다른 사용 사례는 비즈니스 로직이 입력 중 검색이나 복잡한 유효성 검사를 위해 실제로 해당 텍스트에 액세스해야 하는 경우입니다. 이 경우 텍스트 필드를 ViewModel로 호이스팅합니다.

  • 변경 가능한 텍스트 필드 객체를 변경 불가능한 데이터 클래스 UI 상태에 넣지 않는 것이 중요합니다. 복사 메서드로 복사된 후 변경될 때마다 StateFlow로 내보내지기 때문입니다.

    data class FormUiState(val username: TextFieldState)
  • TextFieldState는 스냅샷 시스템에서 지원하는 변경 가능한 장기 지속 객체이며 참조 객체는 변경되지 않습니다.

  • 따라서 참조가 동일하게 유지되므로 StateFlow는 사용자가 현재 입력 중이라는 사실을 알 수 없습니다.

  • 더 나쁜 것은 UI 상태의 새 인스턴스를 만들고 새 TextFieldState를 할당하면 입력 필드의 모든 상태가 손실된다는 점입니다.

  • 선택, 커서 위치, 실행취소/다시 실행 기록 등이 여기에 해당합니다.

  • 대신 TextFieldState를 ViewModel의 속성으로 직접 만듭니다.

    class FormViewModel : ViewModel() {
       val username = TextFieldState()
       val uiState: StateFlow<FormUiState> = ...
    }
  • 여기서는 ViewModel에 UI가 없다는 규칙에 대해 걱정할 필요가 없습니다. 이 경우 TextFieldState는 Android 뷰나 컨텍스트 객체, Compose 수명 주기를 참조하지 않기 때문입니다.

  • 순수한 스냅샷 지원 상태 홀더 객체일 뿐입니다.

  • 따라서 이 경우 개념적으로 StateFlow와 다르지 않습니다.

  • 한 가지 기억해야 할 점은 ViewModel에는 rememberSaveable가 없으므로 텍스트 필드가 프로세스 종료 후에도 유지되고 상태를 복원할 수 있도록 TextFieldState.Saver를 연결해야 한다는 것입니다.

    class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
        val username: TextFieldState = savedStateHandle.saveable(
            key = "username",
            saver = TextFieldState.Saver,
            init = { TextFieldState() }
        )
       val uiState: StateFlow<FormUiState> = ...
    }
  • 팀에 엄격한 모듈 규칙이 있어 ViewModel이 순수 Kotlin 모듈에만 있고 compose-foundation 종속 항목을 가져올 수 없는 경우에는 어떻게 해야 할까요?

  • 또는 복잡한 기존 화면을 이전하고 있는데 지금 UI 상태를 업데이트하거나 리팩터링할 수 없는 경우를 예로 들 수 있습니다.

  • 이 경우 적합한 접근 방식을 사용하세요.

  • 따라서 컴포저블의 TextFieldState를 해당 텍스트 필드의 단일 정보 소스로 유지합니다.

  • 그런 다음 snapshotFlow와 함께 LaunchedEffect를 사용하여 텍스트 변경사항을 기존 ViewModel에 다시 푸시합니다.

    @Composable
    fun LoginScreen(viewModel: LoginViewModel) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        // 1. Hoist TextFieldState in UI layer
        val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
    
        // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
        LaunchedEffect(usernameState, viewModel) {
            snapshotFlow { usernameState.text.toString() }
                .drop(1)
                .collect { viewModel.onUsernameChanged(it) }
        }
    
        BasicTextField(state = usernameState)
    }
  • ViewModel는 Compose를 사용하지 않는 상태로 유지되며, UI 상태 속성이 변경될 때마다 텍스트 필드에 다시 입력되지 않으므로 비동기 왕복 버그를 방지할 수 있습니다.

  • 네, 여기서 흔히 빠지는 함정을 조심해야 합니다.

    @Composable
    fun LoginScreen(viewModel: LoginViewModel) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        // 1. Hoist TextFieldState in UI layer
        val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
    
        // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
        LaunchedEffect(usernameState, viewModel) {
            snapshotFlow { usernameState.text.toString() }
                .drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
                .collect { viewModel.onUsernameChanged(it) }
        }
    
        BasicTextField(state = usernameState)
    }
  • 대부분의 경우 첫 번째 이벤트를 생략하는 것이 좋습니다.

  • 이렇게 하면 불필요하게 중복된 검색어가 방지되고 ViewModel가 초깃값으로 덮어쓰기되는 것도 방지됩니다.

  • 따라서 로직이 상태에 변경사항을 피드백해야 하는 경우, 예를 들어 사용자가 제출 버튼을 누르고 텍스트를 지우려는 경우 ViewModel의 ViewModel에 빈 문자열을 설정할 수 없습니다.

    @Composable
    fun LoginScreen(viewModel: LoginViewModel) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        // 1. Hoist TextFieldState in UI layer
        // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
        ...
    
        // 3. Handle discrete ViewModel events (e.g. clear after login)
        LaunchedEffect(viewModel) {
            viewModel.clearUsernameEvent.collect {
                usernameState.clearText()
            }
        }
        BasicTextField(state = usernameState)
    }
  • 대신 명확한 텍스트 이벤트를 내보내야 합니다. 그러면 TextFieldState 객체를 보유하는 구성 가능한 함수가 해당 객체에서 clearText를 호출합니다.

  • 전환을 지원하기 위해 이 이전 가이드를 작성했습니다.

  • 이 링크를 통해 액세스할 수 있습니다.

  • 또한 이전과 일반적인 TextFieldState 변경사항 관찰을 더 쉽게 할 수 있도록 추가 API를 개발 중이니 기대해 주세요.

서식 있는 텍스트 및 서식 있는 텍스트 편집

  • 지금까지 다룬 TextFieldState, TextFieldBuffer, 입력 및 출력 변환은 일반 텍스트 입력만을 위해 빌드된 것이 아닙니다.

  • 이는 서식 있는 텍스트 수정을 위한 최신 API 세트를 지원하는 기반입니다.

  • 이제 하오위가 이에 대해 자세히 설명해 드리겠습니다.

  • 좋습니다.

  • 감사합니다.

  • 안녕하세요.

  • 다음으로 서식 있는 텍스트 수정을 위해 도입한 새로운 API에 관해 설명하겠습니다.

  • 새로운 API를 살펴보기 전에 한 걸음 물러나 정적 텍스트에서 스타일 지정이 어떻게 작동하는지 살펴보겠습니다.

  • 대부분의 개발자는 이미 AnnotatedString을 잘 알고 있습니다.

    val styledText = buildAnnotatedString {
        append("Jetpack Compose ")
        // 1. Blue & Bold on "Jetpack" [0, 7)
        addStyle(
            style = SpanStyle(
                color = Color.Blue,
                fontWeight = FontWeight.Bold
            ),
            start = 0,
            end = 7
        )
    }
    // Render static formatted text
    Text(text = styledText)
  • 정적 텍스트의 스타일을 지정하는 표준 방법입니다.

  • buildAnnotatedString를 사용하고 addStyle를 호출하여 특정 범위에 SpanStyle 또는 ParagraphStyle를 적용합니다.

  • 정적 텍스트에 적합합니다.

  • 서식 있는 텍스트 편집을 살펴볼 때 처음에는 AnnotatedString을 수정 가능하게 만들 수 있을지 생각했습니다.

  • 하지만 곧 그것이 그리 간단하지 않다는 것을 깨달았습니다.

  • AnnotatedString은 변경할 수 없는 데이터 구조입니다.

  • 하지만 편집 환경에서는 사용자가 계속 입력합니다.

  • 그리고 키를 누를 때마다, 즉 키 입력마다 새로운 AnnotatedString 객체를 할당해야 합니다.

  • 각 AnnotatedString에는 많은 AnnotatedString.Range가 있습니다.

  • 따라서 이러한 객체도 다시 만들어야 합니다.

  • 확장성이 좋지 않고 상당한 할당 오버헤드가 발생합니다.

  • 이 예시를 확인해 보세요.

  • 중간에 단어를 삽입하면 그 뒤의 모든 스타일이 범위를 이동하고 재할당됩니다.

  • 스타일이 많으면 이 방법은 효과가 없습니다.

  • 따라서 아나스타샤가 이전에 소개한 TextFieldBuffer 내에 전용 스타일 지정 API를 도입했습니다.

  • AnnotatedString의 스타일 지정 API와 매우 유사합니다.

  • 작동 방식을 자세히 살펴보겠습니다.

  • 따라서 AnnotatedString.Builder와 마찬가지로 TextFieldBuffer에는 이제 SpanStyle 또는 ParagraphStyle를 범위에 적용하는 addStyle가 있습니다.

    fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
    
    fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
    
    fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
    
    fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>
  • ExpandPolicy 매개변수를 전달할 수도 있습니다. 이 매개변수는 스타일 경계에서 텍스트를 입력할 때 스타일이 작동하는 방식을 제어합니다.

  • 잠시 후에 살펴보겠습니다.

  • 먼저 스타일 추적이 어떻게 작동하는지 살펴보겠습니다.

    val state = TextFieldState("The quick fox jumps")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 13
        )
        insert(0, "Look! ")
        insert(16, "brown ")
        delete(16, 22)
        delete(10, 19)
    }
  • TextFieldBuffer에 스타일을 추가하면 텍스트가 수정될 때 범위가 자동으로 업데이트됩니다.

  • 이 예시에서는 먼저 일부 텍스트를 굵게 표시합니다.

  • 앞에 텍스트를 삽입하면 스타일 범위가 앞으로 이동합니다.

  • 그리고 그 안에 텍스트를 삽입하면 텍스트 범위가 확장됩니다.

  • 범위 내의 텍스트를 삭제하면 스타일 범위가 그에 따라 축소됩니다.

  • 스타일 범위 내의 모든 텍스트가 삭제되면 스타일이 축소되고 버퍼에서 자동으로 삭제됩니다.

  • 이제 스타일 범위의 경계에 텍스트가 삽입되면 어떻게 될까요?

  • 새로 삽입된 텍스트가 스타일에 포함되는지 여부

  • 이를 제어할 수 있도록 ExpandPolicy API를 도입했습니다.

  • 하나씩 살펴보겠습니다.

  • 첫 번째는 ExpandPolicy.InsideOnly입니다.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.InsideOnly
        )
        insert(4, "quick ")
        insert(length, " jumps")
  • 이 모드에서는 경계에 텍스트를 삽입해도 스타일 범위가 확장되지 않습니다.

  • 여기서는 먼저 굵게 표시할 범위를 적용합니다.

  • 굵게 표시된 범위의 시작 부분에 텍스트를 삽입하면 스타일 범위가 이동하지만 확장되지는 않습니다.

  • 마찬가지로 굵게 표시된 범위 끝에 텍스트를 삽입하면 스타일 범위도 확장되지 않습니다.

  • 다음은 편집기에서 작동하는 방식을 보여주는 데모입니다.

  • 다음은 ExpandPolicy.AtStart입니다.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtStart
        )
        insert(4, "quick ")
  • 이름에서 알 수 있듯이 범위 시작 부분에 텍스트를 삽입하면 스타일 범위가 확장됩니다.

  • 따라서 스타일 범위 바로 앞에 텍스트를 삽입하면 새로 삽입된 텍스트를 포함하도록 스타일 범위가 확장됩니다.

  • 작동 방식을 보여주는 또 다른 데모입니다.

  • 세 번째는 ExpandPolicy.AtEnd입니다.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtEnd
        )
        insert(length, " jumps")
  • 따라서 범위 끝에 텍스트를 삽입하면 스타일 범위가 확장됩니다.

  • 따라서 이 설정은 편집자에게 가장 일반적인 설정이기도 합니다.

  • 작동 방식을 보여주는 데모입니다.

  • 마지막으로 ExpandPolicy.AtBoth는 텍스트가 범위의 시작과 끝에 삽입될 때 스타일을 확장합니다.

    val state = TextFieldState("The fox")
    
    state.edit {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            start = 4,
            end = 7,
            policy = ExpandPolicy.AtBoth
        )
        insert(4, "quick ")
     insert(length, " jumps")
    }
  • 이에 관한 또 다른 데모입니다.

  • 지금까지 스타일 적용 API를 모두 다뤘습니다.

  • 또한 특정 범위에 현재 적용된 스타일을 가져오는 getSpanStyles, getParagraphStyles와 같은 새로운 쿼리 API도 도입했습니다.

    // Outside edits. ReadOnly API in TextFieldState.
    val textFieldState.styles: TextFieldTextStyles
    fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
    
    // Inside edits, returned TrackedRange
    fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>
  • 다음은 두 가지 버전의 쿼리 API입니다.

  • TextFieldState에서 스타일 쿼리 API는 읽기 전용입니다.

  • 변경 불가능한 AnnotatedString.Range 객체 목록을 반환합니다.

  • 하지만 TextFieldBuffer에서 getSpanStyles는 새로 도입된 TrackedRange 객체 목록을 반환했습니다. 이에 대해서는 나중에 소개하겠습니다.

  • 따라서 모든 스타일 쿼리는 순서를 인식합니다.

  • 스타일이 적용된 순서대로 스타일 범위를 반환합니다.

  • 따라서 AnnotatedString과 마찬가지로 기존 스타일 위에 다른 스타일을 적용하여 이전 스타일을 덮어쓸 수 있습니다.

  • 다음은 TrackedRange API입니다.

    var TrackedRange<*>.textRange: TextRange
    
    var TrackedRange<*>.expandPolicy: ExpandPolicy
    
    var TrackedRange<SpanStyle>.spanStyle: SpanStyle
    
    var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
    
    val TrackedRange<*>.isValid: Boolean
    
    fun removeStyle(trackedRange: TrackedRange<*>): Boolean
  • TrackedRange 객체는 TextFieldBuffer의 addStyle 또는 getSpanStyles API에 의해 반환됩니다.

  • 버퍼에 있는 스타일 범위의 실시간 핸들이라고 생각하면 됩니다.

  • 이를 사용하여 텍스트 범위, ExpandPolicy 또는 스타일 자체를 제자리에서 업데이트할 수 있습니다.

  • 또한 스타일 범위가 TextFieldBuffer에 여전히 있는지 확인하는 isValid 속성도 있습니다.

  • 수정 중에 접히거나 removeStyle에 의해 의도적으로 삭제되면 스타일이 삭제될 수 있기 때문입니다.

  • 이제 이러한 API가 실제 사용 사례에서 어떻게 결합되는지 살펴보겠습니다.

WYSIWYG 편집기 빌드

  • 첫 번째 사용 사례는 WYSIWYG(What You See Is What You Get) 편집기를 빌드하는 것입니다.

  • 이 예에서는 굵게 버튼 기능을 구현했습니다.

  • 굵게 버튼을 클릭하면 선택한 텍스트의 굵게 스타일이 전환됩니다.

  • 다음으로 코드를 살펴보겠습니다.

  • 따라서 사용자가 굵게 버튼을 탭하면 먼저 선택 항목에서 getSpanStyles를 호출하여 겹치는 스타일을 모두 찾습니다.

    fun TextFieldBuffer.toggleBoldSelection() {
        if (selection.collapsed) return
        val boldSpans = getSpanStyles(selection)
    
        if (!isFullyBold(boldSpans, selection)) {
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                selection,
                ExpandPolicy.AtEnd
            )
        } else {
            unboldSelection(boldSpans.first())
        }
    }
  • 그런 다음 전체 선택 항목이 이미 굵게 표시되어 있는지 확인합니다.

  • 여기에서 선택한 단어는 굵게 표시되지 않습니다.

  • 굵게 표시되지 않으므로 선택한 범위에 addStyle합니다.

  • 사용자가 이 단어 바로 뒤에 굵은 글꼴을 계속 입력할 수 있도록 ExpandPolicy.AtEnd를 전달합니다.

  • 이제 선택한 범위가 이미 굵게 표시된 경우 어떻게 되는지 살펴보겠습니다.

    fun TextFieldBuffer.unboldSelection(
        boldSpan: TrackedRange<SpanStyle>
    ) {
        val hasBoldBefore = boldSpan.start < selection.min
        val hasBoldAfter = boldSpan.end > selection.max
    
        when {
            hasBoldBefore ->
                boldSpan.range = TextRange(range.start, selection.min)
            hasBoldAfter ->
                boldSpan.range = TextRange(selection.max, range.end)
            hasBoldBefore && hasBoldAfter -> {
                val oldEnd = boldSpan.end
                boldSpan.range = TextRange(boldSpan.start, selection.min)
                addStyle(
                    boldSpan.spanStyle,
                    start = selection.max,
                    end = oldEnd
                )
            }
            else -> removeStyle(range)
        }
    }
  • 이 경우 선택한 범위에서 굵게 스타일을 삭제해야 합니다.

  • 여기에서 사용자가 선택한 단어는 더 긴 굵은 문구 안에 있습니다.

  • 굵게 스타일이 선택 항목 앞에 적용되므로 스타일 범위를 업데이트하여 선택한 단어를 굵게 표시하지 않아야 합니다.

  • 이를 위해 TrackedRange API를 사용할 수 있습니다.

  • 이 경우에는 이 스타일을 삭제하고 다시 만드는 대신, 색인 10의 선택 항목 앞에서 끝나도록 굵게 표시된 스팬 범위를 제자리에서 조정하면 됩니다.

  • 물론 이는 매우 단순화된 구현입니다.

  • 전체 데모는 Compose 샘플 저장소를 확인하세요.

  • 다음으로 패턴 기반 스타일 지정을 살펴보겠습니다.

  • 이 데모에서는 사용자가 단어 주위에 마크다운 별표를 입력하면 입력하는 동안 편집기가 자동으로 텍스트 굵게 표시로 변환합니다.

  • 이제 InputTransformation를 사용하여 구현하는 방법을 살펴보겠습니다.

    object MarkdownBoldTransformation : InputTransformation {
        val regex = Regex("""**(.*?)**""")
    
        override fun TextFieldBuffer.transformInput() {
            val matches = regex.findAll(asCharSequence()).toList()
    
            for (match in matches) {
                val range = match.range
                val content = match.groupValues[1]
    
                replace(range.first, range.last + 1, content)
                addStyle(
                    SpanStyle(fontWeight = FontWeight.Bold),
                    start = range.first,
                    end = range.first + content.length,
                    policy = ExpandPolicy.atEnd
                )
            }
        }
    }
  • 변환 입력 필터에서 버튼 위에 일치하는 모든 패턴을 찾습니다.

  • 그리고 일치하는 각 항목에 대해 이중 별표 안의 콘텐츠를 추출합니다.

  • 이 예시에서는 'fox'라는 단어입니다.

  • 그런 다음 별표를 삭제하고 일치하는 패턴을 해당 콘텐츠로 바꿉니다.

  • 마지막으로 addStyle를 bold에 호출하여 콘텐츠 텍스트에 굵게 스타일을 적용합니다.

  • InputTransformation는 최종 텍스트 필드 상태로 제출됩니다.

  • 즉, 굵게 스타일이 상태에 지속적으로 적용됩니다.

  • 마지막으로 검색 강조 표시 예를 살펴보겠습니다.

  • 따라서 이 예에서는 사용자가 검색어를 입력하면 텍스트 필드 상태를 수정하지 않고도 텍스트 필드의 일치하는 단어가 강조 표시됩니다.

  • 이제 OutputTransformation에서 어떻게 구현되는지 살펴보겠습니다.

  • 검색어를 강조 표시할 때 텍스트 상태를 변경해서는 안 됩니다.

  • OutputTransformation은 이 사용 사례를 구현하기에 완벽한 곳입니다.

  • 실제 구현을 확인해 보겠습니다.

  • 먼저 강조 표시 단어를 검색하고 일치하는 항목을 모두 찾습니다.

    class SearchHighlightTransformation(
        val query: MutableState<String>
    ) : OutputTransformation {
        override fun TextFieldBuffer.transformOutput() {
            val search = query.value
            if (search.isEmpty()) return
            val text = asCharSequence().toString()
            var index = text.indexOf(search)
    
            while (index != -1) {
                addStyle(
                    SpanStyle(
                        background = Color.Yellow,
                        color = Color.Black
                    ),
                    start = index,
                    end = index + search.length
                )
                index = text.indexOf(search, index + search.length)
            }
        }
    }
  • 쿼리는 변경 가능한 상태입니다.

  • 이는 OutputTransformation가 스냅샷을 인식하기 때문입니다.

  • 이 변경 가능한 상태가 업데이트되면 OutputTransformation가 자동으로 다시 적용됩니다.

  • 그런 다음 모든 일치 항목을 반복하고 addStyle를 호출하여 일치 범위에 노란색 하이라이트를 그립니다.

  • 다시 말하지만 OutputTransformation는 시각적 텍스트만 변경하고 원본 텍스트 상태는 그대로 유지됩니다.

  • 지금까지 모든 사용 사례를 살펴봤습니다.

  • 앞서 보여드린 모든 API는 Compose 1.12부터 사용할 수 있습니다.

출시 예정인 마크업 텍스트 라이브러리

  • 마지막으로 현재 진행 중인 새로운 프로젝트를 살짝 보여드리겠습니다.

  • 마크다운과 같은 리치 텍스트 형식을 지원하는 Jetpack Compose용 마크업 텍스트 라이브러리입니다.

  • Google에서 지원하는 가장 흥미로운 기능 중 하나는 증분 파싱과 증분 렌더링입니다.

  • 따라서 실시간 편집이나 마크다운 스트리밍에 매우 적합합니다.

  • 작동 중인 프로토타입의 기록을 보면 페이드되는 영역이 UI에서 업데이트되는 부분임을 알 수 있습니다.

  • 관심이 있거나 앱에 사용 사례가 있는 경우 언제든지 문의해 주세요.

  • 여러분, 감사합니다.

코드

코드 스니펫

'Compose의 텍스트 편집기 소개' 00:29의 Kotlin 스니펫
TextField(state = rememberTextFieldState())
'Compose의 텍스트 편집기 소개'의 00:29에 있는 Kotlin 스니펫
OutlinedTextField(state = rememberTextFieldState())
'Compose의 텍스트 편집 소개'의 00:34에 나오는 Kotlin 스니펫
BasicTextField(state = rememberTextFieldState())
'Compose의 텍스트 편집 소개' 00:44의 Kotlin 스니펫
SecureTextField(state = rememberTextFieldState())
'Compose의 텍스트 편집기 소개' 00:58의 Kotlin 스니펫
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
'Compose의 텍스트 편집기 소개'의 1분 28초 지점에 있는 Kotlin 스니펫
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
'TextFieldState 소개'의 4분 1초 지점에 있는 Kotlin 스니펫
// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }
'TextFieldState 소개'의 4분 9초 지점에 있는 Kotlin 스니펫
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}
'TextFieldState 소개'의 4분 25초 지점에 있는 Kotlin 스니펫
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}
'TextFieldState 소개'의 4분 38초 지점에 있는 Kotlin 스니펫
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}
'입력 필터링 및 형식 지정'의 4분 55초 지점에 있는 Kotlin 스니펫
onValueChange { newString ->
  // full diffing new String vs old String
}
'입력 필터링 및 형식 지정'의 5분 17초 지점에 있는 Kotlin 스니펫
@Stable
public fun interface InputTransformation {
    public fun TextFieldBuffer.transformInput()
}
'입력 필터링 및 형식 지정'의 5분 33초 지점에 있는 Kotlin 스니펫
// Intercept and filter input during typing
inputTransformation = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        // Reject non-digit characters
        revertAllChanges()
    }
}
'입력 필터링 및 형식 지정'의 06:48에 나오는 Kotlin 스니펫
@Stable
public fun interface OutputTransformation {
    public fun TextFieldBuffer.transformOutput()
}
'입력 필터링 및 형식 지정'의 06:58에 나오는 Kotlin 스니펫
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}
'TextFieldState 끌어올리기 및 이전'의 7분 51초 지점에 있는 Kotlin 스니펫
@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
    // State hoisted locally inside the composable
    val state = rememberTextFieldState()

    OutlinedTextField(
        state = state,
        inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
        trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
    )
}
'TextFieldState 호이스팅 및 이전'의 8분 58초 지점에 있는 Kotlin 스니펫
@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
    // to share between UI components
    val commentState = rememberTextFieldState()

    Column {
        CommentTextField(commentState)
        Row {
            Text("${commentState.text.length} / 280")
            SubmitButton(commentState)
        }
    }
'TextFieldState 끌어올리기 및 이전'의 9분 27초 지점에 있는 Kotlin 스니펫
@Composable
fun PostEditor() {

    // Hoisting and wiring related pieces separately in the composable
    val textFieldState = rememberTextFieldState()
    val tagRanges = remember { mutableStateListOf<TextRange>() }
    val inputTransform = remember { TagInputTransformation(tagRanges) }
    val outputTransform = remember { TagOutputTransformation(tagRanges) }

    BasicTextField(
        state = textFieldState,
        inputTransformation = inputTransform,
        outputTransformation = outputTransform,
    )
}
'TextFieldState 호이스팅 및 이전'의 10:12에 있는 Kotlin 스니펫
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {

    private val tagRanges = mutableStateListOf<TextRange>()

    // Transformations coordinate state internally
    val inputTransformation = TagInputTransformation(tagRanges)
    val outputTransformation = TagOutputTransformation(tagRanges)

    fun addTag(tag: String) { ... }

}
'TextFieldState 호이스팅 및 이전'의 10:30에 나오는 Kotlin 스니펫
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}

@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
    BasicTextField(
        state = state.textFieldState,
        inputTransformation = state.inputTransformation,
        outputTransformation = state.outputTransformation,
    )
}
'TextFieldState 호이스팅 및 이전'의 10:51에 나오는 Kotlin 스니펫
data class FormUiState(val username: TextFieldState)
'TextFieldState 끌어올리기 및 이전'의 11:38에 나오는 Kotlin 스니펫
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
'TextFieldState 호이스팅 및 이전'의 12:09에 나오는 Kotlin 스니펫
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
   val uiState: StateFlow<FormUiState> = ...
}
'TextFieldState 호이스팅 및 이전'의 12:55에 나오는 Kotlin 스니펫
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 1. Hoist TextFieldState in UI layer
    val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)

    // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
    LaunchedEffect(usernameState, viewModel) {
        snapshotFlow { usernameState.text.toString() }
            .drop(1)
            .collect { viewModel.onUsernameChanged(it) }
    }

    BasicTextField(state = usernameState)
}
'TextFieldState 호이스팅 및 이전'의 13:21에 나오는 Kotlin 스니펫
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 1. Hoist TextFieldState in UI layer
    val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)

    // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
    LaunchedEffect(usernameState, viewModel) {
        snapshotFlow { usernameState.text.toString() }
            .drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
            .collect { viewModel.onUsernameChanged(it) }
    }

    BasicTextField(state = usernameState)
}
'TextFieldState 호이스팅 및 이전'의 13:39에 나오는 Kotlin 스니펫
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    // 1. Hoist TextFieldState in UI layer
    // 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
    ...

    // 3. Handle discrete ViewModel events (e.g. clear after login)
    LaunchedEffect(viewModel) {
        viewModel.clearUsernameEvent.collect {
            usernameState.clearText()
        }
    }
    BasicTextField(state = usernameState)
}
'서식 있는 텍스트 수정'의 15:09에 있는 Kotlin 스니펫
val styledText = buildAnnotatedString {
    append("Jetpack Compose ")
    // 1. Blue & Bold on "Jetpack" [0, 7)
    addStyle(
        style = SpanStyle(
            color = Color.Blue,
            fontWeight = FontWeight.Bold
        ),
        start = 0,
        end = 7
    )
}
// Render static formatted text
Text(text = styledText)
'서식 있는 텍스트 수정'의 16:36에 있는 Kotlin 스니펫
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)

fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)

fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>

fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>
'서식 있는 텍스트 편집'의 16:57에 나오는 Kotlin 스니펫
val state = TextFieldState("The quick fox jumps")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 13
    )
    insert(0, "Look! ")
    insert(16, "brown ")
    delete(16, 22)
    delete(10, 19)
}
'서식 있는 텍스트 수정'의 17:52에 나오는 Kotlin 스니펫
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.InsideOnly
    )
    insert(4, "quick ")
    insert(length, " jumps")
'서식 있는 텍스트 수정'의 18:22에 있는 Kotlin 스니펫
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtStart
    )
    insert(4, "quick ")
'서식 있는 텍스트 수정'의 18:40에 나오는 Kotlin 스니펫
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtEnd
    )
    insert(length, " jumps")
'서식 있는 텍스트 수정'의 18:56에 나오는 Kotlin 스니펫
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtBoth
    )
    insert(4, "quick ")
 insert(length, " jumps")
}
'서식 있는 텍스트 수정'의 19:14에 있는 Kotlin 스니펫
// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>

// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>
'서식 있는 텍스트 편집'의 20:03에 나오는 Kotlin 스니펫
var TrackedRange<*>.textRange: TextRange

var TrackedRange<*>.expandPolicy: ExpandPolicy

var TrackedRange<SpanStyle>.spanStyle: SpanStyle

var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle

val TrackedRange<*>.isValid: Boolean

fun removeStyle(trackedRange: TrackedRange<*>): Boolean
'WYSIWYG 편집기 빌드'의 21:01에 나오는 Kotlin 스니펫
fun TextFieldBuffer.toggleBoldSelection() {
    if (selection.collapsed) return
    val boldSpans = getSpanStyles(selection)

    if (!isFullyBold(boldSpans, selection)) {
        addStyle(
            SpanStyle(fontWeight = FontWeight.Bold),
            selection,
            ExpandPolicy.AtEnd
        )
    } else {
        unboldSelection(boldSpans.first())
    }
}
'WYSIWYG 편집기 빌드'의 21:26에 나오는 Kotlin 스니펫
fun TextFieldBuffer.unboldSelection(
    boldSpan: TrackedRange<SpanStyle>
) {
    val hasBoldBefore = boldSpan.start < selection.min
    val hasBoldAfter = boldSpan.end > selection.max

    when {
        hasBoldBefore ->
            boldSpan.range = TextRange(range.start, selection.min)
        hasBoldAfter ->
            boldSpan.range = TextRange(selection.max, range.end)
        hasBoldBefore && hasBoldAfter -> {
            val oldEnd = boldSpan.end
            boldSpan.range = TextRange(boldSpan.start, selection.min)
            addStyle(
                boldSpan.spanStyle,
                start = selection.max,
                end = oldEnd
            )
        }
        else -> removeStyle(range)
    }
}
'WYSIWYG 편집기 빌드'의 22:27에 있는 Kotlin 스니펫
object MarkdownBoldTransformation : InputTransformation {
    val regex = Regex("""**(.*?)**""")

    override fun TextFieldBuffer.transformInput() {
        val matches = regex.findAll(asCharSequence()).toList()

        for (match in matches) {
            val range = match.range
            val content = match.groupValues[1]

            replace(range.first, range.last + 1, content)
            addStyle(
                SpanStyle(fontWeight = FontWeight.Bold),
                start = range.first,
                end = range.first + content.length,
                policy = ExpandPolicy.atEnd
            )
        }
    }
}
'WYSIWYG 편집기 빌드'의 23:42에 나오는 Kotlin 스니펫
class SearchHighlightTransformation(
    val query: MutableState<String>
) : OutputTransformation {
    override fun TextFieldBuffer.transformOutput() {
        val search = query.value
        if (search.isEmpty()) return
        val text = asCharSequence().toString()
        var index = text.indexOf(search)

        while (index != -1) {
            addStyle(
                SpanStyle(
                    background = Color.Yellow,
                    color = Color.Black
                ),
                start = index,
                end = index + search.length
            )
            index = text.indexOf(search, index + search.length)
        }
    }
}