リッチテキスト入力のスタイルを設定する

リッチテキスト入力を使用すると、ユーザーは入力したテキストの書式を設定できます。たとえば、単語を太字にする、見出しを追加する、段落を揃える、ハイライト表示された @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 で文字書式を適用する

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

ユーザーの入力に応じて、太字と斜体の赤色の書式を動的に適用する 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 の状態を読み取るかに応じて、アクティブなスタイルを検査する 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
            )
        }
    }
}

選択した単語の太字書式を切り替える 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 でのリッチテキスト編集。