Menata gaya input rich text

Input rich text memungkinkan pengguna memformat apa yang mereka ketik, termasuk menebalkan kata, menambahkan judul, meratakan paragraf, atau menyisipkan @mention yang ditandai. Di Compose, Anda membangunnya di atas kolom teks berbasis status (BasicTextField dan TextField). Gaya berada di TextFieldState bersama teks, sehingga tetap ada setelah pengeditan, perubahan pilihan, dan rekomposisi, serta tetap sinkron saat pengguna mengetik, menghapus, dan menempelkan.

Halaman ini menunjukkan cara menerapkan, memperbarui, membuat kueri, mengekstrak, dan menghapus gaya di TextFieldState menggunakan TextFieldBuffer, dan cara mengarahkan toolbar pemformatan dari TextFieldState.textStyles.

Menerapkan gaya inline dan paragraf

Anda dapat menerapkan pemformatan tingkat karakter dan tingkat paragraf di dalam cakupan penerima TextFieldBuffer, termasuk TextFieldState.edit, InputTransformation.transformInput, dan OutputTransformation.transformOutput.

Mengontrol perluasan batas dengan ExpandPolicy

Saat memanggil addStyle, Anda dapat meneruskan ExpandPolicy untuk menentukan perilaku rentang bergaya saat pengguna menyisipkan karakter baru langsung di batas start atau end:

  • Penyisipan di dalam (start, end): Selalu memperluas rentang bergaya.
  • Penyisipan sebelum start: Selalu menggeser offset start dan end ke kanan.
  • Penyisipan langsung di start atau end: Dikontrol oleh ExpandPolicy yang Anda tentukan.

Tabel berikut menjelaskan opsi ExpandPolicy yang tersedia dan kasus penggunaan umumnya:

Kebijakan Perilaku batas Kasus penggunaan yang direkomendasikan Contoh
ExpandPolicy.InsideOnly Teks yang diketik di start atau end tetap berada di luar rentang yang diberi gaya. Token diskret seperti @mentions, #hashtags, atau pil latar belakang yang pengetikan di samping tag-nya tidak boleh mewarisi warna latar belakang tag. Animasi yang menampilkan teks yang diketik di awal atau akhir rentang ExpandPolicy.InsideOnly yang tetap tidak diberi gaya.
ExpandPolicy.AtStart Teks yang diketik di start akan mewarisi gaya, dan teks yang diketik di end akan tetap tanpa gaya. Teks yang diketik langsung di depan teks bergaya juga harus menggunakan format yang sama. Animasi yang menampilkan teks yang diketik di awal rentang ExpandPolicy.AtStart mewarisi gaya, sedangkan teks yang diketik di akhir tetap tidak bergaya.
ExpandPolicy.AtEnd Teks yang diketik di end akan mewarisi gaya, dan teks yang diketik di start akan tetap tanpa gaya. Pengetikan editor rich text standar, yang melanjutkan pemformatan tersebut jika pengetikan dilanjutkan setelah kata yang dicetak tebal atau miring. Animasi yang menampilkan teks yang diketik di akhir rentang ExpandPolicy.AtEnd yang mewarisi gaya, sedangkan teks yang diketik di awal tetap tidak bergaya.
ExpandPolicy.AtBoth Teks yang diketik di start atau end akan memperluas rentang dan mewarisi gaya. Sorotan aktif atau blok berformat berkelanjutan yang harus bertambah dari salah satu tepi. Animasi yang menampilkan teks yang diketik di awal atau akhir rentang ExpandPolicy.AtBoth yang mewarisi gaya.

Menerapkan pemformatan karakter dengan SpanStyle

Gunakan addStyle dengan SpanStyle untuk menerapkan pemformatan tingkat karakter, seperti warna teks, sorotan latar belakang, FontWeight, FontStyle, atau garis bawah ke TextRange tertentu dalam buffer.

Perilaku utama untuk rentang SpanStyle

  • Urutan lapisan: Gaya diterapkan sesuai urutan penambahannya ke buffer. Jika beberapa rentang SpanStyle yang tumpang-tindih menetapkan properti yang sama dengan nilai yang berbeda, gaya yang ditambahkan kemudian akan dirender di atas.
  • Persyaratan rentang tidak kosong: Anda harus meneruskan rentang yang tidak diciutkan dengan start < end. Memanggil addStyle pada pilihan yang diciutkan dengan panjang 0 tidak akan berpengaruh, dan meneruskan indeks terbalik atau di luar batas akan menampilkan 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
                )
            }
        }
    }
)

TextField yang secara dinamis menerapkan pemformatan tebal dan miring berwarna merah saat pengguna mengetik.
Gambar 1. Menerapkan format merah tebal dan miring ke input pengguna secara dinamis.

Menerapkan pemformatan blok dengan ParagraphStyle

Gunakan addStyle dengan ParagraphStyle untuk menerapkan pemformatan struktural tingkat blok seperti textAlign, lineHeight, textDirection, atau textIndent ke rentang paragraf.

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

TextField yang mengalihkan perataan paragraf antara awal, tengah, dan akhir menggunakan ParagraphStyle.
Gambar 2. Mengalihkan perataan paragraf antara awal, tengah, dan akhir menggunakan ParagraphStyle.

Kesalahan yang harus dihindari dengan ParagraphStyle

Karena ParagraphStyle berlaku untuk seluruh blok teks, hindari penggunaannya dalam skenario berikut:

  • Memperluas ke paragraf yang berdekatan: Tetapkan ExpandPolicy dengan hati-hati agar rentang ParagraphStyle yang berdekatan tidak tumpang-tindih setelah pengeditan pengguna. Misalnya, jika [0, 5) menggunakan ExpandPolicy.AtEnd dan [5, 10) menggunakan ExpandPolicy.AtStart, menyisipkan teks pada indeks 5 akan memperluas kedua gaya menjadi tumpang-tindih dan menampilkan IllegalArgumentException.
  • Rentang ParagraphStyle bertingkat: Meskipun rentang bertingkat sepenuhnya (seperti [0, 10) dan [2, 8)) valid di AnnotatedString, hindari rentang ParagraphStyle bertingkat di kolom teks. Tata letak membagi rentang bertingkat menjadi paragraf terpisah dan pengeditan berikutnya dapat secara tidak sengaja mengubah rentang bertingkat menjadi tumpang-tindih sebagian.

Memperbarui gaya di tempat dengan TrackedRange

Memanggil addStyle dengan TextRange dan ExpandPolicy akan menampilkan handle TrackedRange, yang dapat berupa TrackedRange<SpanStyle> atau TrackedRange<ParagraphStyle>. TrackedRange adalah referensi dinamis ke interval gaya di TextFieldBuffer yang otomatis memperbarui offset karakter start dan end saat teks disisipkan, dihapus, atau diganti di sekitarnya.

Mutabilitas langsung

Anda tidak perlu memanggil removeStyle dan addStyle untuk memperbarui gaya yang ada atau menyesuaikan batasnya. Di dalam cakupan TextFieldBuffer, TrackedRange menyediakan properti ekstensi yang memperbarui interval di tempatnya sekaligus mempertahankan urutan lapisan aslinya relatif terhadap gaya lain:

  • TrackedRange<SpanStyle>.spanStyle: Membaca atau mengganti payload SpanStyle di tempat pada TrackedRange<SpanStyle>.
  • TrackedRange<ParagraphStyle>.paragraphStyle: Membaca atau mengganti payload ParagraphStyle di tempat pada TrackedRange<ParagraphStyle>.
  • TrackedRange<*>.textRange: Membaca atau memperbarui batas TextRange saat ini di tempat. Menetapkan rentang yang diciutkan, dibalik, atau di luar batas akan menampilkan IllegalArgumentException.
  • TrackedRange<*>.expandPolicy: Membaca atau memperbarui ExpandPolicy di tempat.
  • TrackedRange<*>.isValid: Menampilkan true jika interval gaya masih aktif di buffer, atau false jika pengeditan menyebabkan panjangnya diciutkan menjadi 0 atau jika dihapus.

// 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)
}

Pembatasan cakupan dan aturan siklus proses

Perhatikan aturan cakupan dan siklus proses berikut saat menggunakan instance TrackedRange:

  • Terbatas pada TextFieldBuffer: Properti ekstensi TrackedRange hanya ada dalam cakupan TextFieldBuffer tempat handle dibuat atau dikueri.
  • Pemangkasan otomatis saat diciutkan: Jika hasil edit pengguna menghapus semua karakter yang dicakup oleh TrackedRange, panjangnya akan menyusut menjadi 0, isValid akan menampilkan false, dan gaya akan dihapus dari buffer.

Membuat kueri dan menghapus gaya

Compose menyediakan dua cara untuk memeriksa gaya aktif, bergantung pada apakah Anda mengubah buffer atau membaca status untuk UI:

  • Di dalam blok TextFieldBuffer: Panggil getSpanStyles dan getParagraphStyles langsung pada penerima TextFieldBuffer untuk mendapatkan handle TrackedRange yang dapat diubah.
  • Di luar blok edit: Baca TextFieldState.textStyles, yang menampilkan daftar AnnotatedString.Range yang tidak dapat diubah yang mencerminkan status yang di-commit.

Membuat kueri gaya yang berpotongan di TextFieldBuffer

TextFieldBuffer.getSpanStyles dan TextFieldBuffer.getParagraphStyles menampilkan semua instance TrackedRange yang berpotongan dengan TextRange yang diberikan, diurutkan berdasarkan waktu penambahannya ke buffer.

Misalnya, Anda dapat membuat kueri getSpanStyles(selection) di dalam state.edit untuk memeriksa atau memperbarui gaya yang mencakup pilihan pengguna saat ini:

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
    }
    // ...
}

Interval gaya menggunakan rentang matematika semi-terbuka [start, end). Apakah gaya sentuh ditampilkan atau tidak, bergantung pada apakah Anda mengkueri pilihan atau satu titik kursor:

  • Untuk pilihan yang tidak kosong [start, end):
    • Ditampilkan: Gaya apa pun yang tumpang-tindih, berisi, atau berada di dalam rentang pilihan. Misalnya, kueri [5, 15) cocok dengan [0, 20), [8, 12), atau [0, 10).
    • Tidak ditampilkan: Gaya yang hanya menyentuh tepi batas luar tanpa karakter yang tumpang-tindih. Misalnya, kueri [5, 15) mengecualikan [0, 5) dan [15, 25).
  • Untuk kursor yang diciutkan [index, index]:
    • Tampilan: Gaya apa pun yang berisi kursor atau dimulai pada posisi kursor. Misalnya, kursor di 10 cocok dengan [0, 20) dan [10, 20).
    • Tidak ditampilkan (aturan akhir sentuhan): Gaya apa pun yang berakhir di posisi kursor. Misalnya, kursor di 10 mengecualikan [0, 10).

Menghapus atau mengalihkan gaya dengan removeStyle

Teruskan pengendali TrackedRange ke removeStyle untuk menghapus interval gaya tertentu tersebut dari TextFieldBuffer.

removeStyle menampilkan true jika TrackedRange ditemukan dan dihapus, atau false jika sudah dihapus atau termasuk dalam buffer yang berbeda. Tindakan ini hanya menghapus handle TrackedRange yang ditargetkan, sehingga gaya tumpang-tindih lainnya tetap utuh.

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

TextField yang mengaktifkan dan menonaktifkan format tebal untuk kata yang dipilih.
Gambar 3. Beralih antara menebalkan dan tidak menebalkan teks.

Mengekstrak atau melakukan serialisasi rich text

Untuk menampilkan input yang diformat dalam composable Text hanya baca, atau menyerialisasikannya ke layanan backend, baca TextFieldState.textStyles di luar blok edit.

state.textStyles.getSpanStyles dan state.textStyles.getParagraphStyles menampilkan List<AnnotatedString.Range<SpanStyle>> dan List<AnnotatedString.Range<ParagraphStyle>>, yang dapat Anda teruskan langsung ke konstruktor AnnotatedString:

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

Contoh lengkap di Jetchat

Untuk melihat implementasi lengkap yang menggabungkan gaya karakter inline (tebal, miring, garis bawah, coret, dan warna), lihat contoh Jetchat di GitHub:

  • RichTextState.kt: Mengenkapsulasi kueri gaya TextFieldState, pengalihan span, pemangkasan atau pemisahan di seluruh batas pilihan, dan lainnya.
  • UserInput.kt: Menghubungkan RichTextState ke toolbar pemformatan teks kaya dan kolom input pesan.
Aplikasi contoh Jetchat yang menunjukkan pemformatan rich text di panel input pesan.
Gambar 4. Pengeditan rich text di Jetchat.