Senior Software Engineer
Die Entwicklung einer zuverlässigen Textbearbeitung in Compose gipfelte in der vollständigen Neuschreibung von BasicTextField. Durch die Umstellung auf eine puffergesteuerte Architektur wurden Race-Bedingungen bei der Eingabemethode endgültig beseitigt. In dieser Session wird erläutert, warum die alte Eingabesynchronisierung abgeschafft werden musste und wie TextFieldState Tastaturaktualisierungen synchronisiert. Anschließend geht es um die formatierte Bearbeitung. Dabei werden die Funktionsweise von TrackedRange, die Leistungskosten von Bearbeitungsblöcken und die realen Probleme hinter Stilgrenzen erläutert, bevor ein erster Blick auf das Markdown-Rendering geworfen wird.
TextFieldState-API-Überladung ersetzt die Callback-basierten Überladungen in Compose. Sie wird synchron mit der Eingabe des Nutzers aktualisiert, wodurch Timing-Probleme mit Softwaretastaturen und unnötige Neuzusammensetzungen vermieden werden.TextFieldBuffer bearbeiten. Sie können Änderungen abfangen, bevor sie übernommen werden, oder angezeigten Text formatieren, ohne Offset-Zuordnungen manuell berechnen zu müssen.TextFieldState hängt von Ihren Architekturanforderungen ab. Sie können ihn lokal in einer zusammensetzbaren Funktion speichern oder direkt in einem ViewModel für den Zugriff auf die Geschäftslogik speichern.TextFieldBuffer hinzugefügt. Sie können Stile mit anpassbaren ExpandPolicy-Optionen anwenden. Vorhandene Stilbereiche werden automatisch aktualisiert, wenn Nutzer Änderungen vornehmen.Transkript
Hallo zusammen!
Vielen Dank für Ihre Teilnahme an unserem Vortrag.
Ich bin Anastasia und das ist mein Kollege Haoyu.
Heute zeigen wir Ihnen, wie Sie eine zuverlässige Textbearbeitung erstellen.
Egal, ob Sie einen einfachen Anmeldebildschirm, eine Suchleiste mit automatischer Vervollständigung oder eine komplexe Messaging-App entwickeln – Textfelder sind das Herzstück der Interaktion von Nutzern mit Ihren Android-Apps.
Die meisten von Ihnen verwenden wahrscheinlich bereits Material-Textfelder oder umrandete Textfelder.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())Es gibt auch eine BasicTextField, wenn Sie ein eigenes Designsystem erstellen oder ein einfaches Bearbeitungsfeld benötigen.
BasicTextField(state = rememberTextFieldState())Es gibt auch SecureTextField, das speziell für die Eingabe von Passwörtern entwickelt wurde.
SecureTextField(state = rememberTextFieldState())BasicTextField ist eine unformatierte Komponente, die alle anderen Textfelder in Compose unterstützt.
In den letzten Jahren haben wir zwei API-Formen parallel verwendet: eine, die den Wert und den Rückruf für die Wertänderung akzeptiert, und eine, die das TextFieldState-Objekt akzeptiert.
@Composable
public fun BasicTextField(
state: TextFieldState,
)Sie fragen sich vielleicht, welche wir empfehlen.
Wir empfehlen dringend, die Überladung TextFieldState zu verwenden.
In diesem Vortrag werden wir die Vorteile in Bezug auf API, Funktionen und Leistung besprechen.
Sie ist seit Compose 1.7 stabil.
Die Callback-Überladungen werden in Compose 1.13 für BasicTextField und Material-Wrapper eingestellt.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> UnitDas bedeutet, dass wir keine neuen Funktionen hinzufügen oder Fehler beheben, es sei denn, es handelt sich um kritische Sicherheitsprobleme oder Probleme mit der Barrierefreiheit im Internet.
Der zustandslose Wert bei der Überladung von Wertänderungen hat bei synchronen Eingaben wirklich gut funktioniert.
Sobald Sie es jedoch mit asynchronen Streams wie StateFlow gekoppelt haben, kam es zu Timing-Problemen mit dem IME oder, als Fachbegriff, mit der Softwaretastatur.
Das führte zu fehlenden Zeichen, doppelten Zeichen und Cursorsprüngen, wie Sie in dieser Aufnahme sehen können.
Um dieses Problem grundlegend zu beheben, haben wir TextFieldState eingeführt.
Sie wird synchron aktualisiert, wenn der Nutzer etwas eingibt, ohne auf die asynchronen Roundtrips zu warten. Genau das ist für die Eingabemethode erforderlich.
TextFieldState hat neben dem Text mit Lesezugriff auch andere Eigenschaften, die eine aktive Bearbeitungssitzung erstellen.
Das sind Auswahl, Rückgängig- und Wiederholen-Status sowie Span- und Span-Stile und Absatzstile für die Bearbeitung von formatiertem Text. Mehr dazu später in diesem Vortrag.
Die internen Properties von TextFieldState werden vom Compose Snapshot System unterstützt.
Wenn Sie also diesen Text oder diese Auswahl aus dem TextFieldState-Objekt irgendwo in Compose lesen, wird der spezifische Bereich, aus dem Sie ihn lesen, für die Aktualisierungen abonniert.
Dadurch kann BasicTextField den Status nur in der Layout- oder Zeichenphase lesen. Zusammen mit einigen anderen Maßnahmen wird so die unnötige Neuzusammensetzung beim Tippen des Nutzers vermieden.
Zusammen mit einigen anderen Änderungen führte dies zu einer erheblichen Leistungsverbesserung für die Überlastung von TextFieldState.
Wenn der Text des TextFieldState eine Eigenschaft mit Lesezugriff ist, wie können Sie dann programmatische Änderungen an Ihrem Eingabefeld vornehmen?
Und hier kommt der TextFieldBuffer ins Spiel.
Es ist ein änderbares Objekt, das APIs zum Vornehmen von Änderungen bereitstellt.
Wenn Sie also die programmatische Änderung vornehmen möchten, rufen Sie die Bearbeitungsfunktion des TextFieldState auf.
Und innerhalb dieses Lambdas erhalten Sie einen TextFieldBuffer.
Und TextFieldBuffer stellt mehrere Funktionen bereit, mit denen Sie Zeichen nach Bedarf einfügen, ersetzen und löschen können.
Ein kurzer Tipp zur Bearbeitung: Vermeiden Sie es, sie mehrmals hintereinander aufzurufen.
// 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) }Fassen Sie stattdessen alle Vorgänge in einem einzigen Bearbeitungsaufruf zusammen.
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}So erstellt Compose nur eine einzige Instanz des TextFieldBuffer, ändert sie direkt und synchronisiert sie dann einmal mit der Eingabemethode und dem Snapshot-System.
Ein weiterer Tipp: Für die meisten gängigen Batchvorgänge, die Sie benötigen, gibt es Erweiterungsfunktionen für TextFieldState.
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}Sie müssen sich also keine Gedanken darüber machen, diese Vorgänge in Batches zusammenzufassen.
Sie rufen einfach eine dieser Nummern an.
Beispiele hierfür sind clearText und setTextAndPlaceCursorAtEnd.
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}Wie sieht es mit dem Filtern der Nutzereingabe aus?
Beispielsweise können Sie eine Zeichenbeschränkung erzwingen oder nur Ziffern zulassen.
In der alten API wurde die Filterung in der onValueChange ausgeführt, nachdem der Text bereits übernommen wurde.
onValueChange { newString ->
// full diffing new String vs old String
}Wenn diese Eingabe abgelehnt oder geändert wurde, war das wie ein Kampf mit der Tastatur.
Außerdem wurde mit dem onValueChange-Callback nur der endgültige String zurückgegeben.
Um zu verstehen, was sich geändert hat, mussten Sie also den gesamten String vergleichen.
Stattdessen haben wir in der neuen Überladung eine Eingabetransformation.
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}Sie fängt also Nutzereingaben ab, bevor sie den Textfeldstatus erreichen, und bietet dieselben TextFieldBuffer-APIs.
So implementieren Sie die Filterung nur nach Ziffern mit der Eingabetransformation. Sie übernehmen im Grunde nur die Änderungen.
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}Sie prüfen, ob nicht numerische Zeichen vorhanden sind. Wenn ja, lehnen Sie die Eingabe ab, indem Sie revertAllChanges für diesen TextFieldBuffer aufrufen.
Die wichtigste Funktion dieser Eingabetransformation ist „ChangeList“.
Anstatt die vollständigen Strings zu vergleichen, können Sie die genauen Bearbeitungsbereiche prüfen und sie dann direkt ändern oder die Eingabe bei Bedarf ganz ablehnen.
Und Compose synchronisiert die Tastatur und den Cursor automatisch für Sie.
Was ist, wenn Sie Ihre Eingabe visuell formatieren möchten?
Sie möchten beispielsweise Klammern oder Bindestriche in eine Kreditkartennummer oder Telefonnummer einfügen.
– Uh. – Ja.
Und Sie möchten diese nicht in Ihrer Datenbank speichern, oder?
Bisher haben Sie die Visual Transformation API verwendet.
In diesem Fall mussten Sie eine bidirektionale Zuordnung von der Roheingabe zur visuellen Ausgabe und dann zurück implementieren.
Es war sehr schwierig, das richtig zu machen, was oft zu Laufzeitausnahmen führte.
Jetzt haben wir eine Ausgabetransformation, die im Hintergrund ebenfalls vom TextFieldBuffer unterstützt wird. Sie erhalten also im Grunde dieselbe API.
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}In diesem Beispiel mit der Telefonnummer rufen Sie einfach die Einfügefunktion des TextFieldBuffers auf, um die Bindestriche an einem bestimmten Index einzufügen. Das System berechnet dann die bidirektionale Offset-Zuordnung für Sie.
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}So entfällt die manuelle Berechnung und die Anzahl der Abstürze wird erheblich reduziert.
Nun zum wichtigsten Teil: Wie migrieren Sie eigentlich und wo hissen Sie die TextFieldState?
In der Praxis fällt fast jeder Anwendungsfall in eine der Kategorien, die ich gleich vorstellen werde.
Wenn nicht, bedeutet das in der Regel, dass Sie eine unkonventionelle Architektur haben. Diese Beispiele helfen Ihnen aber trotzdem bei der Migration.
Der einfachste Fall ist, den TextFieldState direkt in der zusammensetzbaren Funktion zu belassen, in der sich das Textfeld befindet.
@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()) }) { }
)
}Das ist der Fall, wenn Ihr Textfeld ein eigenständiges Eingabefeld ist, z. B. ein Eingabefeld für einen Gutscheincode, und Sie nur auf diesen Text zugreifen müssen, wenn der Nutzer beispielsweise auf den Button zum Senden klickt.
Ihr Ansichtsmodell muss also nicht bei jeder Änderung die Nutzereingabe abfangen.
Sie rufen also einfach rememberTextFieldState lokal auf und rememberTextFieldState hilft Ihnen auch bei der Wiederherstellung des Zustands nach der Prozessbeendigung.
Dann verwenden Sie die Eingabetransformation zum Filtern und rufen den Text aus diesem Zustand, dem TextFieldState-Objekt, ab. Wenn der Nutzer die Eingabe sendet, übergeben Sie sie über das Lambda.
Was ist, wenn gleichgeordnete UI-Elemente wie ein Zeichenzähler oder ein Button zum Senden den Text ebenfalls lesen oder ändern müssen?
Das bringt uns zu einem Fall, in dem Sie das TextFieldState-Objekt in eine gemeinsame zusammensetzbare Funktion, eine gemeinsame übergeordnete zusammensetzbare Funktion, hochstufen.
@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)
}
}Da TextFieldState vom Snapshot-System unterstützt wird, wird durch die Übergabe über den Ancestor keine Neuzusammensetzung der übergeordneten Elemente ausgelöst.
Nur die Stellen, an denen Sie den Text des Status tatsächlich lesen, werden aktualisiert.
Okay.
Für komplexe UI-Anwendungsfälle, in denen TextFieldState, Eingabe- und Ausgabetransformation den Status intern koordinieren müssen, sollten Sie ein State Holder-Objekt verwenden, anstatt sie separat zu deklarieren und in der zusammensetzbaren Funktion zu verknüpfen.
@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,
)
}Stellen Sie sich vor, Sie möchten einen Inline-Hashtag verarbeiten.
TextFieldState enthält einen Rohtext und eine Cursorposition.
Die Eingabetransformation ist für die Erkennung eines Hashtags verantwortlich. Außerdem können Sie über die Eingabetransformation atomare Tags löschen.
Die Ausgabetransformation würde zusätzliche visuelle Stile hinzufügen und könnte Ihnen auch helfen, den Hashtag zu löschen.
Und sie sind alle in einem einzigen State Holder-Objekt zusammengefasst, das ich TagEditorState nenne.
@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) { ... }
}Anschließend können alle die Zwischenberechnungen wie tagRanges in diesem Fall wiederverwenden.
Anschließend übergeben Sie dieses State Holder-Objekt einfach an Ihre zusammensetzbare Funktion.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}Ein weiterer Anwendungsfall ist, wenn Ihre Geschäftslogik Zugriff auf diesen Text für die Suche während der Eingabe oder für eine komplexe Validierung benötigt. Verschieben Sie dieses Textfeld in Ihre ViewModel.
Es ist wichtig, dass Sie kein änderbares Textfeldobjekt in den unveränderlichen UI-Zustand der Datenklasse einfügen, da es mit einer Kopiermethode kopiert und dann bei jeder Änderung in StateFlow ausgegeben wird.
data class FormUiState(val username: TextFieldState)TextFieldState ist ein langlebiges, veränderbares Objekt, das vom Snapshot-System unterstützt wird. Das Referenzobjekt ändert sich nie.
Ihre StateFlow weiß also nicht einmal, dass der Nutzer gerade etwas eingibt, weil die Referenz gleich bleibt.
Noch schlimmer: Wenn Sie eine neue Instanz des UI-Zustands erstellen und ein neues TextFieldState zuweisen, verliert das Eingabefeld seinen gesamten Zustand.
Das sind beispielsweise die Auswahl, die Cursorposition oder der Verlauf der rückgängig gemachten/wiederholten Aktionen.
Machen Sie TextFieldState stattdessen zu einer Property direkt in ViewModel.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}Und machen Sie sich keine Sorgen wegen der Regel „Keine UI in ViewModel“, denn in diesem Fall verweist TextFieldState nicht auf ein Android-View- oder Context-Objekt oder den Compose-Lebenszyklus.
Es ist nur ein reines Snapshot-Backed-State-Holder-Objekt.
In diesem Fall unterscheidet es sich also konzeptionell nicht von StateFlow.
Beachten Sie, dass ViewModel nicht über rememberSaveable verfügt. Sie müssen also TextFieldState.Saver für das Textfeld einbinden, damit es die Prozessbeendigung übersteht und seinen Status wiederherstellt.
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}Was ist, wenn Ihr Team strenge Modulregeln hat, nach denen ViewModels nur in reinen Kotlin-Modulen vorhanden sein dürfen, und Sie die Abhängigkeit „compose-foundation“ nicht einbinden können?
Oder Sie migrieren einen komplexen Legacy-Bildschirm und können den UI-Status heute nicht aktualisieren oder refaktorieren?
Verwenden Sie in diesem Fall den konformen Ansatz.
Sie behalten also den TextFieldState in der zusammensetzbaren Funktion als einzige verlässliche Quelle für dieses Textfeld bei.
Anschließend verwenden Sie LaunchedEffect mit einem snapshotFlow, um die Textänderungen wieder in Ihre vorhandene ViewModel zu übertragen.
@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)
}Ihre ViewModel bleibt frei von Compose. Da die UI-Zustandseigenschaften nicht bei jeder Änderung an das Textfeld zurückgegeben werden, vermeiden Sie weiterhin diese asynchronen Roundtrip-Fehler.
Ja, und achten Sie hier auf eine häufige Falle.
@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)
}In den meisten Fällen sollten Sie das erste Ereignis weglassen.
So werden unnötige doppelte Suchanfragen vermieden und es wird verhindert, dass der ViewModel mit dem ursprünglichen Wert überschrieben wird.
Wenn Ihre Logik diese Änderungen an den Status zurückgeben muss, z. B. wenn der Nutzer auf „Senden“ klickt und Sie den Text löschen möchten, können Sie nicht einfach einen leeren String für ViewModel des ViewModels festlegen.
@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)
}Stattdessen müssen Sie ein Ereignis zum Löschen des Texts ausgeben. Die zusammensetzbare Funktion, die das TextFieldState-Objekt enthält, ruft dann clearText für dieses Objekt auf.
Diese Migrationsanleitung soll Sie bei der Umstellung unterstützen.
Sie können über diesen Link darauf zugreifen.
Wir arbeiten außerdem an einer zusätzlichen API, um sowohl die Migration als auch die Beobachtung allgemeiner TextFieldState-Änderungen zu vereinfachen. Bleiben Sie also dran.
Und alles, was wir bisher behandelt haben, TextFieldState, TextFieldBuffer, Eingabe- und Ausgabetransformation, wurde nicht nur für die Nur-Text-Eingabe entwickelt.
Diese sind die Grundlage für unsere neuesten APIs für die Bearbeitung von formatiertem Text.
Haoyu wird euch jetzt mehr darüber erzählen.
Okay.
Vielen Dank.
Hallo zusammen.
Als Nächstes spreche ich über neue APIs, die wir für die Bearbeitung von formatiertem Text eingeführt haben.
Bevor wir uns die neuen APIs ansehen, werfen wir einen Blick darauf, wie die Formatierung in statischem Text funktioniert.
Die meisten von Ihnen kennen AnnotatedString also bereits sehr gut.
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)Das ist die Standardmethode, um statischen Text zu formatieren.
Wir verwenden buildAnnotatedString und rufen addStyle auf, um SpanStyle oder ParagraphStyle auf bestimmte Bereiche anzuwenden.
Sie eignet sich gut für statischen Text.
Als wir uns die Bearbeitung von formatiertem Text angesehen haben, dachten wir zuerst: Können wir AnnotatedString nicht einfach bearbeitbar machen?
Aber wir haben schnell gemerkt, dass das nicht so einfach ist.
„AnnotatedString“ ist eine unveränderliche Datenstruktur.
In der Bearbeitungswelt tippt der Nutzer jedoch ständig.
Und bei jedem Tastendruck müssen wir ein neues AnnotatedString-Objekt zuweisen.
Und jeder AnnotatedString enthält viele AnnotatedString.Range.
Diese Objekte müssen also ebenfalls neu erstellt werden.
Sie lässt sich nicht gut skalieren und verursacht einen erheblichen Zuordnungsaufwand.
Sehen Sie sich dieses Beispiel an.
Wenn wir das Wort in der Mitte einfügen, verschiebt sich der Bereich aller nachfolgenden Stile und sie werden neu zugewiesen.
Das funktioniert natürlich nicht, wenn Sie viele Stile haben.
Stattdessen haben wir spezielle Styling-APIs im TextFieldBuffer eingeführt, den Anastasia zuvor vorgestellt hat.
Sie sind den Styling-APIs in AnnotatedString sehr ähnlich.
Sehen wir uns an, wie sie funktionieren.
Genau wie AnnotatedString.Builder hat TextFieldBuffer jetzt addStyle, um SpanStyle oder ParagraphStyle auf einen Bereich anzuwenden.
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>Sie können auch einen ExpandPolicy-Parameter übergeben, der steuert, wie sich der Stil verhält, wenn Text direkt an den Stilgrenzen eingegeben wird.
Das sehen wir uns gleich an.
Sehen wir uns zuerst an, wie das Stil-Tracking funktioniert.
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)
}Sobald Sie dem TextFieldBuffer einen Stil hinzufügen, wird der Bereich automatisch aktualisiert, wenn der Text bearbeitet wird.
In diesem Beispiel formatieren wir zuerst einen Text fett.
Wenn Sie Text davor einfügen, verschiebt sich der Stilbereich nach vorn.
Wenn Sie Text darin einfügen, wird der Textbereich erweitert.
Und wenn wir Text innerhalb des Bereichs löschen, wird der Stilbereich entsprechend verkleinert.
Wenn der gesamte Text innerhalb des Stilbereichs gelöscht wird, wird der Stil minimiert und automatisch aus dem Zwischenspeicher entfernt.
Was passiert also, wenn der Text genau an den Grenzen eines Stilbereichs eingefügt wird?
Ist der neu eingefügte Text vom Stil abgedeckt oder nicht?
Damit Sie dies steuern können, haben wir ExpandPolicy APIs eingeführt.
Sehen wir uns die einzelnen Schritte an.
Der erste ist 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")In diesem Modus werden die Stilbereiche nicht erweitert, wenn Text an der Grenze eingefügt wird.
Hier wenden wir zuerst einen fett formatierten Bereich an.
Wenn wir Text am Anfang des fett formatierten Bereichs einfügen, verschiebt sich der Stilbereich, wird aber nicht erweitert.
Wenn wir Text am Ende des fett formatierten Bereichs einfügen, wird der Stilbereich ebenfalls nicht erweitert.
Hier ist eine Demo, die zeigt, wie das in einem Editor funktioniert.
Als Nächstes kommt ExpandPolicy.AtStart.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")Wie der Name schon sagt, wird der Stilbereich durch das Einfügen von Text am Anfang des Bereichs erweitert.
Wenn wir also Text direkt davor einfügen, wird der Stilbereich erweitert, um den neu eingefügten Text abzudecken.
Hier ist eine weitere Demo, die zeigt, wie das funktioniert.
An dritter Stelle steht ExpandPolicy.AtEnd.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")Wir fügen also Text am Ende des Bereichs ein und erweitern den Stilbereich.
Das ist auch die gängigste Einstellung für die Editoren.
Hier ist eine Demo, die zeigt, wie es funktioniert.
Schließlich gibt es noch ExpandPolicy.AtBoth, wodurch der Stil erweitert wird, wenn der Text am Anfang und am Ende des Bereichs eingefügt wird.
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")
}Hier ist eine weitere Demo dazu.
Wir haben bisher alle APIs zum Anwenden von Stilen behandelt.
Außerdem haben wir neue Abfrage-APIs wie getSpanStyles und getParagraphStyles eingeführt, um die aktuell in einem bestimmten Bereich angewendeten Stile abzurufen.
// 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>>Es gibt zwei Versionen der Abfrage-APIs.
Auf dem TextFieldState haben die APIs für Stilabfragen Lesezugriff.
Sie geben eine Liste unveränderlicher AnnotatedString.Range-Objekte zurück.
Im TextFieldBuffer hat getSpanStyles jedoch eine Liste neu eingeführter TrackedRange-Objekte zurückgegeben, die ich später vorstellen werde.
Alle Stilabfragen sind also reihenfolgeabhängig.
Sie geben die Stilbereiche genau in der Reihenfolge zurück, in der der Stil angewendet wird.
So können Sie, genau wie bei AnnotatedString, einen anderen Stil auf einen vorhandenen Stil anwenden, um die vorherigen zu überschreiben.
Als Nächstes kommt die 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-Objekte werden von addStyle- oder getSpanStyles-APIs in TextFieldBuffer zurückgegeben.
Stellen Sie sich das als Live-Handle des Stilbereichs im Zwischenspeicher vor.
Sie können damit den Textbereich, ExpandPolicy oder den Stil selbst an der Stelle aktualisieren.
Außerdem gibt es die Property isValid, mit der geprüft werden kann, ob sich der Stilbereich noch im TextFieldBuffer befindet.
Das liegt daran, dass der Stil entfernt werden kann, wenn er während der Bearbeitung minimiert wird oder wenn er absichtlich vom removeStyle entfernt wird.
Sehen wir uns als Nächstes an, wie diese APIs in realen Anwendungsfällen zusammenwirken.
Der erste Anwendungsfall ist die Erstellung eines WYSIWYG-Editors (What You See Is What You Get).
In diesem Beispiel haben wir die Funktion für die Schaltfläche „Fett“ implementiert.
Wenn Sie auf die Schaltfläche „Fett“ klicken, wird der ausgewählte Text fett formatiert.
Sehen wir uns nun den Code an.
Wenn der Nutzer also auf die Schaltfläche „Fett“ tippt, rufen wir zuerst getSpanStyles für eine Auswahl auf, um alle sich überschneidenden Stile zu finden.
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())
}
}Anschließend prüfen wir, ob die gesamte Auswahl bereits fett formatiert ist oder nicht.
Hier ist das ausgewählte Wort nicht fett formatiert.
Da der Text nicht fett formatiert ist, addStyle wir den ausgewählten Bereich.
Wir übergeben ExpandPolicy.AtEnd, damit der Nutzer direkt nach diesem Wort fett formatierten Text eingeben kann.
Sehen wir uns nun an, was passiert, wenn der ausgewählte Bereich bereits fett formatiert ist.
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)
}
}In diesem Fall müssen wir den fett formatierten Stil aus dem ausgewählten Bereich entfernen.
Hier befindet sich das vom Nutzer ausgewählte Wort innerhalb der längeren fett formatierten Wortgruppe.
Da sich der fett formatierte Stil vor der Auswahl erstreckt, müssen wir den Stilbereich aktualisieren, sodass das ausgewählte Wort nicht fett formatiert ist.
Dazu können wir eine TrackedRange API verwenden.
In diesem Fall können wir den Stil einfach anpassen, anstatt ihn zu löschen und neu zu erstellen. Dazu ändern wir den Bereich des Fettdrucks so, dass er vor der Auswahl bei Index 10 endet.
Natürlich ist das eine stark vereinfachte Implementierung.
Eine vollständige Demo finden Sie in unserem Repository mit Compose-Beispielen.
Als Nächstes sehen wir uns die musterbasierte Stilgestaltung an.
In dieser Demo gibt der Nutzer Markdown-Sternchen um ein Wort ein und der Editor wandelt es automatisch in Fettdruck um.
Sehen wir uns nun an, wie das mit InputTransformation implementiert wird.
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
)
}
}
}In unserem transformierenden Eingabefilter finden wir alle übereinstimmenden Muster über der Schaltfläche.
Und für jede Übereinstimmung extrahieren wir den Inhalt innerhalb der doppelten Sternchen.
In diesem Beispiel ist es das Wort „fox“.
Als Nächstes entfernen wir die Sternchen und ersetzen das übereinstimmende Muster durch seinen Inhalt.
Und schließlich rufen wir addStyle für „bold“ auf, um den fett formatierten Stil auf den Inhaltstext anzuwenden.
Denken Sie daran, dass InputTransformation den endgültigen Textfeldstatus sendet.
Das bedeutet, dass der fett formatierte Stil dauerhaft auf den Status angewendet wird.
Sehen wir uns zum Schluss noch das Beispiel für die Hervorhebung von Suchergebnissen an.
Wenn ein Nutzer in diesem Beispiel eine Suchanfrage eingibt, werden übereinstimmende Wörter in den Textfeldern hervorgehoben, ohne dass der Status des Textfelds geändert wird.
Sehen wir uns nun an, wie das in der OutputTransformation implementiert wird.
Wenn Sie ein Suchwort hervorheben, sollten Sie den Textstatus nie ändern.
OutputTransformation ist der perfekte Ort, um diesen Anwendungsfall zu implementieren.
Sehen wir uns die tatsächliche Implementierung an.
Zuerst suchen wir nach den hervorgehobenen Wörtern und finden alle Übereinstimmungen.
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)
}
}
}Beachten Sie, dass die Abfrage ein änderbarer Status ist.
Das liegt daran, dass OutputTransformation Snapshot-fähig ist.
Wenn diese änderbaren Zustände aktualisiert werden, wird die OutputTransformation automatisch neu angewendet.
Anschließend durchlaufen wir alle Übereinstimmungen und rufen addStyle auf, um die gelben Markierungen über dem Übereinstimmungsbereich zu zeichnen.
Auch hier ändert OutputTransformation nur den visuellen Text, während die ursprünglichen Textzustände unverändert bleiben.
Okay, das sind alle Anwendungsfälle, die wir haben.
Zur Erinnerung: Alle zuvor gezeigten APIs sind ab Compose 1.12 verfügbar.
Zum Abschluss möchte ich Ihnen noch einen kleinen Einblick in das neue Projekt geben, an dem wir gerade arbeiten.
Es ist eine Markup-Textbibliothek für Jetpack Compose, die Rich-Text-Formate wie Markdown unterstützt.
Eine der interessantesten Funktionen, die wir unterstützen, ist das inkrementelle Parsen und Rendern.
Sie eignet sich also sehr gut für die Live-Bearbeitung oder das Markdown-Streaming.
Hier sehen Sie unseren funktionierenden Prototyp in Aktion. Die verblassenden Bereiche sind die aktualisierten Teile der Benutzeroberfläche.
Wenn Sie daran interessiert sind oder einen Anwendungsfall in Ihrer App haben, können Sie sich gerne an uns wenden.
Und vielen Dank an alle.
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)
}
}
}