シニア ソフトウェア エンジニア
Compose で信頼性の高いテキスト編集を実現するための取り組みは、BasicTextField の完全な書き換えで結実しました。このバッファ駆動型アーキテクチャへの移行により、IME の競合状態が完全に解消されました。このセッションでは、従来の入力同期を廃止する必要があった理由と、TextFieldState がキーボードの更新を同期する方法について説明します。そこから、書式設定された編集について特集し、TrackedRange の仕組み、編集ブロックのパフォーマンス コスト、スタイル境界の背後にある実際のキャッチについて説明します。その後、Markdown レンダリングについて簡単に説明します。
TextFieldState API オーバーロードは、Compose のコールバック ベースのオーバーロードに代わるものです。ユーザーの入力と同期して更新されるため、ソフトウェア キーボードのタイミングの問題や不要な再コンポジションがなくなります。TextFieldBuffer を使用してテキストを操作できます。編集を commit する前にインターセプトしたり、オフセット マッピングを手動で計算せずに表示テキストをフォーマットしたりできます。TextFieldState をホイスティングするかどうかは、アーキテクチャのニーズによって異なります。コンポーザブルにローカルに保持することも、ビジネス ロジックにアクセスするために ViewModel に直接保存することもできます。TextFieldBuffer に書式設定されたテキスト編集 API が追加されています。カスタマイズ可能な ExpandPolicy オプションを使用してスタイルを適用できます。既存のスタイル範囲は、ユーザーが編集すると自動的に更新されます。文字起こし
皆さん、こんにちは。
ご参加いただきありがとうございました。
私は Anastasia です。同僚の Haoyu も同席しています。
本日は、信頼性の高いテキスト編集エクスペリエンスを構築する方法をご紹介します。
シンプルなログイン画面、入力時に検索するバー、複雑なメッセージ アプリのいずれを構築する場合でも、テキスト フィールドはユーザーが Android アプリを操作する際の中心となります。
ほとんどの方は、すでにマテリアル テキスト フィールドか、アウトライン付きテキスト フィールドを使用していると思います。
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())独自の設計システムを構築する場合や、シンプルな編集ボックスが必要な場合は、BasicTextField もあります。
BasicTextField(state = rememberTextFieldState())また、パスワードの入力専用に設計された SecureTextField もあります。
SecureTextField(state = rememberTextFieldState())BasicTextField はスタイル設定されていないコンポーネントで、実際には Compose の他のすべてのテキスト フィールドを強化します。
過去数年間で、2 つの API 形状が並行して存在していることに気づいたかもしれません。1 つは値と値変更コールバックを受け取り、もう 1 つは TextFieldState オブジェクトを受け取ります。
@Composable
public fun BasicTextField(
state: TextFieldState,
)どちらがおすすめか、疑問に思われるかもしれません。
TextFieldState オーバーロードを使用することを強くおすすめします。
この講演では、API、機能、パフォーマンスの観点から、なぜそれがメリットとなるのかについて説明します。
Compose 1.7 以降、安定版となっています。
コールバックのオーバーロードについては、Compose 1.13 で BasicTextField とマテリアル ラッパー全体でソフト非推奨としています。
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unitつまり、重大なセキュリティの問題やユーザー補助に関する問題がない限り、新機能の追加やバグの修正は行われません。
値変更オーバーロードのステートレス値は、同期入力で非常にうまく機能しました。
しかし、StateFlow などの非同期ストリームと組み合わせると、IME(ソフトウェア キーボード)でタイミングの問題が発生していました。
この録画でわかるように、文字の欠落、文字の重複、カーソルのジャンプが発生しました。
この問題を根本的に解決するために、TextFieldState が導入されました。
ユーザーが入力すると、非同期のラウンドトリップを待たずに同期的に更新されます。これはまさに IME が必要としていたものです。
TextFieldState には、読み取り専用テキストに加えて、アクティブな編集セッションを作成するような他のプロパティもあります。
選択、元に戻す / やり直しの状態、書式付きテキスト編集用のスパン、スパンのスタイル、段落のスタイルなどです。これらについては、この講演の後半で詳しく説明します。
TextFieldState の内部プロパティは、Compose スナップショット システムによってサポートされています。
つまり、Compose のどこかで TextFieldState オブジェクトからテキストまたは選択範囲を読み取ると、読み取り元の特定のスコープが更新にサブスクライブされます。
これにより、BasicTextField はレイアウト フェーズまたは描画フェーズでのみ状態を読み取ることができ、ユーザーが入力したときに不要な再コンポジションを排除できます。
また、他のいくつかの変更と組み合わせることで、TextFieldState オーバーロードのパフォーマンスが大幅に向上しました。
TextFieldState のテキストが読み取り専用プロパティである場合、入力フィールドをプログラムで変更するにはどうすればよいでしょうか。
ここで TextFieldBuffer が役立ちます。
これは、変更を行うための API を提供する可変オブジェクトです。
そのため、プログラムで変更を行う場合は、TextFieldState の編集関数を呼び出します。
このラムダ内で TextFieldBuffer を取得します。
また、TextFieldBuffer は、必要に応じて文字の挿入、置換、削除を可能にするいくつかの関数を公開しています。
編集に関する簡単なヒントとして、編集を連続して何度も呼び出さないようにしてください。
// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }代わりに、すべてのオペレーションを 1 つの編集呼び出しにバッチ処理します。
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}そうすることで、Compose は TextFieldBuffer のインスタンスを 1 つだけ作成し、その場で変更して、IME およびスナップショット システムと 1 回同期します。
もう 1 つのヒントとして、必要となる一般的なバッチ オペレーションのほとんどには、TextFieldState の拡張関数が用意されています。
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}そのため、これらのオペレーションをバッチ処理する必要はありません。
そのうちの 1 つを呼び出すだけです。
たとえば、clearText と setTextAndPlaceCursorAtEnd です。
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}では、ユーザー入力をフィルタするにはどうすればよいでしょうか。
たとえば、文字数制限を適用したり、数字のみを受け入れたりする場合です。
以前の API では、テキストがすでに commit された後に onValueChange でのフィルタリングが実行されていました。
onValueChange { newString ->
// full diffing new String vs old String
}そのため、その入力を拒否したり変更したりすると、キーボードと競合していました。
また、onValueChange コールバックは最終的な文字列のみを返していました。
そのため、変更内容を把握するには、文字列全体の差分を比較する必要がありました。
代わりに、新しいオーバーロードでは入力変換を使用します。
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}そのため、ユーザー入力がテキスト フィールドの状態に到達する前にその入力をインターセプトし、同じ TextFieldBuffer API を提供します。
このように、入力変換を使用して数字のみのフィルタリングを実装し、基本的に変更を取得します。
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}数字以外の文字があるかどうかを確認し、ある場合は、その TextFieldBuffer で revertAllChanges を呼び出して入力を拒否します。
この入力変換の主な機能は ChangeList です。
これにより、文字列全体を比較するのではなく、正確な編集範囲を検査して、必要に応じてその場で変更したり、入力を完全に拒否したりできます。
Compose は、キーボードとカーソルを自動的に同期します。
最後に、入力の視覚的な書式設定を行う場合はどうすればよいでしょうか。
たとえば、クレジット カード番号や電話番号に丸かっこやダッシュを追加する場合です。
うわぁ
また、それらをデータベースに保存したくない場合もあります。
以前は、ビジュアル変換 API を使用していました。
そのため、未加工の入力から視覚的な出力への双方向マッピングを実装し、その後、元に戻す必要がありました。
正しく記述するのが非常に難しく、ランタイム例外が発生することがよくありました。
出力変換が追加されましたが、内部では同じ TextFieldBuffer が使用されているため、API はほぼ同じです。
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}この電話番号の例では、特定のインデックスにダッシュを挿入するために、TextFieldBuffer の insert 関数を呼び出すだけで、システムが双方向のオフセット マッピングを計算します。
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}これにより、手動での計算が不要になり、クラッシュの数が大幅に減少します。
では、最も重要な部分、つまり実際に移行する方法と、TextFieldState をホイスティングする場所について説明します。
実際には、ほぼすべてのユースケースが、これから説明するカテゴリのいずれかに分類されます。
そうでない場合は、通常、従来とは異なるアーキテクチャを使用していることを意味しますが、これらの例は移行のガイドとして役立ちます。
最もシンプルなケースは、テキスト フィールドが存在するコンポーザブル内に TextFieldState を保持することです。
@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}これは、テキスト フィールドが自己完結型の入力フィールドである場合です。たとえば、プロモーション コードの入力フィールドがあり、ユーザーが送信ボタンを押したときにのみ、そのテキストにアクセスする必要がある場合です。
そのため、ビューモデルは変更のたびにユーザー入力をインターセプトする必要がありません。
そのため、ローカルで rememberTextFieldState を呼び出すだけで、rememberTextFieldState はプロセスの終了後の状態の復元にも役立ちます。
次に、入力変換を使用してフィルタリングを行い、その状態(TextFieldState オブジェクト)からテキストを取得し、ユーザーが送信したときにラムダに渡します。
では、文字数カウンターや送信ボタンなどの兄弟 UI 要素もテキストを読み取ったり、変更したりする必要がある場合はどうすればよいでしょうか。
これにより、TextFieldState オブジェクトを共通のコンポーズ可能な関数(共通の祖先コンポーズ可能な関数)にホイスティングするケースが発生します。
@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}また、TextFieldState はスナップショット システムによってサポートされているため、祖先を介して渡しても、親の再コンポーズはトリガーされません。
状態のテキストを実際に読み取る場所のみが更新されます。
違いますね。
そのため、TextFieldState、入力、出力の変換で内部的に状態を調整する必要がある複雑な UI ユースケースでは、それらを個別にホイスティングしてコンポーズ可能な関数内で接続しようとするのではなく、状態ホルダー オブジェクトを使用します。
@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}インライン ハッシュタグを処理するとします。
TextFieldState は、未加工のテキストとカーソル位置を保持します。
入力変換はハッシュタグの検出を担当します。また、入力変換を通じてアトミックなタグ削除を行うこともできます。
出力変換では、追加のビジュアル スタイルを追加したり、ハッシュタグを削除したりすることもできます。
これらはすべて、1 つの状態ホルダー オブジェクト(TagEditorState)にまとめられています。
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}そして、この例の tagRanges のような中間計算をすべて再利用できます。
そして、この状態ホルダー オブジェクトをコンポーザブルに渡すだけです。
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}別のユースケースとして、入力中の検索や複雑な検証のためにビジネス ロジックが実際にそのテキストにアクセスする必要がある場合は、そのテキスト フィールドを ViewModel にホイスティングします。
変更可能なテキスト フィールド オブジェクトを不変のデータクラス UI 状態に配置しないことが重要です。これは、コピーメソッドでコピーされ、変更のたびに StateFlow に出力されるためです。
data class FormUiState(val username: TextFieldState)TextFieldState は、スナップショット システムを基盤とする、有効期間が長く、変更可能なオブジェクトです。その参照オブジェクトは変更されません。
そのため、参照が同じままであるため、StateFlow はユーザーが入力していることを認識できません。
さらに悪いことに、UI 状態の新しいインスタンスを作成して新しい TextFieldState を割り当てると、入力フィールドの状態がすべて失われます。
たとえば、選択、カーソル位置、元に戻す / やり直しの履歴などです。
代わりに、TextFieldState を ViewModel のプロパティとして直接作成します。
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}また、ViewModel に UI を含めないというルールについては、TextFieldState が Android の View オブジェクト、Context オブジェクト、Compose のライフサイクルを参照しないため、心配する必要はありません。
これは、純粋なスナップショットベースの状態ホルダー オブジェクトです。
その場合、概念的には StateFlow と同じです。
1 つ注意すべき点として、ViewModel には rememberSaveable がないため、テキスト フィールドの TextFieldState.Saver を接続して、プロセスの終了後も状態を維持し、復元できるようにする必要があります。
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}では、チームに厳格なモジュール ルールがあり、ViewModel は純粋な Kotlin モジュールにのみ存在し、compose-foundation 依存関係を導入できない場合はどうすればよいでしょうか。
または、複雑なレガシー画面を移行していて、UI 状態を更新またはリファクタリングできない場合はどうでしょうか。
その場合は、準拠アプローチを使用します。
そのため、そのテキスト フィールドの信頼できる唯一の情報源として、コンポーザブルに TextFieldState を保持します。
その後、snapshotFlow を指定した LaunchedEffect を使用して、テキストの変更を既存の ViewModel に push します。
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}ViewModel は Compose を使用しないままで、UI 状態プロパティは変更のたびにテキスト フィールドにフィードバックされないため、非同期の往復バグを回避できます。
そうですね。ここでよくある落とし穴に注意してください。
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}ほとんどの場合、最初のイベントは省略します。
これにより、検索クエリが不必要に重複するのを防ぐことができます。また、ViewModel が初期値で上書きされるのを防ぐこともできます。
ロジックで状態の変更をフィードバックする必要がある場合、たとえば、ユーザーが送信ボタンを押したときにテキストをクリアしたい場合、ViewModel の ViewModel に空の文字列を設定するだけでは不十分です。
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}代わりに、テキスト消去イベントを出力する必要があります。そうすると、その TextFieldState オブジェクトを保持するコンポーズ可能な関数が、そのオブジェクトで clearText を呼び出します。
この移行ガイドは、移行をさらに支援するために作成されました。
このリンクからアクセスできます。
また、移行と一般的な TextFieldState の変更のモニタリングを容易にする追加の API にも取り組んでいます。
これまで説明した TextFieldState、TextFieldBuffer、入力と出力の変換は、書式なしテキスト入力のためだけに構築されたものではありません。
これらは、書式設定されたテキスト編集用の最新の API セットを支える基盤です。
それでは、Haoyu から詳しく説明してもらいましょう。
それでは
ありがとうございました
皆さん、こんにちは。
次に、書式設定されたテキストの編集用に導入された新しい API について説明します。
新しい API について詳しく説明する前に、一歩戻って、静的テキストでスタイル設定がどのように機能するかを見てみましょう。
皆さんの多くは、AnnotatedString についてすでによくご存じだと思います。
val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)これは、静的テキストのスタイルを設定する標準的な方法です。
buildAnnotatedString を使用し、addStyle を呼び出して、特定の範囲に SpanStyle または ParagraphStyle を適用します。
静的テキストに適しています。
書式設定されたテキストの編集について、当初は AnnotatedString を編集可能にすればよいと考えていました。
しかし、それがそれほど単純ではないことにすぐに気づきました。
AnnotatedString は不変のデータ構造です。
しかし、編集の世界では、ユーザーは常にキーボードを叩いています。
キーを押すたびに、新しい AnnotatedString オブジェクトを割り当てる必要があります。
各 AnnotatedString には、多数の AnnotatedString.Range があります。
そのため、これらのオブジェクトも再作成する必要があります。
スケーリングが不十分で、割り当てのオーバーヘッドが大きくなります。
この例を見てみましょう。
中央に単語を挿入すると、それ以降のすべてのスタイルの範囲がシフトされ、再割り当てされます。
スタイルが多い場合、この方法は明らかに機能しません。
そこで、代わりに、アナスタシアが先ほど紹介した TextFieldBuffer 内に専用のスタイル設定 API を導入しました。
これらは、AnnotatedString のスタイル設定 API と非常によく似ています。
それぞれの仕組みを詳しく見ていきましょう。
AnnotatedString.Builder と同様に、TextFieldBuffer に addStyle が追加され、SpanStyle または ParagraphStyle を範囲に適用できるようになりました。
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>ExpandPolicy パラメータを渡すこともできます。このパラメータは、スタイルの境界にテキストが入力されたときのスタイルの動作を制御します。
これについては後ほど説明します。
まず、スタイル トラッキングの仕組みを見てみましょう。
val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}TextFieldBuffer にスタイルを追加すると、テキストの編集時にその範囲が自動的に更新されます。
この例では、まず一部のテキストを太字にします。
その前にテキストを挿入すると、スタイル範囲が前方にシフトします。
その中にテキストを挿入すると、テキスト範囲が拡張されます。
範囲内のテキストを削除すると、スタイル範囲はそれに応じて縮小します。
また、スタイル範囲内のすべてのテキストが削除されると、スタイルは折りたたまれ、バッファから自動的に削除されます。
では、スタイル範囲の境界にテキストが挿入されるとどうなるでしょうか。
新しく挿入されたテキストがスタイルでカバーされているかどうか。
そこで、これを制御できるように、ExpandPolicy API を導入しました。
それぞれ見ていきましょう。
1 つ目は ExpandPolicy.InsideOnly です。
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")このモードでは、境界にテキストを挿入してもスタイル範囲は拡張されません。
ここでは、まず太字の範囲を適用します。
太字範囲の先頭にテキストを挿入すると、スタイル範囲はシフトしますが、拡張されません。
同様に、太字の範囲の末尾にテキストを挿入しても、スタイル範囲は拡張されません。
エディタでの動作を示すデモをご覧ください。
次は ExpandPolicy.AtStart です。
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")名前が示すように、範囲の先頭にテキストを挿入すると、スタイル範囲が拡張されます。
そのため、スタイル範囲の直前にテキストを挿入すると、スタイル範囲が拡張されて、新しく挿入されたテキストがカバーされます。
仕組みを示す別のデモをご覧ください。
3 つ目は ExpandPolicy.AtEnd です。
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")そのため、範囲の末尾にテキストを挿入すると、スタイル範囲が拡張されます。
そのため、これは編集者にとって最も一般的な設定でもあります。
このデモでは、その仕組みを紹介します。
最後に ExpandPolicy.AtBoth です。これは、範囲の先頭と末尾にテキストが挿入されたときにスタイルを拡張します。
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}これに関する別のデモです。
ここまで、スタイルを適用する API をすべて説明しました。
さらに、指定した範囲に現在適用されているスタイルを取得するための新しいクエリ API(getSpanStyles、getParagraphStyles など)も導入しました。
// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>クエリ API には 2 つのバージョンがあります。
TextFieldState では、スタイルクエリ API は読み取り専用です。
不変の AnnotatedString.Range オブジェクトのリストを返します。
TextFieldBuffer では、getSpanStyles は新しく導入された TrackedRange オブジェクトのリストを返します。これについては後ほど説明します。
そのため、すべてのスタイルクエリは順序を認識します。
スタイルが適用された順序でスタイル範囲を返します。
これにより、AnnotatedString と同様に、既存のスタイルの上に別のスタイルを適用して、以前のスタイルを上書きできます。
次は TrackedRange API です。
var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): BooleanTrackedRange オブジェクトは、TextFieldBuffer の addStyle API または getSpanStyles API によって返されます。
これは、バッファ内のスタイル範囲のライブハンドルと考えることができます。
これを使用して、テキスト範囲、ExpandPolicy、またはスタイル自体をその場で更新できます。
また、スタイル範囲が TextFieldBuffer にまだ存在するかどうかを確認する isValid プロパティもあります。
これは、編集時にスタイルが折りたたまれた場合や、removeStyle によって意図的に削除された場合に、スタイルが削除される可能性があるためです。
次に、これらの API が実際のユースケースでどのように組み合わされるかを見てみましょう。
最初のユースケースは、WYSIWYG(見たとおりの結果が得られる)エディタの構築です。
この例では、太字ボタン機能を実装しました。
太字ボタンをクリックすると、選択したテキストの太字スタイルが切り替わります。
次に、コードを見てみましょう。
ユーザーが太字ボタンをタップすると、まず選択範囲で getSpanStyles を呼び出して、重複するスタイルをすべて見つけます。
fun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}次に、選択範囲全体がすでに太字になっているかどうかを確認します。
ここでは、選択した単語は太字ではありません。
太字ではないため、選択した範囲に addStyle します。
ExpandPolicy.AtEnd を渡すことで、ユーザーはこの単語の直後に太字の入力を続けることができます。
次に、選択した範囲がすでに太字になっている場合にどうなるかを見てみましょう。
fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}その場合は、選択した範囲から太字スタイルをクリアする必要があります。
この例では、ユーザーが選択した単語が、太字の長いフレーズの中に含まれています。
太字スタイルが選択範囲の前まで及んでいるため、スタイル範囲を更新して、選択した単語を太字にしないようにする必要があります。
これを行うには、TrackedRange API を使用します。
このケースでは、このスタイルを削除して再作成するのではなく、太字スパンの範囲をインプレースで調整して、インデックス 10 の選択範囲の前で終了するようにします。
もちろん、これは非常に簡略化された実装です。
完全なデモについては、Compose サンプル リポジトリをご覧ください。
次に、パターンベースのスタイル設定について見ていきましょう。
このデモでは、ユーザーが単語を Markdown のアスタリスクで囲むと、エディタが入力中に自動的にテキストを太字にする機能に変換します。
では、InputTransformation を使用してどのように実装されているかを見てみましょう。
object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}変換入力フィルタでは、ボタン上で一致するパターンをすべて検索します。
そして、一致するたびに、2 つのアスタリスク内のコンテンツを抽出します。
この例では、「fox」という単語です。
次に、アスタリスクを削除し、一致するパターンをその内容に置き換えます。
最後に、addStyle を呼び出して、コンテンツ テキストに太字スタイルを適用します。
InputTransformation は、テキスト フィールドの最終的な状態を送信することに注意してください。
つまり、太字スタイルが状態に永続的に適用されます。
最後に、検索ハイライトの例を見てみましょう。
この例では、ユーザーが検索クエリを入力すると、テキスト フィールドの状態を変更せずに、テキスト フィールド内の一致する単語がハイライト表示されます。
次に、OutputTransformation での実装方法を見てみましょう。
検索キーワードをハイライト表示するときに、テキストの状態を変更しないでください。
OutputTransformation は、このユースケースを実装するのに最適な場所です。
実際の実装を確認してみましょう。
まず、ハイライト表示する単語を検索し、一致するものをすべて見つけます。
class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}また、クエリは可変状態です。
これは、OutputTransformation がスナップショット対応であるためです。
この可変状態が更新されると、OutputTransformation が自動的に再適用されます。
次に、すべての一致をループ処理し、addStyle を呼び出して、一致範囲に黄色のハイライトを描画します。
ここでも、OutputTransformation は表示テキストのみを変更し、元のテキストの状態は変更されません。
ユースケースは以上です。
なお、前述の API はすべて Compose 1.12 以降で利用できます。
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())BasicTextField(state = rememberTextFieldState())SecureTextField(state = rememberTextFieldState())@Composable
public fun BasicTextField(
state: TextFieldState,
)@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}onValueChange { newString ->
// full diffing new String vs old String
}@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}data class FormUiState(val username: TextFieldState)class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): Booleanfun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}