Styl tekstu sformatowanego

Tekst sformatowany umożliwia użytkownikom formatowanie wpisywanego tekstu, w tym pogrubianie słów, dodawanie nagłówków, wyrównywanie akapitów czy wstawianie wyróżnionego elementu @mention. W Compose tworzysz to na podstawie pól tekstowych opartych na stanie (BasicTextField i TextField). Style są przechowywane w TextFieldState razem z tekstem, więc nie są tracone podczas edycji, zmian zaznaczenia i rekompozycji. Są też synchronizowane, gdy użytkownik wpisuje, usuwa i wkleja tekst.

Na tej stronie dowiesz się, jak stosować, aktualizować, wyodrębniać i usuwać style w TextFieldState za pomocą TextFieldBuffer oraz jak sterować paskiem narzędzi formatowania z TextFieldState.textStyles.

Stosowanie stylów wbudowanych i stylów akapitów

Formatowanie na poziomie znaków i akapitów możesz stosować w dowolnym zakresie odbiornika TextFieldBuffer, w tym TextFieldState.edit, InputTransformation.transformInput i OutputTransformation.transformOutput.

Kontrolowanie rozszerzania granic za pomocą ExpandPolicy

Gdy wywołujesz addStyle, możesz przekazać ExpandPolicy, aby określić, jak zachowuje się zakres stylizowany, gdy użytkownik wstawia nowe znaki bezpośrednio na granicy start lub end:

  • Wstawianie wewnątrz (start, end): zawsze rozszerza zakres stylizowany.
  • Wstawienie przed start: zawsze przesuwa przesunięcia start i end w prawo.
  • Wstawianie bezpośrednio w start lub end: kontrolowane przez określony przez Ciebie parametr ExpandPolicy.

W tabeli poniżej opisujemy dostępne opcje ExpandPolicy i ich typowe przypadki użycia:

Zasady Działanie na granicy Zalecane przypadki użycia Przykład
ExpandPolicy.InsideOnly Tekst wpisany w miejscu start lub end pozostaje poza zakresem stylizowanym. Odrębne tokeny, takie jak @mentions, #hashtags lub pigułki tła, w przypadku których wpisywanie obok tagu nie powinno powodować dziedziczenia koloru tła tagu. Animacja pokazująca, że tekst wpisany na początku lub na końcu zakresu ExpandPolicy.InsideOnly pozostaje bez stylu.
ExpandPolicy.AtStart Tekst wpisany w miejscu start dziedziczy styl, a tekst wpisany w miejscu end pozostaje bez stylu. Tekst wpisany bezpośrednio przed tekstem ze stylem powinien również przyjąć to samo formatowanie. Animacja pokazująca, że tekst wpisany na początku zakresu ExpandPolicy.AtStart dziedziczy styl, a tekst wpisany na końcu pozostaje bez stylu.
ExpandPolicy.AtEnd Tekst wpisany w miejscu end dziedziczy styl, a tekst wpisany w miejscu start pozostaje bez stylu. Standardowe pisanie w edytorze tekstu sformatowanego, gdzie kontynuowanie pisania po słowie pogrubionym lub zapisanym kursywą powoduje kontynuowanie tego formatowania. Animacja pokazująca, że tekst wpisany na końcu zakresu ExpandPolicy.AtEnd dziedziczy styl, a tekst wpisany na początku pozostaje bez stylu.
ExpandPolicy.AtBoth Tekst wpisany w miejscu start lub end rozszerza zakres i dziedziczy styl. Aktywne wyróżnienia lub ciągłe sformatowane bloki, które powinny rozszerzać się od dowolnej krawędzi. Animacja przedstawiająca tekst wpisany na początku lub na końcu zakresu ExpandPolicy.AtBoth, który dziedziczy styl.

Stosowanie formatowania znaków za pomocą SpanStyle

Użyj addStyle z SpanStyle, aby zastosować formatowanie na poziomie znaków, takie jak kolor tekstu, wyróżnienie tła, FontWeight, FontStyle lub podkreślenie, do określonego TextRange w buforze.

Kluczowe zachowania w zakresach SpanStyle

  • Kolejność warstw: style są stosowane w kolejności, w jakiej zostały dodane do bufora. Jeśli kilka nakładających się zakresów SpanStyle ustawia tę samą właściwość z różnymi wartościami, styl dodany później jest renderowany na górze.
  • Wymaganie dotyczące niepustego zakresu: musisz przekazać zakres, który nie jest zwinięty, gdzie start < end. Wywołanie metody addStyle na zwiniętym zaznaczeniu o długości 0 nie ma żadnego efektu, a przekazanie odwróconych lub wykraczających poza zakres indeksów powoduje błąd IllegalArgumentException.

TextField(
    state = rememberTextFieldState(),
    inputTransformation = InputTransformation {
        for (i in 0 until changes.changeCount) {
            val range = changes.getRange(i)
            if (!range.collapsed) {
                addStyle(
                    spanStyle = SpanStyle(
                        fontWeight = FontWeight.ExtraBold,
                        fontStyle = FontStyle.Italic,
                        color = Color.Red,
                    ),
                    range = range,
                    expandPolicy = ExpandPolicy.AtEnd
                )
            }
        }
    }
)

Pole tekstowe, które dynamicznie stosuje pogrubienie i kursywę w kolorze czerwonym, gdy użytkownik pisze.
Rysunek 1. Dynamiczne stosowanie pogrubienia i kursywy oraz czerwonego koloru do danych wejściowych użytkownika.

Stosowanie formatowania blokowego za pomocą ParagraphStyle

Użyj addStyle z ParagraphStyle, aby zastosować formatowanie strukturalne na poziomie bloku, takie jak textAlign, lineHeight, textDirection lub textIndent, do zakresu akapitu.

fun setParagraphAlignment(state: TextFieldState, alignment: TextAlign) {
    val targetRange = if (state.selection.collapsed) {
        TextRange(0, state.text.length)
    } else {
        state.selection
    }
    if (targetRange.collapsed) return
    state.edit {
        // 1. Remove existing paragraph styles in this range
        getParagraphStyles(targetRange).forEach { removeStyle(it) }
        // 2. Apply the new paragraph alignment
        addStyle(
            paragraphStyle = ParagraphStyle(textAlign = alignment),
            range = targetRange,
            expandPolicy = ExpandPolicy.InsideOnly
        )
    }
}

Pole tekstowe przełączające wyrównanie akapitu między początkiem, środkiem i końcem za pomocą ParagraphStyle.
Rysunek 2. Przełączanie wyrównania akapitu między początkiem, środkiem i końcem za pomocą klawisza ParagraphStyle.

Czego unikać w przypadku ParagraphStyle

Ponieważ tag ParagraphStyle dotyczy całych bloków tekstu, unikaj używania go w tych sytuacjach:

  • Rozszerzanie na sąsiednie akapity: ustaw ExpandPolicy ostrożnie, aby sąsiednie zakresy ParagraphStyle nie nakładały się na siebie po zmianach wprowadzonych przez użytkownika. Jeśli na przykład [0, 5) używa ExpandPolicy.AtEnd, a [5, 10) używa ExpandPolicy.AtStart, wstawienie tekstu w indeksie 5 powoduje rozszerzenie obu stylów, co prowadzi do ich nakładania się i wywołania błędu IllegalArgumentException.
  • Zagnieżdżanie zakresów ParagraphStyle: chociaż całkowicie zagnieżdżone zakresy (takie jak [0, 10) i [2, 8)) są prawidłowe w AnnotatedString, unikaj zagnieżdżania zakresów ParagraphStyle w polu tekstowym. Układ dzieli zagnieżdżone zakresy na osobne akapity, a kolejne zmiany mogą nieumyślnie przekształcić zagnieżdżone zakresy w częściowe nakładanie się.

Zaktualizuj styl na miejscu za pomocą TrackedRange

Wywołanie addStyle z TextRange i ExpandPolicy zwraca uchwyt TrackedRange, który może być TrackedRange<SpanStyle> lub TrackedRange<ParagraphStyle>. TrackedRange to dynamiczne odwołanie do interwału stylu w TextFieldBuffer, które automatycznie aktualizuje przesunięcia znaków start i end, gdy tekst jest wstawiany, usuwany lub zastępowany wokół niego.

Zmienność na miejscu

Nie musisz wywoływać funkcji removeStyle i addStyle, aby zaktualizować istniejący styl lub dostosować jego granice. W zakresie TextFieldBuffer TrackedRange udostępnia właściwości rozszerzenia, które aktualizują interwał na miejscu, zachowując pierwotną kolejność warstw względem innych stylów:

  • TrackedRange<SpanStyle>.spanStyle: odczytuje lub zastępuje ładunek SpanStyle na miejscu w TrackedRange<SpanStyle>.
  • TrackedRange<ParagraphStyle>.paragraphStyle: odczytuje lub zastępuje ładunek ParagraphStyle na urządzeniu TrackedRange<ParagraphStyle>.
  • TrackedRange<*>.textRange: odczytuje lub aktualizuje bieżące granice TextRange na miejscu. Ustawienie zwiniętego, odwróconego lub wykraczającego poza zakres zakresu powoduje błąd IllegalArgumentException.
  • TrackedRange<*>.expandPolicy: odczytuje lub aktualizuje ExpandPolicy na miejscu.
  • TrackedRange<*>.isValid: zwraca true, jeśli interwał stylu jest nadal aktywny w buforze, lub false, jeśli zmiany spowodowały skrócenie jego długości do 0 lub jeśli został usunięty.

// Trim an existing bold span in place when the selection overlaps one edge
val range = style.textRange
if (range.start < selection.min && range.end <= selection.max) {
    // Selection covers the end of the span - shrink end boundary in place
    style.textRange = TextRange(range.start, selection.min)
} else if (range.start >= selection.min && range.end > selection.max) {
    // Selection covers the start of the span - shrink start boundary in place
    style.textRange = TextRange(selection.max, range.end)
}

Ograniczenie zakresu i reguły cyklu życia

Podczas pracy z instancjami TrackedRange pamiętaj o tych regułach dotyczących zakresu i cyklu życia:

  • Ograniczone do TextFieldBuffer: właściwości rozszerzenia TrackedRange istnieją tylko w zakresie TextFieldBuffer, w którym uchwyt został utworzony lub odpytany.
  • Automatyczne usuwanie po zwinięciu: jeśli zmiany wprowadzone przez użytkownika spowodują usunięcie wszystkich znaków objętych przez TrackedRange, jego długość zmniejszy się do 0, isValid zwróci false, a styl zostanie usunięty z bufora.

Wykonywanie zapytań i usuwanie stylów

Compose udostępnia 2 sposoby sprawdzania aktywnych stylów w zależności od tego, czy mutujesz bufor, czy odczytujesz stan interfejsu:

  • Wewnątrz bloku TextFieldBuffer: wywołaj getSpanStyles i getParagraphStyles bezpośrednio na odbiorniku TextFieldBuffer, aby uzyskać zmienne uchwyty TrackedRange.
  • Poza blokiem edycji: odczytaj TextFieldState.textStyles, który zwraca niezmienne listy AnnotatedString.Range odzwierciedlające zatwierdzony stan.

Style przecinające zapytanie w: TextFieldBuffer

TextFieldBuffer.getSpanStyles i TextFieldBuffer.getParagraphStyles zwracają wszystkie instancje TrackedRange, które przecinają się z danym TextRange, uporządkowane według czasu dodania do bufora.

Możesz na przykład wysłać zapytanie getSpanStyles(selection) wewnątrz state.edit, aby sprawdzić lub zaktualizować style obejmujące bieżące zaznaczenie użytkownika:

state.edit {
    if (selection.collapsed) return@edit
    // Query all bold spans that overlap with the current selection
    val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
        it.spanStyle.fontWeight == FontWeight.Bold
    }
    // ...
}

Przedziały stylów używają półotwartego zakresu matematycznego [start, end). To, czy zwracany jest styl dotykowy, zależy od tego, czy zapytanie dotyczy zaznaczenia czy pojedynczego punktu kursora:

  • W przypadku niepustego zaznaczenia [start, end):
    • Zwracane: dowolny styl, który pokrywa się z zakresem zaznaczenia, zawiera go lub znajduje się w nim. Na przykład zapytanie [5, 15) pasuje do [0, 20), [8, 12) lub [0, 10).
    • Nie zwrócono: style, które tylko dotykają zewnętrznych krawędzi obramowania, ale nie nakładają się na znaki. Na przykład zapytanie [5, 15) wyklucza [0, 5) i [15, 25).
  • W przypadku zwiniętego kursora [index, index]:
    • Zwracane: dowolny styl zawierający kursor lub zaczynający się w pozycji kursora. Na przykład kursor w pozycji 10 pasuje do [0, 20) i [10, 20).
    • Not returned (touching end rule): Any style ending at the cursor position. Na przykład kursor w pozycji 10 wyklucza [0, 10).

Usuwanie i przełączanie stylów za pomocą removeStyle

Przekaż uchwyt TrackedRange do removeStyle, aby usunąć ten konkretny interwał stylu z obiektu TextFieldBuffer.

removeStyle zwraca true, jeśli element TrackedRange został znaleziony i usunięty, lub false, jeśli został już usunięty lub należał do innego bufora. Usuwa tylko docelowy uchwyt TrackedRange, pozostawiając inne nakładające się style bez zmian.

fun toggleBold(state: TextFieldState) {
    state.edit {
        if (selection.collapsed) return@edit
        // Query all bold spans that overlap with the current selection
        val boldStyles: List<TrackedRange<SpanStyle>> = getSpanStyles(selection).filter {
            it.spanStyle.fontWeight == FontWeight.Bold
        }
        if (boldStyles.isNotEmpty()) {
            // Remove or trim existing bold spans covering the selection
            for (style in boldStyles) {
                val range = style.textRange
                if (range.start >= selection.min && range.end <= selection.max) {
                    removeStyle(style)
                } else if (range.start < selection.min && range.end > selection.max) {
                    val oldEnd = range.end
                    style.textRange = TextRange(range.start, selection.min)
                    addStyle(
                        spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                        range = TextRange(selection.max, oldEnd),
                        expandPolicy = ExpandPolicy.AtEnd
                    )
                } else if (range.start < selection.min) {
                    style.textRange = TextRange(range.start, selection.min)
                } else {
                    style.textRange = TextRange(selection.max, range.end)
                }
            }
        } else {
            // No bold spans intersect the selection - apply bold
            addStyle(
                spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
                range = selection,
                expandPolicy = ExpandPolicy.AtEnd
            )
        }
    }
}

Pole tekstowe, w którym można włączać i wyłączać pogrubienie zaznaczonych słów.
Rysunek 3. Przełączanie między pogrubieniem a brakiem pogrubienia tekstu.

Wyodrębnianie i serializacja tekstu sformatowanego

Aby wyświetlić sformatowane dane wejściowe w funkcji kompozycyjnej Text tylko do odczytu lub zserializować je do usługi backendu, odczytaj TextFieldState.textStyles poza blokiem edycji.

state.textStyles.getSpanStyles i state.textStyles.getParagraphStyles zwracają List<AnnotatedString.Range<SpanStyle>> i List<AnnotatedString.Range<ParagraphStyle>>, które możesz przekazać bezpośrednio do konstruktora AnnotatedString:

fun TextFieldState.toAnnotatedString(): AnnotatedString {
    val fullRange = TextRange(0, text.length)
    return AnnotatedString(
        text = text.toString(),
        spanStyles = textStyles.getSpanStyles(fullRange),
        paragraphStyles = textStyles.getParagraphStyles(fullRange)
    )
}

Kompletny przykład w Jetchat

Aby zobaczyć pełną implementację, która łączy style znaków w tekście (pogrubienie, kursywę, podkreślenie, przekreślenie i kolor), zapoznaj się z przykładem Jetchat na GitHubie:

  • RichTextState.kt: obejmuje zapytania o styl TextFieldState, przełączanie zakresu, przycinanie lub dzielenie w granicach zaznaczenia i inne funkcje.
  • UserInput.kt: łączy RichTextState z paskiem narzędzi formatowania tekstu sformatowanego i polem do wprowadzania danych wiadomości.
Przykładowa aplikacja Jetchat pokazująca formatowanie tekstu na pasku wprowadzania wiadomości.
Rysunek 4. Edytowanie tekstu sformatowanego w Jetchat.