リッチテキスト入力を使用すると、ユーザーは入力したテキストの書式を設定できます。たとえば、単語を太字にする、見出しを追加する、段落を揃える、ハイライト表示された @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 で文字書式を適用する
addStyle を SpanStyle とともに使用して、バッファ内の特定の 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 の状態を読み取るかに応じて、アクティブなスタイルを検査する 2 つの方法が用意されています。
TextFieldBufferブロック内:TextFieldBufferレシーバーでgetSpanStylesとgetParagraphStylesを直接呼び出して、変更可能なTrackedRangeハンドルを取得します。- 編集ブロックの外:
TextFieldState.textStylesを読み取ります。これにより、commit された状態を反映する不変の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をリッチテキスト形式のツールバーとメッセージ入力フィールドに接続します。



