Starszy inżynier oprogramowania
Droga do niezawodnej edycji tekstu w Compose zakończyła się całkowitym przepisywaniem BasicTextField. Przejście na architekturę opartą na buforze wyeliminowało sytuacje wyścigu w edytorze IME raz na zawsze. Podczas tej sesji wyjaśnimy, dlaczego musieliśmy zrezygnować ze starszej wersji synchronizacji danych wejściowych i jak TextFieldState synchronizuje aktualizacje klawiatury. Następnie szczegółowo omówimy edytowanie sformatowanego tekstu, aby poznać mechanizmy TrackedRange, koszty wydajności bloków edycji i rzeczywiste pułapki związane z granicami stylów, a na koniec przyjrzymy się renderowaniu Markdown.
TextFieldState zastępuje przeciążenia oparte na wywołaniach zwrotnych w Compose. Aktualizuje się synchronicznie z pisaniem przez użytkownika, co eliminuje problemy z czasem w przypadku klawiatur ekranowych i niepotrzebne rekompozycje.TextFieldBuffer. Możesz przechwytywać zmiany przed ich zatwierdzeniem lub formatować wyświetlany tekst bez ręcznego obliczania mapowania przesunięcia.TextFieldState zależy od potrzeb architektury. Możesz przechowywać go lokalnie w funkcji kompozycyjnej lub bezpośrednio w ViewModel, aby uzyskać dostęp do logiki biznesowej.TextFieldBuffer interfejsy API do edycji sformatowanego tekstu. Możesz stosować style z dostosowywanymi opcjami ExpandPolicy, a istniejące zakresy stylów będą automatycznie aktualizowane podczas edytowania przez użytkowników.Transkrypcja
Cześć,
Dziękujemy za udział w naszej prezentacji.
Nazywam się Anastasia, a to jest mój kolega Haoyu.
Dzisiaj pokażemy, jak stworzyć niezawodne środowisko do edycji tekstu.
Niezależnie od tego, czy tworzysz prosty ekran logowania, pasek wyszukiwania podczas pisania czy złożoną aplikację do obsługi wiadomości, pola tekstowe są podstawą interakcji użytkowników z aplikacjami na Androida.
Większość z Was prawdopodobnie używa już pól tekstowych Material Design lub pól tekstowych z obramowaniem.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())Dostępny jest też BasicTextField, jeśli tworzysz własny system projektowania lub potrzebujesz prostego pola edycji.
BasicTextField(state = rememberTextFieldState())Dostępna jest też klawiatura SecureTextField, która została zaprojektowana specjalnie do wpisywania haseł.
SecureTextField(state = rememberTextFieldState())BasicTextField to komponent bez stylu, który obsługuje wszystkie inne pola tekstowe w Compose.
W ciągu ostatnich kilku lat można było zauważyć, że mamy 2 równoległe kształty interfejsu API: jeden przyjmuje wartość i wywołanie zwrotne zmiany wartości, a drugi – obiekt TextFieldState.
@Composable
public fun BasicTextField(
state: TextFieldState,
)Możesz się zastanawiać, którą z nich zalecamy.
Zdecydowanie zalecamy używanie przeciążenia TextFieldState.
W tym wystąpieniu omówimy, dlaczego jest to korzystne pod względem interfejsu API, funkcji i wydajności.
Jest stabilna od wersji Compose 1.7.
Jeśli chodzi o przeciążenia wywołań zwrotnych, w Compose 1.13 wycofujemy je w przypadku BasicTextField i opakowań Material.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> UnitOznacza to, że nie będziemy dodawać żadnych nowych funkcji ani poprawiać błędów, chyba że będą to krytyczne problemy z zabezpieczeniami lub ułatwieniami dostępu.
The stateless value on value change overload was really really worked well for synchronous inputs.
Jednak gdy połączysz go ze strumieniami asynchronicznymi, np. StateFlow, pojawiają się problemy z synchronizacją z edytorem IME lub, mówiąc językiem technicznym, z klawiaturą ekranową.
Powodowało to pomijanie znaków, ich powielanie i przeskakiwanie kursora, jak widać na tym nagraniu.
Aby rozwiązać ten problem, wprowadziliśmy TextFieldState.
Aktualizuje się synchronicznie, gdy użytkownik pisze, bez czekania na asynchroniczne operacje, co jest dokładnie tym, czego potrzebuje edytor IME.
TextFieldState, oprócz tekstu tylko do odczytu, ma też inne właściwości, które tworzą aktywną sesję edycji.
Są to: zaznaczenie, stan cofania i ponawiania, a także zakresy, style zakresów i style akapitów do edycji sformatowanego tekstu. Więcej na ten temat powiem później.
Wewnętrzne właściwości TextFieldState są obsługiwane przez system migawek Compose.
Oznacza to, że gdy odczytujesz tekst lub zaznaczenie z obiektu TextFieldState w Compose, konkretny zakres, z którego odczytujesz, jest subskrybowany na potrzeby aktualizacji.
Dzięki temu BasicTextField może odczytywać stan tylko w fazach układu lub rysowania, co w połączeniu z innymi elementami eliminuje niepotrzebną rekompozycję, gdy użytkownik pisze.
Wraz z innymi zmianami pozwoliło to znacznie zwiększyć wydajność w przypadku przeciążenia TextFieldState.
Jeśli tekst TextFieldState jest właściwością tylko do odczytu, jak można wprowadzać zmiany programowe w polu do wprowadzania danych?
I tu właśnie przydaje się TextFieldBuffer.
Jest to obiekt zmienny, który udostępnia interfejsy API umożliwiające wprowadzanie zmian.
Jeśli chcesz wprowadzić zmianę programowo, wywołaj funkcję edycji TextFieldState.
W tej funkcji lambda otrzymujesz TextFieldBuffer.
TextFieldBuffer udostępnia kilka funkcji, które umożliwiają wstawianie, zastępowanie i usuwanie znaków zgodnie z potrzebami.
Jedna wskazówka dotycząca edycji: unikaj wielokrotnego wywoływania ich pod rząd.
// 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) }Zamiast tego połącz wszystkie operacje w jednym wywołaniu edycji.
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}W ten sposób Compose utworzy tylko jedną instancję TextFieldBuffer, zmodyfikuje ją na miejscu, a następnie zsynchronizuje ją z edytorem IME i systemem Snapshot.
Kolejna wskazówka: w przypadku większości typowych operacji zbiorczych, których będziesz potrzebować, mamy funkcje rozszerzeń w TextFieldState.
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}Nie musisz więc myśleć o grupowaniu tych operacji.
Wystarczy zadzwonić pod jeden z tych numerów.
Na przykład: clearText i setTextAndPlaceCursorAtEnd.
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}A co z filtrowaniem danych wejściowych użytkownika?
Możesz na przykład wymusić limit znaków lub akceptować tylko cyfry.
W starym interfejsie API filtrowanie w onValueChange było uruchamiane po zatwierdzeniu tekstu.
onValueChange { newString ->
// full diffing new String vs old String
}Odrzucanie lub zmienianie tych danych wejściowych było więc sprzeczne z klawiaturą.
Ponadto wywołanie zwrotne onValueChange podawało tylko ostateczny ciąg znaków.
Aby dowiedzieć się, co się zmieniło, trzeba było porównać cały ciąg znaków.
Zamiast tego w nowym przeciążeniu mamy przekształcenie danych wejściowych.
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}Przechwytuje więc dane wejściowe użytkownika, zanim dotrą do stanu pola tekstowego, i udostępnia te same interfejsy API TextFieldBuffer.
W ten sposób można wdrożyć filtrowanie tylko cyfr za pomocą przekształcenia danych wejściowych. Wystarczy wprowadzić zmiany.
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}Sprawdzasz, czy są znaki inne niż cyfry, a jeśli tak, odrzucasz dane wejściowe, wywołując revertAllChanges na tym TextFieldBuffer.
Kluczową funkcją tej transformacji danych wejściowych jest ChangeList.
Zamiast porównywać całe ciągi znaków, możesz sprawdzić dokładne zakresy edycji, a następnie zmodyfikować je na miejscu lub w razie potrzeby odrzucić dane wejściowe.
Compose automatycznie synchronizuje klawiaturę i kursor.
Na koniec: co zrobić, jeśli chcesz sformatować wizualnie dane wejściowe?
Możesz na przykład dodać nawiasy lub myślniki do numeru karty kredytowej lub numeru telefonu.
O, tak.
Nie chcesz przechowywać ich w swojej bazie danych, prawda?
Wcześniej używaliśmy interfejsu API do przekształcania wizualnego.
W takim przypadku trzeba było wdrożyć dwukierunkowe mapowanie od nieprzetworzonych danych wejściowych do danych wyjściowych i z powrotem.
Poprawne wykonanie tego zadania było bardzo trudne, co często prowadziło do wyjątków w czasie działania.
Teraz mamy transformację danych wyjściowych, która również jest obsługiwana przez ten sam TextFieldBuffer, więc interfejs API jest praktycznie taki sam.
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}W tym przykładzie z numerem telefonu, aby wstawić łączniki w określonym indeksie, wystarczy wywołać funkcję wstawiania TextFieldBuffer, a system obliczy dwukierunkowe mapowanie przesunięcia.
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}Eliminuje to konieczność ręcznego obliczania i znacznie zmniejsza liczbę awarii.
Teraz przejdźmy do najważniejszej części: jak przeprowadzić migrację i gdzie umieścić TextFieldState?
W praktyce niemal każdy przypadek użycia należy do jednej z tych kategorii, które omówię.
Jeśli nie, zwykle oznacza to, że masz niekonwencjonalną architekturę, ale te przykłady i tak pomogą Ci przeprowadzić migrację.
Najprostszym przypadkiem jest zachowanie TextFieldState bezpośrednio w funkcji kompozycyjnej, w której znajduje się pole tekstowe.
@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()) }) { }
)
}Dzieje się tak, gdy pole tekstowe jest samodzielnym polem do wprowadzania danych, np. polem do wprowadzania kodu promocyjnego, i potrzebujesz dostępu do tego tekstu tylko wtedy, gdy użytkownik np. naciśnie przycisk przesyłania.
Dzięki temu model widoku nie musi przechwytywać danych wejściowych użytkownika przy każdej zmianie.
Wystarczy więc wywołać rememberTextFieldState lokalnie, a rememberTextFieldState pomoże też przywrócić stan po śmierci procesu.
Następnie używasz przekształcenia danych wejściowych do filtrowania i pobierasz tekst z tego stanu, obiektu TextFieldState, a gdy użytkownik przesyła dane, przekazujesz je przez funkcję lambda.
Co jeśli elementy interfejsu na tym samym poziomie, takie jak licznik znaków lub przycisk przesyłania, również muszą odczytywać tekst lub go zmieniać?
W takim przypadku obiekt TextFieldState należy przenieść do wspólnej funkcji typu „composable”, czyli wspólnej funkcji nadrzędnej.
@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)
}
}Ponieważ TextFieldState jest obsługiwany przez system migawek, przekazanie go przez element nadrzędny nie powoduje ponownego tworzenia elementów nadrzędnych.
Zaktualizowane zostaną tylko te miejsca, w których faktycznie odczytujesz tekst stanu.
OK.
W przypadku złożonych interfejsów, w których TextFieldState, przekształcenie danych wejściowych i wyjściowych muszą koordynować stan wewnętrznie, zamiast przenosić je osobno i próbować połączyć w funkcji typu „composable”, użyj obiektu zmiennej stanu.
@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,
)
}Wyobraź sobie, że chcesz obsłużyć hashtag w tekście.
TextFieldState zawiera zwykły tekst i pozycję kursora.
Przekształcanie danych wejściowych odpowiada za wykrywanie hashtaga, a następnie można wykonać niepodzielne usunięcie tagu za pomocą przekształcania danych wejściowych.
Przekształcenie danych wyjściowych doda dodatkowy styl wizualny i może też pomóc w usunięciu hashtaga.
Wszystkie są spakowane w jednym obiekcie zmiennej stanu, który nazywa się 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) { ... }
}Następnie mogą ponownie wykorzystać obliczenia pośrednie, takie jak tagRanges w tym przypadku.
Następnie wystarczy przekazać ten obiekt zmiennej stanu do funkcji kompozycyjnej.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}Innym przypadkiem użycia jest sytuacja, gdy logika biznesowa potrzebuje dostępu do tego tekstu na potrzeby wyszukiwania podczas pisania lub złożonej weryfikacji. Wtedy należy przenieść to pole tekstowe do ViewModel.
Ważne jest, aby nie umieszczać zmiennego obiektu pola tekstowego w niezmiennym stanie interfejsu klasy danych, ponieważ jest on kopiowany za pomocą metody kopiowania, a następnie emitowany do StateFlow przy każdej zmianie.
data class FormUiState(val username: TextFieldState)TextFieldState to długotrwały, zmienny obiekt obsługiwany przez system Snapshot, którego obiekt referencyjny nigdy się nie zmienia.
Dlatego StateFlow nie będzie nawet wiedzieć, że użytkownik pisze, ponieważ odwołanie pozostaje takie samo.
Co gorsza, jeśli utworzysz nową instancję stanu interfejsu i przydzielisz nowy obiekt TextFieldState, pole do wprowadzania danych utraci cały swój stan.
Będzie to na przykład zaznaczenie, pozycja kursora lub historia cofania i ponawiania.
Zamiast tego ustaw TextFieldState jako właściwość bezpośrednio w ViewModel.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}Nie musisz się martwić o regułę braku interfejsu w ViewModel, ponieważ w tym przypadku TextFieldState nie odwołuje się do widoku Androida, obiektu kontekstu ani cyklu życia Compose.
Jest to zwykły obiekt zmiennej stanu oparty na Snapshot.
W takim przypadku nie różni się to koncepcyjnie od StateFlow.
Pamiętaj, że ViewModel nie ma rememberSaveable, więc musisz połączyć TextFieldState.Saver z polem tekstowym, aby przetrwało śmierć procesu i przywróciło swój stan.
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}Co jednak, jeśli Twój zespół ma rygorystyczne reguły dotyczące modułów, zgodnie z którymi modele ViewModels mogą znajdować się tylko w czystych modułach Kotlin, i nie możesz dodać tej zależności compose-foundation?
A może na przykład przenosisz złożony starszy ekran i nie możesz teraz zaktualizować ani zrefaktoryzować stanu interfejsu?
W takim przypadku użyj podejścia zgodnego z zasadami.
Dlatego zachowujesz TextFieldState w funkcji kompozycyjnej jako jedno źródło wiarygodnych danych dla tego pola tekstowego.
Następnie używasz LaunchedEffect z snapshotFlow, aby przesłać te zmiany tekstu z powrotem do istniejącego ViewModel.
@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)
}A Twoja klasa ViewModel pozostaje wolna od Compose. Ponieważ właściwości stanu interfejsu nie są przekazywane z powrotem do pola tekstowego przy każdej zmianie, nadal unikasz błędów związanych z asynchronicznym cyklem w obie strony.
Tak, i uważaj na częstą pułapkę.
@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)
}W większości przypadków należy pominąć pierwsze zdarzenie.
Zapobiegnie to niepotrzebnemu powielaniu zapytań i zastępowaniu wartości ViewModel wartością początkową.
Jeśli więc logika musi przekazać te zmiany do stanu, na przykład gdy użytkownik naciśnie przycisk przesyłania i chcesz wyczyścić tekst, nie możesz po prostu ustawić pustego ciągu znaków w ViewModel na 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)
}Zamiast tego musisz wyemitować zdarzenie czyszczenia tekstu, a następnie funkcja typu „composable”, która zawiera obiekt TextFieldState, wywoła clearText na tym obiekcie.
Aby ułatwić Ci przejście na nową wersję, przygotowaliśmy ten przewodnik po migracji.
Możesz go otworzyć, klikając ten link.
Pracujemy też nad dodatkowym interfejsem API, który ułatwi migrację i obserwowanie ogólnych zmian w TextFieldState. Więcej informacji na ten temat podamy wkrótce.
Wszystko, co do tej pory omówiliśmy, TextFieldState, TextFieldBuffer, przekształcanie danych wejściowych i wyjściowych, nie zostało stworzone tylko do wprowadzania zwykłego tekstu.
Są to podstawy, na których opiera się nasz najnowszy zestaw interfejsów API do edycji sformatowanego tekstu.
Teraz Haoyu opowie o tym więcej.
OK.
Dziękujemy.
Cześć!
Teraz opowiem o nowych interfejsach API, które wprowadziliśmy do edycji sformatowanego tekstu.
Zanim przejdziemy do nowych interfejsów API, cofnijmy się o krok i zobaczmy, jak działa stylizowanie w tekście statycznym.
Większość z Was zna już dobrze 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)To standardowy sposób stylizowania tekstu statycznego.
Używamy buildAnnotatedString i wywołujemy funkcję add-- call addStyle, aby zastosować SpanStyle lub ParagraphStyle w określonych zakresach.
Dobrze sprawdza się w przypadku tekstu statycznego.
Gdy przyjrzeliśmy się edytowaniu sformatowanego tekstu, naszą pierwszą myślą było: czy możemy po prostu sprawić, że AnnotatedString będzie edytowalny?
Szybko jednak zdaliśmy sobie sprawę, że to nie takie proste.
AnnotatedString to niezmienna struktura danych.
Jednak w świecie edycji użytkownik stale coś wpisuje.
Przy każdym naciśnięciu klawisza musimy przydzielić nowy obiekt AnnotatedString.
W każdym obiekcie AnnotatedString jest wiele elementów AnnotatedString.Range.
Dlatego te obiekty również muszą zostać utworzone ponownie.
Nie skaluje się zbyt dobrze i powoduje znaczne obciążenie związane z alokacją.
Spójrz na ten przykład.
Gdy wstawiamy słowo w środku, każdy styl po nim zmienia swój zakres i jest ponownie przydzielany.
To rozwiązanie nie sprawdza się, gdy masz wiele stylów.
Zamiast tego wprowadziliśmy dedykowane interfejsy API do stylizacji w TextFieldBuffer, o których Anastasia wspominała wcześniej.
Są one bardzo podobne do interfejsów API do stylizacji w AnnotatedString.
Przyjrzyjmy się, jak działają.
Podobnie jak AnnotatedString.Builder, TextFieldBuffer ma teraz funkcję addStyle, która umożliwia stosowanie SpanStyle lub ParagraphStyle do zakresu.
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>Możesz też przekazać parametr ExpandPolicy, który określa, jak styl zachowuje się, gdy tekst jest wpisywany bezpośrednio na jego granicach.
Za chwilę się temu przyjrzymy.
Najpierw przyjrzyjmy się, jak działa śledzenie stylu.
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)
}Gdy dodasz styl do TextFieldBuffer, jego zakres będzie się automatycznie aktualizować podczas edytowania tekstu.
W tym przykładzie zaczynamy od pogrubienia tekstu.
Gdy wstawisz tekst przed nim, zakres stylu przesunie się do przodu.
Gdy wstawisz w nim tekst, zakres tekstu się rozszerzy.
Gdy usuniemy tekst z zakresu, zakres stylu odpowiednio się zmniejszy.
Jeśli cały tekst w zakresie stylu zostanie usunięty, styl zostanie zwinięty i automatycznie usunięty z bufora.
Co się stanie, gdy tekst zostanie wstawiony na granicy zakresu stylu?
Czy nowo wstawiony tekst jest objęty stylem?
Aby umożliwić Ci kontrolę nad tym, wprowadziliśmy interfejsy API ExpandPolicy.
Przyjrzyjmy się im po kolei.
Pierwszy to 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")W tym trybie wstawianie tekstu na granicy nie spowoduje rozszerzenia zakresów stylów.
Najpierw stosujemy zakres pogrubienia.
Jeśli wstawimy tekst na początku zakresu pogrubienia, zakres stylu przesunie się, ale nie rozszerzy.
Podobnie, jeśli wstawimy tekst na końcu zakresu pogrubienia, zakres stylu również się nie rozszerzy.
Oto wersja demonstracyjna pokazująca, jak to działa w edytorze.
Następny krok to ExpandPolicy.AtStart.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")Jak sama nazwa wskazuje, wstawienie tekstu na początku zakresu rozszerza zakres stylu.
Gdy wstawiamy tekst bezpośrednio przed, zakres stylu rozszerza się, aby objąć nowo wstawiony tekst.
Oto kolejny przykład pokazujący, jak to działa.
Trzeci to ExpandPolicy.AtEnd.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")Wstawiamy więc tekst na końcu zakresu, aby go rozszerzyć.
Jest to też najczęstsze ustawienie dla edytujących.
Oto wersja demonstracyjna, która pokazuje, jak to działa.
Ostatnia wartość to ExpandPolicy.AtBoth, która rozszerza styl, gdy tekst jest wstawiany na początku i na końcu zakresu.
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")
}Oto kolejna wersja demonstracyjna.
Omówiliśmy już wszystkie interfejsy API do stosowania stylów.
Oprócz tego wprowadziliśmy nowe interfejsy API zapytań, takie jak getSpanStyles i getParagraphStyles, które umożliwiają pobieranie stylów obecnie stosowanych w danym zakresie.
// 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>>Oto 2 wersje interfejsów API zapytań.
W TextFieldState interfejsy API zapytań o style są dostępne tylko do odczytu.
Zwracają listę niezmiennych obiektów AnnotatedString.Range.
W TextFieldBuffer getSpanStyles zwraca listę nowo wprowadzonych obiektów TrackedRange, które omówię później.
Dlatego wszystkie zapytania o styl uwzględniają kolejność.
Zwracają zakresy stylów dokładnie w kolejności, w jakiej styl jest stosowany.
Dzięki temu możesz, podobnie jak w przypadku AnnotatedString, zastosować inny styl na istniejącym stylu, aby zastąpić wcześniejsze.
Następny jest interfejs API TrackedRange.
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<*>): BooleanObiekty TrackedRange są zwracane przez interfejsy API addStyle lub getSpanStyles w TextFieldBuffer.
Można go traktować jako aktywny uchwyt zakresu stylów w buforze.
Możesz go użyć, aby zaktualizować zakres tekstu, ExpandPolicy lub sam styl w danym miejscu.
Ma też właściwość isValid, która pozwala sprawdzić, czy zakres stylu nadal znajduje się w TextFieldBuffer.
Dzieje się tak, ponieważ styl może zostać usunięty, jeśli zostanie zwinięty podczas edycji lub zostanie celowo usunięty przez removeStyle.
Zobaczmy teraz, jak te interfejsy API współpracują ze sobą w rzeczywistych przypadkach użycia.
Pierwszy przypadek użycia to utworzenie edytora WYSIWYG (What You See Is What You Get).
W tym przykładzie zaimplementowaliśmy funkcję przycisku pogrubienia.
Kliknięcie przycisku pogrubienia przełącza styl pogrubienia zaznaczonego tekstu.
Teraz przyjrzyjmy się kodowi.
Gdy użytkownik kliknie przycisk pogrubienia, najpierw wywołujemy funkcję getSpanStyles na zaznaczeniu, aby znaleźć wszystkie nakładające się style.
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())
}
}Następnie sprawdzamy, czy całe zaznaczenie jest już pogrubione.
Tutaj wybrane słowo nie jest pogrubione.
Ponieważ nie jest pogrubiony, addStyle do wybranego zakresu.
Przekazujemy ExpandPolicy.AtEnd, aby użytkownik mógł kontynuować pisanie pogrubioną czcionką zaraz po tym słowie.
Zobaczmy teraz, co się stanie, jeśli zaznaczony zakres jest już pogrubiony.
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)
}
}W takim przypadku musimy usunąć pogrubienie z wybranego zakresu.
W tym przypadku słowo wybrane przez użytkownika znajduje się wewnątrz dłuższego wyrażenia zapisanego pogrubioną czcionką.
Ponieważ styl pogrubiony rozciąga się przed zaznaczeniem, musimy zaktualizować zakres stylu, pozostawiając zaznaczone słowo bez pogrubienia.
W tym celu możemy użyć interfejsu API TrackedRange.
W tym konkretnym przypadku zamiast usuwać ten styl i tworzyć go na nowo, możemy po prostu dostosować zakres pogrubienia, aby kończył się przed zaznaczeniem o indeksie 10.
Oczywiście jest to bardzo uproszczona implementacja.
Pełną wersję demonstracyjną znajdziesz w naszym repozytorium przykładów Compose.
Teraz przyjrzyjmy się stylowi opartemu na wzorcu.
W tej wersji demonstracyjnej, gdy użytkownik wpisuje gwiazdki Markdown wokół słowa, edytor automatycznie konwertuje je na pogrubiony tekst podczas wpisywania.
Zobaczmy teraz, jak to zaimplementować za pomocą 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
)
}
}
}W naszym filtrze przekształcającym dane wejściowe znajdujemy wszystkie pasujące wzorce nad przyciskiem.
Dla każdego dopasowania wyodrębniamy treść między podwójnymi gwiazdkami.
W tym przykładzie jest to słowo „fox”.
Następnie usuwamy gwiazdki i zastępujemy pasujący wzorzec jego zawartością.
Na koniec wywołujemy addStyle na obiekcie bold, aby zastosować styl pogrubiony do tekstu treści.
Pamiętaj, że InputTransformation prześle ostateczny stan pola tekstowego.
Oznacza to, że pogrubienie jest stosowane do stanu na stałe.
Na koniec przyjrzyjmy się przykładowi wyróżniania wyszukiwania.
W tym przykładzie, gdy użytkownik wpisuje zapytanie, pasujące słowa w polach tekstowych są wyróżniane bez zmiany stanu pola tekstowego.
Zobaczmy teraz, jak to działa w OutputTransformation.
Podczas wyróżniania wyszukiwanego słowa nigdy nie należy zmieniać stanu tekstu.
OutputTransformation to idealne miejsce na wdrożenie tego przypadku użycia.
Sprawdźmy więc rzeczywistą implementację.
Najpierw wyszukujemy słowa do wyróżnienia i znajdujemy wszystkie dopasowania.
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)
}
}
}Pamiętaj, że zapytanie jest stanem zmiennym.
Dzieje się tak, ponieważ OutputTransformation uwzględnia migawki.
Gdy te zmienne stany zostaną zaktualizowane, OutputTransformation zostanie automatycznie zastosowany ponownie.
Następnie przechodzimy przez wszystkie dopasowania i wywołujemy funkcję addStyle, aby narysować żółte podświetlenia nad zakresem dopasowania.
Przypominamy, że OutputTransformation zmienia tylko tekst wizualny, a stany oryginalnego tekstu pozostają niezmienione.
To wszystkie przypadki użycia, które mamy.
Przypominamy, że wszystkie pokazane wcześniej interfejsy API są dostępne od wersji Compose 1.12.
Na koniec chcę pokazać Wam nowy projekt, nad którym obecnie pracujemy.
Jest to biblioteka tekstu znaczników dla Jetpack Compose, która obsługuje formaty tekstu sformatowanego, takie jak Markdown.
Jedną z najbardziej ekscytujących funkcji, które obsługujemy, jest przyrostowe analizowanie i renderowanie.
Dlatego bardzo dobrze nadaje się do edycji na żywo lub strumieniowego przesyłania danych w formacie Markdown.
Oto nagranie naszego działającego prototypu. Możesz zobaczyć, że te zanikające obszary to aktualizowane części interfejsu.
Jeśli jest to coś, co Cię interesuje, lub masz jakiś przypadek użycia w swojej aplikacji, skontaktuj się z nami.
Dziękuję wszystkim.
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)
}
}
}