設定 RTF 格式輸入內容的樣式

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.InsideOnly 範圍的開頭或結尾輸入文字,文字仍未套用樣式。
ExpandPolicy.AtStart 在 start 輸入的文字會沿用樣式,在 end 輸入的文字則不會套用樣式。 直接在樣式文字前方輸入的文字,也應採用相同格式。 動畫:在 ExpandPolicy.AtStart 範圍開頭輸入的文字會沿用樣式,在結尾輸入的文字則不會。
ExpandPolicy.AtEnd 在 end 輸入的文字會沿用樣式,在 start 輸入的文字則不會套用樣式。 標準 RTF 格式編輯器輸入,在粗體或斜體字後繼續輸入會延續該格式。 動畫:在 ExpandPolicy.AtEnd 範圍結尾輸入的文字會沿用樣式,但在開頭輸入的文字則不會套用樣式。
ExpandPolicy.AtBoth 在 start 或 end 輸入的文字會擴大範圍並沿用樣式。 應從任一邊緣延伸的有效醒目顯示或連續格式化區塊。 動畫:在 ExpandPolicy.AtBoth 範圍的開頭或結尾輸入文字,文字會沿用樣式。

使用 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
                )
            }
        }
    }
)

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 會套用至整個文字區塊,因此請避免在下列情況中使用:

  • 展開至相鄰段落:請謹慎設定 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
            )
        }
    }
}

TextField 切換所選字詞的粗體格式。
圖 3. 切換文字粗體和取消粗體。

擷取或序列化 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 格式工具列和訊息輸入欄位。
Jetchat 範例應用程式,示範訊息輸入列中的 RTF 格式。
圖 4. Jetchat 中的 RTF 格式編輯。