RTF 格式輸入可讓使用者設定輸入內容的格式,包括將字詞設為粗體、新增標題、對齊段落,或插入醒目顯示的 @mention。在 Compose 中,您可以在狀態型文字欄位 (BasicTextField 和 TextField) 上建構此功能。樣式會與文字一起存在於 TextFieldState 中,因此在編輯、選取變更和重組後仍會保留,並在使用者輸入、刪除和貼上時保持同步。
本頁說明如何使用 TextFieldBuffer 在 TextFieldState 中套用、更新、查詢、擷取及移除樣式,以及如何透過 TextFieldState.textStyles 驅動格式工具列。
套用內嵌和段落樣式
您可以在任何 TextFieldBuffer 接收器範圍內套用字元層級和段落層級格式,包括 TextFieldState.edit、InputTransformation.transformInput 和 OutputTransformation.transformOutput。
使用 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 輸入的文字則不會套用樣式。 |
標準 RTF 格式編輯器輸入,在粗體或斜體字後繼續輸入會延續該格式。 | ![]() |
ExpandPolicy.AtBoth |
在 start 或 end 輸入的文字會擴大範圍並沿用樣式。 |
應從任一邊緣延伸的有效醒目顯示或連續格式化區塊。 | ![]() |
使用 SpanStyle 套用字元格式
使用 addStyle 搭配 SpanStyle,可套用字元層級格式,例如文字顏色、背景醒目顯示、FontWeight、FontStyle,或在緩衝區中特定 TextRange 加上底線。
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 會套用至整個文字區塊,因此請避免在下列情況中使用:
- 展開至相鄰段落:請謹慎設定
ExpandPolicy,以免相鄰的ParagraphStyle範圍在使用者編輯後重疊。舉例來說,如果[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 中刪除該特定樣式間隔。
如果找到並移除 TrackedRange,removeStyle 會傳回 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 ) } } }

擷取或序列化 RTF 格式
如要在唯讀 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連線至 RTF 格式工具列和訊息輸入欄位。



