서식 있는 텍스트 입력 스타일 지정

서식 있는 텍스트 입력을 사용하면 사용자가 단어를 굵게 표시하거나 제목을 추가하거나 단락을 정렬하거나 강조 표시된 @mention를 삽입하는 등 입력하는 내용의 서식을 지정할 수 있습니다. Compose에서는 상태 기반 텍스트 필드(BasicTextField 및 TextField)를 기반으로 이를 빌드합니다. 스타일은 텍스트와 함께 TextFieldState에 있으므로 수정, 선택 변경, 리컴포지션 후에도 유지되며 사용자가 입력, 삭제, 붙여넣기할 때 동기화된 상태를 유지합니다.

이 페이지에서는 TextFieldBuffer를 사용하여 TextFieldState에서 스타일을 적용, 업데이트, 쿼리, 추출, 삭제하는 방법과 TextFieldState.textStyles에서 서식 도구 모음을 구동하는 방법을 보여줍니다.

인라인 및 단락 스타일 적용

TextFieldState.edit, InputTransformation.transformInput, OutputTransformation.transformOutput를 비롯한 모든 TextFieldBuffer 수신기 범위 내에서 문자 수준 및 단락 수준 서식을 적용할 수 있습니다.

ExpandPolicy로 경계 확장 제어

addStyle를 호출할 때 ExpandPolicy를 전달하여 사용자가 start 또는 end 경계에 새 문자를 직접 삽입할 때 스타일이 지정된 범위가 작동하는 방식을 정의할 수 있습니다.

  • (start, end) 내부에 삽입: 항상 스타일이 지정된 범위를 확장합니다.
  • start앞에 삽입: 항상 start 및 end 오프셋을 모두 오른쪽 으로 이동합니다.
  • start 또는 end에 직접 삽입: 지정한 ExpandPolicy에 의해 제어됩니다.

다음 표에서는 사용 가능한 ExpandPolicy 옵션과 일반적인 사용 사례를 설명합니다.

정책 경계 동작 권장 사용 사례 예
ExpandPolicy.InsideOnly start 또는 end에서 입력한 텍스트는 스타일이 지정된 범위 밖에 있습니다. @mentions, #hashtags 또는 배경 필과 같은 개별 토큰으로, 태그 옆에 입력할 때 태그의 배경 색상을 상속하지 않아야 합니다. ExpandPolicy.InsideOnly 범위의 시작 또는 끝에 입력된 텍스트가 스타일이 지정되지 않은 상태로 유지되는 것을 보여주는 애니메이션
ExpandPolicy.AtStart start에 입력된 텍스트는 스타일을 상속하고 end에 입력된 텍스트는 스타일이 지정되지 않은 상태로 유지됩니다. 스타일이 지정된 텍스트 바로 앞에 입력된 텍스트도 동일한 서식을 채택해야 합니다. ExpandPolicy.AtStart 범위의 시작 부분에 입력된 텍스트가 스타일을 상속하는 반면 끝부분에 입력된 텍스트는 스타일이 지정되지 않은 상태로 유지되는 것을 보여주는 애니메이션
ExpandPolicy.AtEnd end에 입력된 텍스트는 스타일을 상속하고 start에 입력된 텍스트는 스타일이 지정되지 않은 상태로 유지됩니다. 굵게 또는 기울임꼴 단어 뒤에 계속 입력하면 해당 서식이 계속 적용되는 표준 서식 있는 텍스트 편집기 입력 ExpandPolicy.AtEnd 범위 끝에 입력된 텍스트가 스타일을 상속하는 반면 시작 부분에 입력된 텍스트는 스타일이 지정되지 않은 상태로 유지되는 것을 보여주는 애니메이션
ExpandPolicy.AtBoth start 또는 end에 입력된 텍스트는 범위를 확장하고 스타일을 상속합니다. 활성 하이라이트 또는 양쪽 가장자리에서 확장되어야 하는 연속적인 형식의 블록입니다. ExpandPolicy.AtBoth 범위의 시작 또는 끝에 입력된 텍스트가 스타일을 상속하는 모습을 보여주는 애니메이션

SpanStyle를 사용하여 문자 서식 적용

SpanStyle와 함께 addStyle를 사용하여 버퍼의 특정 TextRange에 문자 수준 서식(예: 텍스트 색상, 배경 강조표시, FontWeight, FontStyle, 밑줄)을 적용합니다.

SpanStyle 범위의 주요 동작

  • 레이어 순서: 스타일은 버퍼에 추가하는 순서대로 적용됩니다. 겹치는 여러 SpanStyle 범위가 동일한 속성을 다른 값으로 설정하는 경우 나중에 추가된 스타일이 위에 렌더링됩니다.
  • 비어 있지 않은 범위 요구사항: start < end인 축소되지 않은 범위를 전달해야 합니다. 길이가 0인 축소된 선택 항목에서 addStyle를 호출해도 아무런 효과가 없으며, 반전되거나 범위를 벗어난 색인을 전달하면 IllegalArgumentException이 발생합니다.

TextField(
    state = rememberTextFieldState(),
    inputTransformation = InputTransformation {
        for (i in 0 until changes.changeCount) {
            val range = changes.getRange(i)
            if (!range.collapsed) {
                addStyle(
                    spanStyle = SpanStyle(
                        fontWeight = FontWeight.ExtraBold,
                        fontStyle = FontStyle.Italic,
                        color = Color.Red,
                    ),
                    range = range,
                    expandPolicy = ExpandPolicy.AtEnd
                )
            }
        }
    }
)

사용자가 입력할 때 굵게 및 기울임꼴 빨간색 서식을 동적으로 적용하는 TextField
그림 1. 사용자 입력에 굵게 및 기울임꼴 빨간색 서식을 동적으로 적용합니다.

ParagraphStyle로 블록 서식 적용

addStyle를 ParagraphStyle와 함께 사용하여 textAlign, lineHeight, textDirection, textIndent와 같은 블록 수준 구조 형식을 단락 범위에 적용합니다.

fun setParagraphAlignment(state: TextFieldState, alignment: TextAlign) {
    val targetRange = if (state.selection.collapsed) {
        TextRange(0, state.text.length)
    } else {
        state.selection
    }
    if (targetRange.collapsed) return
    state.edit {
        // 1. Remove existing paragraph styles in this range
        getParagraphStyles(targetRange).forEach { removeStyle(it) }
        // 2. Apply the new paragraph alignment
        addStyle(
            paragraphStyle = ParagraphStyle(textAlign = alignment),
            range = targetRange,
            expandPolicy = ExpandPolicy.InsideOnly
        )
    }
}

ParagraphStyle을 사용하여 시작, 가운데, 끝 사이에서 단락 정렬을 전환하는 TextField입니다.
그림 2. ParagraphStyle를 사용하여 단락 정렬을 시작, 가운데, 끝으로 전환합니다.

ParagraphStyle 사용 시 주의사항

ParagraphStyle는 전체 텍스트 블록에 적용되므로 다음 시나리오에서는 사용하지 마세요.

  • 인접한 단락으로 확장: 사용자 수정 후 인접한 ParagraphStyle 범위가 겹치지 않도록 ExpandPolicy를 신중하게 설정합니다. 예를 들어 [0, 5)에서 ExpandPolicy.AtEnd를 사용하고 [5, 10)에서 ExpandPolicy.AtStart를 사용하는 경우 색인 5에 텍스트를 삽입하면 두 스타일이 모두 겹치도록 확장되고 IllegalArgumentException가 발생합니다.
  • ParagraphStyle 범위 중첩: 완전히 중첩된 범위 (예: [0, 10) 및 [2, 8))는 AnnotatedString에서 유효하지만 텍스트 필드에서 ParagraphStyle 범위를 중첩하지 마세요. 레이아웃은 중첩된 범위를 별도의 단락으로 분할하며, 이후 수정으로 인해 중첩된 범위가 실수로 부분적으로 겹칠 수 있습니다.

TrackedRange를 사용하여 스타일을 제자리에서 업데이트

TextRange 및 ExpandPolicy를 사용하여 addStyle를 호출하면 TrackedRange 핸들이 반환되며, 이 핸들은 TrackedRange<SpanStyle> 또는 TrackedRange<ParagraphStyle>일 수 있습니다. TrackedRange는 TextFieldBuffer의 스타일 간격에 대한 동적 참조로, 텍스트가 삽입, 삭제 또는 대체될 때 start 및 end 문자 오프셋을 자동으로 업데이트합니다.

인플레이스 변경 가능 여부

기존 스타일을 업데이트하거나 경계를 조정하기 위해 removeStyle 및 addStyle를 호출할 필요는 없습니다. TextFieldBuffer 범위 내에서 TrackedRange는 다른 스타일과 비교하여 원래 레이어 순서를 유지하면서 간격을 제자리에서 업데이트하는 확장 속성을 제공합니다.

  • TrackedRange<SpanStyle>.spanStyle: TrackedRange<SpanStyle>에서 SpanStyle 페이로드를 읽거나 바꿉니다.
  • TrackedRange<ParagraphStyle>.paragraphStyle: TrackedRange<ParagraphStyle>에서 ParagraphStyle 페이로드를 읽거나 바꿉니다.
  • TrackedRange<*>.textRange: 현재 TextRange 경계를 제자리에서 읽거나 업데이트합니다. 접히거나 반전되거나 범위를 벗어난 범위를 설정하면 IllegalArgumentException이 발생합니다.
  • TrackedRange<*>.expandPolicy: ExpandPolicy를 제자리에서 읽거나 업데이트합니다.
  • TrackedRange<*>.isValid: 스타일 간격이 버퍼에서 여전히 활성 상태이면 true를 반환하고, 수정으로 인해 길이가 0로 축소되거나 삭제된 경우 false를 반환합니다.

// Trim an existing bold span in place when the selection overlaps one edge
val range = style.textRange
if (range.start < selection.min && range.end <= selection.max) {
    // Selection covers the end of the span - shrink end boundary in place
    style.textRange = TextRange(range.start, selection.min)
} else if (range.start >= selection.min && range.end > selection.max) {
    // Selection covers the start of the span - shrink start boundary in place
    style.textRange = TextRange(selection.max, range.end)
}

범위 제한 및 수명 주기 규칙

TrackedRange 인스턴스를 사용할 때는 다음 범위 및 수명 주기 규칙에 유의하세요.

  • TextFieldBuffer로 제한됨: TrackedRange 확장 속성은 핸들이 생성되거나 쿼리된 TextFieldBuffer 범위 내에만 존재합니다.
  • 축소 시 자동 프루닝: 사용자 수정으로 TrackedRange에서 다루는 모든 문자가 삭제되면 길이가 0로 줄어들고 isValid는 false를 반환하며 스타일이 버퍼에서 삭제됩니다.

스타일 쿼리 및 삭제

Compose는 UI의 버퍼를 변경하는지 아니면 상태를 읽는지에 따라 활성 스타일을 검사하는 두 가지 방법을 제공합니다.

  • TextFieldBuffer 블록 내부: TextFieldBuffer 수신자에서 직접 getSpanStyles 및 getParagraphStyles를 호출하여 변경 가능한 TrackedRange 핸들을 가져옵니다.
  • 수정 블록 외부: TextFieldState.textStyles를 읽습니다. 그러면 커밋된 상태를 반영하는 변경 불가능한 AnnotatedString.Range 목록이 반환됩니다.

TextFieldBuffer에서 교차하는 스타일 쿼리

TextFieldBuffer.getSpanStyles 및 TextFieldBuffer.getParagraphStyles는 지정된 TextRange와 교차하는 모든 TrackedRange 인스턴스를 버퍼에 추가된 순서대로 반환합니다.

예를 들어 state.edit 내에서 getSpanStyles(selection)를 쿼리하여 사용자의 현재 선택사항에 적용되는 스타일을 검사하거나 업데이트할 수 있습니다.

state.edit {
    if (selection.collapsed) return@edit
    // Query all bold spans that overlap with the current selection
    val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
        it.spanStyle.fontWeight == FontWeight.Bold
    }
    // ...
}

스타일 간격은 반개방형 수학 범위 [start, end)를 사용합니다. 터치 스타일이 반환되는지 여부는 선택사항을 쿼리하는지 아니면 단일 커서 지점을 쿼리하는지에 따라 다릅니다.

  • 비어 있지 않은 선택 [start, end)의 경우:
    • 반환됨: 선택 범위와 겹치거나, 선택 범위에 포함되거나, 선택 범위 내에 있는 모든 스타일입니다. 예를 들어 쿼리 [5, 15)는 [0, 20), [8, 12) 또는 [0, 10)와 일치합니다.
    • 반환되지 않음: 문자와 겹치지 않고 외부 경계 가장자리만 터치하는 스타일입니다. 예를 들어 [5, 15) 쿼리는 [0, 5) 및 [15, 25)를 제외합니다.
  • 접힌 커서 [index, index]의 경우:
    • 반환됨: 커서를 포함하거나 커서 위치에서 시작하는 모든 스타일입니다. 예를 들어 10의 커서는 [0, 20) 및 [10, 20)와 일치합니다.
    • 반환되지 않음 (종료 규칙 터치): 커서 위치에서 끝나는 모든 스타일입니다. 예를 들어 10의 커서는 [0, 10)를 제외합니다.

removeStyle를 사용하여 스타일 삭제 또는 전환

TrackedRange 핸들을 removeStyle에 전달하여 TextFieldBuffer에서 특정 스타일 간격을 삭제합니다.

removeStyle는 TrackedRange를 찾아서 삭제한 경우 true를 반환하고, 이미 삭제되었거나 다른 버퍼에 속한 경우 false를 반환합니다. 타겟팅된 TrackedRange 핸들만 삭제하고 다른 중복 스타일은 그대로 유지합니다.

fun toggleBold(state: TextFieldState) {
    state.edit {
        if (selection.collapsed) return@edit
        // Query all bold spans that overlap with the current selection
        val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
        if (boldStyles.isNotEmpty()) {
            // Remove or trim existing bold spans covering the selection
            for (style in boldStyles) {
                val range = style.textRange
                if (range.start >= selection.min && range.end <= selection.max) {
                    removeStyle(style)
                } else if (range.start < selection.min && range.end > selection.max) {
                    val oldEnd = range.end
                    style.textRange = TextRange(range.start, selection.min)
                    addStyle(
                        spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                        range = TextRange(selection.max, oldEnd),
                        expandPolicy = ExpandPolicy.AtEnd
                    )
                } else if (range.start < selection.min) {
                    style.textRange = TextRange(range.start, selection.min)
                } else {
                    style.textRange = TextRange(selection.max, range.end)
                }
            }
        } else {
            // No bold spans intersect the selection - apply bold
            addStyle(
                spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                range = selection,
                expandPolicy = ExpandPolicy.AtEnd
            )
        }
    }
}

선택한 단어의 굵게 서식을 사용 설정 및 중지하는 TextField
그림 3. 텍스트를 굵게 표시하거나 굵게 표시하지 않도록 전환합니다.

서식 있는 텍스트 추출 또는 직렬화

읽기 전용 Text 컴포저블에 형식이 지정된 입력을 표시하거나 백엔드 서비스로 직렬화하려면 수정 블록 외부에서 TextFieldState.textStyles를 읽습니다.

state.textStyles.getSpanStyles 및 state.textStyles.getParagraphStyles는 List<AnnotatedString.Range<SpanStyle>> 및 List<AnnotatedString.Range<ParagraphStyle>>를 반환하며, 이를 AnnotatedString 생성자에 직접 전달할 수 있습니다.

fun TextFieldState.toAnnotatedString(): AnnotatedString {
    val fullRange = TextRange(0, text.length)
    return AnnotatedString(
        text = text.toString(),
        spanStyles = textStyles.getSpanStyles(fullRange),
        paragraphStyles = textStyles.getParagraphStyles(fullRange)
    )
}

Jetchat의 전체 예

인라인 문자 스타일 지정 (굵게, 기울임꼴, 밑줄, 취소선, 색상)을 결합하는 전체 구현을 보려면 GitHub의 Jetchat 샘플을 참고하세요.

  • RichTextState.kt: TextFieldState 스타일 쿼리, 스팬 전환, 선택 경계에 걸친 자르기 또는 분할 등을 캡슐화합니다.
  • UserInput.kt: RichTextState를 서식 있는 텍스트 서식 도구 모음 및 메시지 입력 필드에 연결합니다.
메시지 입력 창에서 서식 있는 텍스트 형식을 보여주는 Jetchat 샘플 앱
그림 4. Jetchat의 서식 있는 텍스트 편집