Podstawy aplikacji

Tworzenie niezawodnych funkcji edycji tekstu w Compose

Głośniki

  • Anastasia Soboleva

    Inżynier oprogramowania

  • Haoyu Zhang

    Starszy inżynier oprogramowania

Informacje

Informacje o wystąpieniu

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.

Podsumowanie

  • Przeciążenie interfejsu API 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.
  • Przekształcenia danych wejściowych i wyjściowych umożliwiają manipulowanie tekstem za pomocą TextFieldBuffer. Możesz przechwytywać zmiany przed ich zatwierdzeniem lub formatować wyświetlany tekst bez ręcznego obliczania mapowania przesunięcia.
  • Przenoszenie 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.
  • Compose 1.12 dodaje do 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.
  • Obecnie pracujemy nad nową biblioteką tekstu znaczników dla Jetpack Compose. Obsługuje formaty takie jak Markdown z przyrostowym analizowaniem i renderowaniem na potrzeby edytowania i transmitowania na żywo.

Zasoby

Gemini Notebook

Tematy

  • jetpack-compose
  • textfieldstate
  • edytowanie tekstu
  • basictextfield
  • formatted-text
  • input-transformation

Transkrypcja

Pobierz transkrypcję

Wprowadzenie do edycji tekstu w Compose

  • 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) -> Unit
  • Oznacza 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.

Wprowadzenie do TextFieldState

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

Filtrowanie i formatowanie danych wejściowych

  • 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.

Przenoszenie i migracja TextFieldState

  • 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.

Edytowanie tekstu sformatowanego i tekstu sformatowanego

  • 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<*>): Boolean
  • Obiekty 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.

Tworzenie edytora WYSIWYG

  • 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.

Nadchodząca biblioteka tekstu znaczników

  • 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.

Kod

Fragmenty kodu

Fragment kodu w języku Kotlin w 00:29 w filmie „Wprowadzenie do edycji tekstu w Compose”
TextField(state = rememberTextFieldState())
Fragment kodu w języku Kotlin w 00:29 w filmie „Wprowadzenie do edycji tekstu w Compose”
OutlinedTextField(state = rememberTextFieldState())
Fragment kodu w języku Kotlin w 00:34 w filmie „Wprowadzenie do edycji tekstu w Compose”
BasicTextField(state = rememberTextFieldState())
Fragment kodu w języku Kotlin w 0:44 w filmie „Wprowadzenie do edycji tekstu w Compose”
SecureTextField(state = rememberTextFieldState())
Fragment kodu w Kotlinie w 00:58 w filmie „Wprowadzenie do edycji tekstu w Compose”
@Composable
public fun BasicTextField(
    state: TextFieldState,
)
Fragment kodu w języku Kotlin w 01:28 w filmie „Wprowadzenie do edycji tekstu w Compose”
@Composable
public fun BasicTextField(
    value: String,
    onValueChange: (String) -> Unit
)

@Composable
public fun BasicTextField(
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit
Fragment kodu w języku Kotlin w 04:01 w filmie „Introducing TextFieldState”
// 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) }
Fragment kodu w języku Kotlin w 04:09 w filmie „Introducing TextFieldState”
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}
Fragment kodu w języku Kotlin w 04:25 w filmie „Introducing TextFieldState”
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}
Fragment kodu w Kotlinie w 04:38 w filmie „Introducing TextFieldState”
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}
Fragment kodu w Kotlinie w 04:55 w filmie „Filtrowanie i formatowanie danych wejściowych”
onValueChange { newString ->
  // full diffing new String vs old String
}
Fragment kodu w języku Kotlin w 05:17 w filmie „Filtrowanie i formatowanie danych wejściowych”
@Stable
public fun interface InputTransformation {
    public fun TextFieldBuffer.transformInput()
}
Fragment kodu w języku Kotlin w 05:33 w filmie „Filtrowanie i formatowanie danych wejściowych”
// Intercept and filter input during typing
inputTransformation = InputTransformation {
    val text = asCharSequence()
    if (!text.all { it.isDigit() }) {
        // Reject non-digit characters
        revertAllChanges()
    }
}
Fragment kodu w Kotlinie w 06:48 w filmie „Filtrowanie i formatowanie danych wejściowych”
@Stable
public fun interface OutputTransformation {
    public fun TextFieldBuffer.transformOutput()
}
Fragment kodu w języku Kotlin w 06:58 w filmie „Filtrowanie i formatowanie danych wejściowych”
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
    if (length > 6) {
        insert(6, "-")
    }
    if (length > 3) {
        insert(3, "-")
    }
}
Fragment kodu w języku Kotlin w 07:51 w filmie „Hoisting and migrating 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()) }) { }
    )
}
Fragment kodu w języku Kotlin w 08:58 w filmie „Hoisting and migrating 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)
        }
    }
Fragment kodu w Kotlinie w 09:27 w filmie „Hoisting and migrating TextFieldState”
@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,
    )
}
Fragment kodu w Kotlinie w 10:12 w filmie „Hoisting and migrating TextFieldState”
@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) { ... }

}
Fragment kodu w Kotlinie w 10:30 w filmie „Hoisting and migrating TextFieldState”
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}

@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
    BasicTextField(
        state = state.textFieldState,
        inputTransformation = state.inputTransformation,
        outputTransformation = state.outputTransformation,
    )
}
Fragment kodu w języku Kotlin w 10:51 w filmie „Hoisting and migrating TextFieldState”
data class FormUiState(val username: TextFieldState)
Fragment kodu w Kotlinie w 11:38 w filmie „Hoisting and migrating TextFieldState”
class FormViewModel : ViewModel() {
   val username = TextFieldState()
   val uiState: StateFlow<FormUiState> = ...
}
Fragment kodu w Kotlinie w 12:09 w filmie „Hoisting and migrating TextFieldState”
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
    val username: TextFieldState = savedStateHandle.saveable(
        key = "username",
        saver = TextFieldState.Saver,
        init = { TextFieldState() }
    )
   val uiState: StateFlow<FormUiState> = ...
}
Fragment kodu w języku Kotlin w 12:55 w filmie „Hoisting and migrating TextFieldState”
@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)
}
Fragment kodu w języku Kotlin w filmie „Hoisting and migrating TextFieldState”
@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)
}
Fragment kodu w języku Kotlin w 13:39 w filmie „Hoisting and migrating TextFieldState”
@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)
}
Fragment kodu w języku Kotlin w 15:09 w filmie „Formatted and rich text editing”
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)
Fragment kodu w języku Kotlin w 16:36 w filmie „Formatted and rich text editing”
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>
Fragment kodu w Kotlinie w 16:57 w filmie „Formatted and rich text editing”
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)
}
Fragment kodu w języku Kotlin w 17:52 w filmie „Edytowanie sformatowanego tekstu”
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")
Fragment kodu w języku Kotlin w 18:22 w filmie „Formatted and rich text editing”
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtStart
    )
    insert(4, "quick ")
Fragment kodu w języku Kotlin w 18:40 w filmie „Formatted and rich text editing”
val state = TextFieldState("The fox")

state.edit {
    addStyle(
        SpanStyle(fontWeight = FontWeight.Bold),
        start = 4,
        end = 7,
        policy = ExpandPolicy.AtEnd
    )
    insert(length, " jumps")
Fragment kodu w języku Kotlin w 18:56 w filmie „Formatted and rich text editing”
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")
}
Fragment kodu w języku Kotlin w 19:14 w filmie „Formatted and rich text editing”
// 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>>
Fragment kodu w języku Kotlin w 20:03 w filmie „Formatted and rich text editing”
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<*>): Boolean
Fragment kodu w języku Kotlin w 21:01 w filmie „Tworzenie edytora WYSIWYG”
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())
    }
}
Fragment kodu w Kotlinie w 21:26 w filmie „Tworzenie edytora WYSIWYG”
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)
    }
}
Fragment kodu w języku Kotlin w 22:27 w filmie „Tworzenie edytora WYSIWYG”
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
            )
        }
    }
}
Fragment kodu w Kotlinie w 23:42 w filmie „Tworzenie edytora WYSIWYG”
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)
        }
    }
}