서식 있는 텍스트 입력을 사용하면 사용자가 단어를 굵게 표시하거나 제목을 추가하거나 단락을 정렬하거나 강조 표시된 @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.AtStart |
start에 입력된 텍스트는 스타일을 상속하고 end에 입력된 텍스트는 스타일이 지정되지 않은 상태로 유지됩니다. |
스타일이 지정된 텍스트 바로 앞에 입력된 텍스트도 동일한 서식을 채택해야 합니다. | ![]() |
ExpandPolicy.AtEnd |
end에 입력된 텍스트는 스타일을 상속하고 start에 입력된 텍스트는 스타일이 지정되지 않은 상태로 유지됩니다. |
굵게 또는 기울임꼴 단어 뒤에 계속 입력하면 해당 서식이 계속 적용되는 표준 서식 있는 텍스트 편집기 입력 | ![]() |
ExpandPolicy.AtBoth |
start 또는 end에 입력된 텍스트는 범위를 확장하고 스타일을 상속합니다. |
활성 하이라이트 또는 양쪽 가장자리에서 확장되어야 하는 연속적인 형식의 블록입니다. | ![]() |
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 ) } } } )

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를 사용하여 단락 정렬을 시작, 가운데, 끝으로 전환합니다.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 ) } } }

서식 있는 텍스트 추출 또는 직렬화
읽기 전용 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를 서식 있는 텍스트 서식 도구 모음 및 메시지 입력 필드에 연결합니다.



