Software Engineer Senior
Perjalanan menuju pengeditan teks yang andal di Compose diakhiri dengan penulisan ulang BasicTextField secara menyeluruh. Peralihan ke arsitektur berbasis buffering ini menghilangkan kondisi race IME untuk selamanya. Sesi ini membahas alasan sinkronisasi input lama harus dihentikan dan cara TextFieldState menjaga update keyboard tetap sinkron. Dari sana, lakukan pembahasan mendalam tentang pengeditan berformat untuk mempelajari mekanisme TrackedRange, biaya performa blok pengeditan, dan masalah di dunia nyata di balik batas gaya, sebelum melihat sekilas rendering Markdown.
TextFieldState menggantikan overload berbasis callback di Compose. Fitur ini diupdate secara sinkron dengan pengetikan pengguna, sehingga menghilangkan masalah pengaturan waktu dengan keyboard virtual dan rekomposisi yang tidak perlu.TextFieldBuffer. Anda dapat mengintersepsi pengeditan sebelum menerapkannya atau memformat teks tampilan tanpa menghitung pemetaan offset secara manual.TextFieldState bergantung pada kebutuhan arsitektur Anda. Anda dapat menyimpannya secara lokal dalam composable atau menyimpannya langsung di ViewModel untuk akses logika bisnis.TextFieldBuffer. Anda dapat menerapkan gaya dengan opsi ExpandPolicy yang dapat disesuaikan, dan rentang gaya yang ada akan otomatis diperbarui saat pengguna mengedit.Transkrip
Halo, semuanya!
Terima kasih telah bergabung dalam diskusi kami.
Nama saya Anastasia, dan bersama saya adalah rekan saya, Haoyu.
Hari ini, kami akan menunjukkan cara membuat pengalaman pengeditan teks yang andal.
Baik Anda membuat layar login sederhana, kotak penelusuran saat mengetik, atau aplikasi pesan yang kompleks, kolom teks adalah inti dari cara pengguna berinteraksi dengan aplikasi Android Anda.
Jadi, sebagian besar dari Anda mungkin sudah menggunakan kolom teks material, atau kolom teks dengan garis batas.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())Ada juga BasicTextField jika Anda membuat sistem desain sendiri, atau Anda memerlukan kotak pengeditan sederhana.
BasicTextField(state = rememberTextFieldState())Ada juga SecureTextField yang dirancang khusus untuk memasukkan sandi.
SecureTextField(state = rememberTextFieldState())Dan BasicTextField adalah komponen tanpa gaya dan sebenarnya mendukung semua kolom teks lainnya di Compose.
Jadi, Anda mungkin telah memperhatikan selama beberapa tahun terakhir bahwa kami memiliki dua bentuk API secara paralel, satu mengambil nilai dan callback perubahan nilai, dan yang lainnya mengambil objek TextFieldState.
@Composable
public fun BasicTextField(
state: TextFieldState,
)Jadi, Anda mungkin bertanya-tanya mana yang kami rekomendasikan.
Sebaiknya gunakan overload TextFieldState.
Dalam diskusi ini, kita akan membahas manfaatnya dalam hal API, fitur, dan performa.
API ini telah stabil sejak Compose 1.7.
Sedangkan untuk overload callback, kami menghentikan penggunaannya secara bertahap di Compose 1.13 di seluruh BasicTextField dan wrapper material.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> UnitArtinya, kami tidak akan menambahkan fitur baru atau memperbaiki bug, kecuali jika ada masalah keamanan atau aksesibilitas yang kritis.
Nilai stateless pada overload perubahan nilai sangat berfungsi dengan baik untuk input sinkron.
Namun, begitu Anda menyambungkannya dengan streaming asinkron, misalnya, StateFlow, hal ini akan menimbulkan masalah pengaturan waktu dengan IME atau istilah teknisnya, untuk keyboard virtual.
Jadi, hal itu menyebabkan karakter hilang, karakter duplikat, kursor melompat, seperti yang dapat Anda lihat di rekaman ini.
Untuk memperbaiki masalah ini secara mendasar, kami memperkenalkan TextFieldState.
State ini diupdate secara sinkron saat pengguna mengetik tanpa menunggu perjalanan bolak-balik asinkron tersebut, yang merupakan hal yang dibutuhkan IME.
TextFieldState, selain teks hanya baca, juga memiliki properti lain yang membuat sesi pengeditan aktif.
Yaitu pilihan, status urungkan ulangi, serta gaya span dan span serta gaya paragraf untuk pengeditan teks berformat, selengkapnya nanti dalam pembahasan ini.
Properti internal TextFieldState didukung oleh Sistem Snapshot Compose.
Jadi, artinya saat Anda membaca teks atau pilihan tersebut dari objek TextFieldState di suatu tempat di Compose, cakupan spesifik tempat Anda membacanya akan di-subscribe ke update.
Hal ini memungkinkan BasicTextField hanya membaca status dalam fase tata letak atau gambar, dan bersama dengan beberapa hal lainnya, hal ini menghilangkan rekomposisi yang tidak perlu saat pengguna mengetik.
Dan bersama dengan beberapa perubahan lainnya, hal ini memberikan peningkatan performa yang signifikan untuk overload TextFieldState.
Sekarang, jika teks TextFieldState adalah properti hanya baca, bagaimana cara membuat perubahan terprogram pada kolom input?
Di sinilah TextFieldBuffer berguna.
Ini adalah objek yang dapat diubah yang memberi Anda API untuk melakukan perubahan.
Jadi, saat Anda ingin membuat perubahan terprogram, panggil fungsi edit TextFieldState.
Dan di dalam lambda tersebut, Anda akan mendapatkan TextFieldBuffer.
Dan TextFieldBuffer mengekspos beberapa fungsi yang memungkinkan Anda menyisipkan, mengganti karakter, dan menghapusnya sesuai kebutuhan.
Satu tips singkat terkait pengeditan adalah hindari memanggilnya beberapa kali berturut-turut.
// 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) }Sebagai gantinya, gabungkan semua operasi Anda ke dalam satu panggilan edit.
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}Dengan begitu, Compose hanya akan membuat satu instance TextFieldBuffer, mengubahnya di tempat, lalu menyinkronkannya dengan IME dan Sistem Snapshot sekali.
Tips lainnya adalah untuk sebagian besar operasi batch umum yang Anda perlukan, kami sebenarnya memiliki fungsi ekstensi di TextFieldState.
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}Jadi, Anda tidak perlu memikirkan pengelompokan operasi tersebut.
Anda cukup memanggil salah satunya.
Contohnya adalah clearText dan setTextAndPlaceCursorAtEnd.
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}Sekarang, bagaimana dengan memfilter input pengguna?
Misalnya, menerapkan batas karakter atau hanya ingin menerima digit.
Di API lama, pemfilteran di onValueChange berjalan setelah teks di-commit.
onValueChange { newString ->
// full diffing new String vs old String
}Jadi, menolak atau mengubah input tersebut akan bertentangan dengan keyboard.
Selain itu, callback onValueChange hanya memberi Anda string akhir.
Jadi, untuk memahami apa yang berubah, Anda harus melakukan diffing string lengkap.
Sebagai gantinya, dalam overload baru, kita memiliki transformasi input.
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}Jadi, API ini menangkap input pengguna sebelum input tersebut mencapai status kolom teks, dan memberi Anda API TextFieldBuffer yang sama.
Jadi, inilah cara Anda menerapkan pemfilteran khusus angka dengan transformasi input, dan pada dasarnya Anda hanya mengambil perubahan yang dilakukan.
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}Anda memeriksa apakah ada karakter non-digit, dan jika ada, Anda menolak input dengan memanggil revertAllChanges pada TextFieldBuffer tersebut.
Dan kemampuan utama transformasi input ini adalah ChangeList.
Jadi, alih-alih membandingkan string lengkap, Anda dapat memeriksa rentang pengeditan yang tepat, lalu mengubahnya di tempat atau menolak input sama sekali jika diperlukan.
Compose akan otomatis menyinkronkan keyboard dan kursor untuk Anda.
Terakhir, bagaimana jika Anda ingin melakukan pemformatan visual pada input Anda?
Misalnya, Anda ingin menambahkan tanda kurung atau tanda hubung ke nomor kartu kredit atau nomor telepon.
Serius.
Dan Anda tidak ingin menyimpannya di database, bukan?
Sebelumnya, Anda telah menggunakan API transformasi visual.
Jadi, dalam kasus tersebut, Anda harus menerapkan pemetaan dua arah dari input mentah ke output visual, lalu kembali lagi.
Dan sangat sulit untuk membuatnya benar, yang sering kali menyebabkan pengecualian runtime.
Sekarang kita memiliki transformasi output, dan sekali lagi, di balik layar, transformasi ini didukung oleh TextFieldBuffer yang sama, sehingga Anda mendapatkan API yang hampir sama.
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}Jadi, dalam contoh nomor telepon ini, untuk menyisipkan tanda hubung pada indeks tertentu, Anda cukup memanggil fungsi insert dari TextFieldBuffer, dan sistem akan menghitung pemetaan offset dua arah untuk Anda.
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}Jadi, hal ini menghilangkan penghitungan manual dan mengurangi jumlah error secara signifikan.
Sekarang ke bagian yang paling penting, bagaimana cara Anda bermigrasi, dan di mana Anda menempatkan TextFieldState tersebut?
Dalam praktiknya, hampir setiap kasus penggunaan termasuk dalam salah satu kategori yang akan saya bahas.
Jika tidak, biasanya berarti Anda memiliki beberapa arsitektur yang tidak konvensional, tetapi contoh ini akan tetap memandu Anda melalui migrasi.
Oke, jadi kasus paling sederhana adalah menyimpan TextFieldState tepat di dalam composable tempat kolom teks tersebut berada.
@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()) }) { }
)
}Ini adalah saat kolom teks Anda adalah kolom input mandiri, jadi, misalnya, kolom input kode promo, dan Anda hanya perlu mendapatkan akses ke teks tersebut saat pengguna, misalnya, menekan tombol kirim, bukan?
Jadi, model tampilan Anda tidak perlu menangkap input pengguna pada setiap perubahan.
Jadi, Anda cukup memanggil rememberTextFieldState secara lokal, dan rememberTextFieldState juga membantu Anda memulihkan status setelah penghentian proses.
Kemudian, Anda menggunakan transformasi input untuk pemfilteran, dan Anda mendapatkan teks dari status tersebut, objek TextFieldState, dan saat pengguna mengirimkan, dan Anda meneruskannya melalui lambda.
Oke, bagaimana jika elemen UI yang setara seperti penghitung karakter atau tombol kirim juga perlu membaca teks atau mengubahnya, membuat perubahan padanya?
Hal ini membawa kita ke kasus saat Anda mengangkat objek TextFieldState ke fungsi composable umum, yaitu fungsi composable ancestor umum.
@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)
}
}Dan karena TextFieldState didukung oleh sistem snapshot, meneruskannya melalui ancestor tidak akan memicu rekomposisi induk.
Hanya tempat Anda benar-benar membaca teks status yang akan diperbarui.
Oke.
Jadi, untuk kasus penggunaan UI yang kompleks, saat Anda memerlukan TextFieldState, transformasi input dan output untuk mengoordinasikan status secara internal, gunakan objek holder status, alih-alih mengangkatnya secara terpisah dan mencoba menghubungkannya dalam fungsi composable.
@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,
)
}Jadi, bayangkan Anda ingin menangani hashtag inline.
TextFieldState menyimpan teks mentah dan posisi kursor.
Transformasi input bertanggung jawab untuk mendeteksi hashtag, dan Anda juga dapat melakukan penghapusan tag atomik melalui transformasi input.
Transformasi output akan menambahkan gaya visual tambahan dan juga dapat membantu Anda menghapus hashtag.
Dan semuanya dikemas bersama dalam satu objek holder status, yang saya sebut 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) { ... }
}Kemudian, semuanya dapat menggunakan kembali penghitungan perantara seperti tagRanges dalam kasus ini.
Kemudian, Anda cukup meneruskan objek holder status ini ke composable.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}Oke, kasus penggunaan lainnya adalah saat logika bisnis Anda benar-benar memerlukan akses ke teks tersebut untuk penelusuran saat Anda mengetik, atau beberapa validasi yang kompleks, angkat kolom teks tersebut ke dalam ViewModel Anda.
Penting untuk tidak menempatkan objek kolom teks yang dapat diubah ke dalam status UI class data yang tidak dapat diubah karena objek tersebut akan disalin dengan metode salin, lalu ditampilkan ke StateFlow pada setiap perubahan.
data class FormUiState(val username: TextFieldState)TextFieldState adalah objek yang dapat diubah dan berumur panjang yang didukung oleh Snapshot System, dan objek referensinya tidak pernah berubah.
Jadi, StateFlow Anda bahkan tidak akan tahu bahwa pengguna sedang mengetik saat ini karena referensinya tetap sama.
Dan yang lebih buruk, jika Anda membuat instance baru status UI dan mengalokasikan TextFieldState baru, kolom input Anda akan kehilangan semua statusnya.
Jadi, hal itu akan seperti pilihan, atau posisi kursor, atau histori urungkan/ulangi.
Jadi, jadikan TextFieldState sebagai properti langsung di ViewModel Anda.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}Jangan khawatir tentang aturan tidak ada UI di ViewModel di sini karena, dalam hal ini, TextFieldState tidak mereferensikan objek Context atau View Android atau siklus proses Compose.
Objek ini hanyalah objek holder status murni yang Didukung Snapshot.
Jadi, dalam hal ini, secara konseptual tidak berbeda dengan StateFlow.
Satu hal yang perlu diingat adalah ViewModel tidak memiliki rememberSaveable, jadi Anda perlu menghubungkan TextFieldState.Saver untuk kolom teks agar dapat bertahan dari penghentian proses dan memulihkan statusnya.
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}Sekarang, bagaimana jika tim Anda memiliki aturan modul yang ketat, yaitu ViewModel hanya ada di modul Kotlin murni, dan Anda tidak dapat menggunakan dependensi compose-foundation tersebut?
Atau misalnya, Anda memigrasikan layar lama yang kompleks, dan Anda tidak dapat mengupdate atau melakukan refaktorisasi status UI saat ini?
Dalam hal ini, gunakan pendekatan yang sesuai.
Jadi, Anda menyimpan TextFieldState di composable sebagai satu sumber tepercaya untuk kolom teks tersebut.
Namun, Anda menggunakan LaunchedEffect dengan snapshotFlow untuk mengirim perubahan teks tersebut kembali ke ViewModel yang ada.
@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)
}ViewModel Anda tetap bebas Compose, dan karena properti status UI tidak memberikan masukan ke kolom teks pada setiap perubahan, Anda tetap dapat menghindari bug perjalanan bolak-balik asinkron tersebut.
Ya, dan hati-hati dengan jebakan umum di sini.
@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)
}Biasanya, Anda ingin menghilangkan peristiwa pertama.
Tindakan ini akan mencegah kueri penelusuran yang diduplikasi secara tidak perlu, atau juga akan mencegah penimpaan ViewModel Anda dengan nilai awal.
Oke, jadi jika logika Anda kemudian perlu memberikan masukan untuk memberikan masukan perubahan tersebut ke status, misalnya, saat pengguna menekan tombol kirim dan Anda ingin menghapus teks, Anda tidak dapat hanya menetapkan string kosong ke ViewModel ke 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)
}Sebagai gantinya, Anda perlu memunculkan peristiwa hapus teks, lalu fungsi composable yang menyimpan objek TextFieldState tersebut akan memanggil clearText pada objek tersebut.
Untuk membantu Anda lebih lanjut dalam transisi, kami menulis panduan migrasi ini.
Anda dapat mengaksesnya melalui link ini.
Kami juga sedang mengerjakan beberapa API tambahan untuk mempermudah migrasi, serta pengamatan perubahan TextFieldState umum, jadi nantikan info selengkapnya.
Dan semua yang telah kita bahas sejauh ini, TextFieldState, TextFieldBuffer, transformasi input dan output, tidak dibuat hanya untuk input teks biasa.
Ini adalah fondasi yang mendukung kumpulan API terbaru kami untuk pengeditan teks berformat.
Sekarang, mari kita dengarkan penjelasan Haoyu tentang hal ini.
Oke.
Terima kasih.
Halo semuanya.
Selanjutnya, saya akan membahas API baru yang kami perkenalkan untuk pengeditan teks berformat.
Jadi, sebelum kita membahas API baru, mari kita mundur sejenak dan melihat cara kerja penataan gaya dalam teks statis.
Jadi, sebagian besar dari Anda sudah sangat mengenal 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)Ini adalah cara standar untuk menata gaya teks statis.
Kita menggunakan buildAnnotatedString dan memanggil add-- memanggil addStyle untuk menerapkan SpanStyle atau ParagraphStyle pada rentang tertentu yang diberikan.
Cara ini berfungsi dengan baik untuk teks statis.
Jadi, saat melihat-- saat melihat pengeditan teks berformat, pemikiran awal kami adalah, bisakah kita membuat AnnotatedString dapat diedit?
Namun, kami segera menyadari bahwa hal itu tidaklah mudah.
AnnotatedString adalah struktur data yang tidak dapat diubah.
Namun, di dunia pengeditan, pengguna terus mengetik.
Dan dengan setiap tombol-- setiap penekanan tombol, kita harus mengalokasikan objek AnnotatedString baru.
Dan di setiap AnnotatedString, ada banyak AnnotatedString.Range.
Jadi, objek tersebut juga harus dibuat ulang.
Hal ini tidak dapat diskalakan dengan baik dan menyebabkan overhead alokasi yang signifikan.
Jadi, lihat contoh ini.
Saat kita menyisipkan kata di tengah, setiap gaya setelahnya akan menggeser rentangnya dan dialokasikan ulang.
Hal ini jelas tidak akan berhasil jika Anda memiliki banyak gaya.
Jadi, kami memperkenalkan API penataan gaya khusus di dalam TextFieldBuffer yang diperkenalkan Anastasia sebelumnya.
API ini sangat mirip dengan API gaya visual di AnnotatedString.
Mari kita pelajari cara kerjanya.
Jadi, seperti AnnotatedString.Builder, TextFieldBuffer kini memiliki addStyle untuk menerapkan SpanStyle atau ParagraphStyle ke rentang.
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>Anda juga dapat meneruskan parameter ExpandPolicy, yang mengontrol perilaku gaya saat teks diketik tepat di batas gaya.
Kita akan membahasnya sebentar lagi.
Pertama, mari kita lihat cara kerja pelacakan gaya.
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)
}Setelah Anda menambahkan gaya ke TextFieldBuffer, rentangnya akan otomatis diperbarui saat teks diedit.
Dalam contoh ini, kita mulai dengan menebalkan beberapa teks.
Saat Anda menyisipkan teks sebelum rentang gaya, rentang gaya akan bergeser ke depan.
Saat Anda menyisipkan teks di dalamnya, rentang teks akan diperluas.
Dan saat kita menghapus teks di dalam rentang, rentang gaya akan menyusut sesuai dengan itu.
Jika semua teks di dalam rentang gaya dihapus, gaya akan diciutkan dan otomatis dihapus dari buffer.
Jadi, apa yang terjadi jika teks disisipkan tepat di batas rentang gaya?
Apakah teks yang baru disisipkan tercakup dalam gaya atau tidak?
Jadi, untuk memungkinkan Anda mengontrol hal ini, kami telah memperkenalkan API ExpandPolicy.
Mari kita lihat-- mari kita bahas satu per satu.
Jadi, yang pertama adalah 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")Dalam mode ini, menyisipkan teks di batas tidak akan memperluas rentang gaya.
Di sini, kita menerapkan rentang tebal terlebih dahulu.
Jika kita menyisipkan teks di awal rentang tebal, rentang gaya akan bergeser, tetapi tidak meluas.
Demikian pula, jika kita menyisipkan teks di akhir rentang tebal, rentang gaya juga tidak akan diperluas.
Berikut demo yang menunjukkan cara kerjanya di editor.
Jadi, selanjutnya adalah ExpandPolicy.AtStart.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")Seperti namanya, menyisipkan teks di awal rentang akan memperluas rentang gaya.
Jadi, saat kita menyisipkan teks tepat sebelum rentang gaya diperluas untuk mencakup teks yang baru disisipkan.
Berikut demo lain yang menunjukkan cara kerjanya.
Ketiga adalah ExpandPolicy.AtEnd.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")Jadi, kita menyisipkan teks di akhir rentang untuk memperluas rentang gaya.
Jadi, ini juga merupakan setelan yang paling umum untuk editor.
Berikut demo yang menunjukkan cara kerjanya.
Terakhir, ada ExpandPolicy.AtBoth, yang memperluas gaya saat teks disisipkan di awal dan akhir rentang.
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")
}Jadi, demo lain untuk ini.
Sejauh ini, kita telah membahas semua API penerapan gaya.
Selain itu, kami juga memperkenalkan API kueri baru seperti getSpanStyles, getParagraphStyles untuk mengambil gaya yang saat ini diterapkan dalam rentang tertentu.
// 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>>Berikut adalah dua versi API kueri.
Di TextFieldState, API kueri gaya bersifat hanya baca.
Fungsi ini menampilkan daftar objek AnnotatedString.Range yang tidak dapat diubah.
Namun, di TextFieldBuffer, getSpanStyles menampilkan daftar objek TrackedRange yang baru diperkenalkan, yang akan saya perkenalkan nanti.
Jadi, semua kueri gaya memperhatikan urutan.
Metode ini menampilkan rentang gaya sesuai urutan penerapan gaya.
Jadi, hal ini memungkinkan Anda menerapkan, seperti AnnotatedString, untuk menerapkan gaya lain di atas gaya yang ada untuk menimpa gaya sebelumnya.
Selanjutnya adalah TrackedRange API.
var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): BooleanObjek TrackedRange ditampilkan oleh API addStyle atau getSpanStyles di TextFieldBuffer.
Anggap saja sebagai handle aktif dari rentang gaya dalam buffer.
Anda dapat menggunakannya untuk memperbarui rentang teks, ExpandPolicy, atau gaya itu sendiri di tempatnya.
Class ini juga memiliki properti isValid untuk memeriksa apakah rentang gaya masih ada di TextFieldBuffer.
Hal ini karena gaya dapat dihapus jika-- jika diciutkan selama pengeditan, atau dihapus dengan sengaja oleh removeStyle.
Selanjutnya, mari kita lihat bagaimana API tersebut dipadukan dalam kasus penggunaan di dunia nyata.
Kasus penggunaan pertama adalah membuat Editor What You See Is What You Get, WYSIWYG.
Dalam contoh ini, kami menerapkan fitur tombol tebal.
Mengklik tombol tebal akan mengaktifkan/menonaktifkan gaya tebal pada teks yang dipilih.
Selanjutnya, mari kita lihat kodenya.
Jadi, saat pengguna mengetuk tombol tebal, pertama-tama kita akan memanggil getSpanStyles pada pilihan untuk menemukan semua gaya yang tumpang-tindih.
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())
}
}Kemudian, kita periksa apakah seluruh pilihan sudah ditebalkan atau belum.
Di sini, kata yang dipilih tidak dicetak tebal.
Karena tidak dicetak tebal, kita addStyle ke rentang yang dipilih.
Kita meneruskan ExpandPolicy.AtEnd, sehingga pengguna dapat terus mengetik teks tebal tepat setelah kata ini.
Sekarang, mari kita lihat apa yang terjadi jika rentang yang dipilih sudah dicetak tebal.
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)
}
}Dalam hal ini, kita perlu menghapus gaya tebal dari rentang yang dipilih.
Di sini, kata yang dipilih pengguna berada di dalam frasa tebal yang lebih panjang.
Karena gaya tebal meluas sebelum pilihan, kita perlu memperbarui rentang gaya, sehingga kata yang dipilih tidak ditebalkan.
Untuk melakukannya, kita dapat menggunakan TrackedRange API.
Dan untuk kasus khusus ini, daripada menghapus gaya ini dan membuatnya kembali, kita dapat menyesuaikan rentang span tebal di tempat untuk berakhir sebelum pilihan di indeks 10.
Tentu saja, ini adalah implementasi yang sangat disederhanakan.
Untuk demo lengkap, Anda dapat memeriksa repositori contoh Compose kami.
Selanjutnya, mari kita lihat gaya visual berbasis pola.
Dalam demo ini, saat pengguna mengetik tanda bintang Markdown di sekitar kata, editor akan otomatis mengonversinya menjadi teks tebal saat mereka mengetik.
Sekarang, mari kita lihat cara penerapannya menggunakan 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
)
}
}
}Dalam filter input transformasi, kita menemukan semua pola yang cocok di tombol.
Dan untuk setiap kecocokan, kita mengekstrak konten di dalam tanda bintang ganda.
Dalam contoh ini, kata tersebut adalah "fox".
Selanjutnya, kita hapus tanda bintang dan ganti pola yang cocok dengan kontennya.
Terakhir, kita memanggil addStyle ke bold untuk menerapkan gaya tebal ke teks konten.
Ingat, InputTransformation akan mengirimkan ke status kolom teks akhir.
Artinya, gaya tebal diterapkan ke status secara persisten.
Terakhir, mari kita lihat contoh penandaan penelusuran.
Jadi, dalam contoh ini, saat pengguna mengetik kueri penelusuran, kata yang cocok di kolom teks akan ditandai, tanpa mengubah status kolom teks.
Sekarang, mari kita lihat penerapannya di OutputTransformation.
Saat menandai kata penelusuran, Anda tidak boleh mengubah status teks.
OutputTransformation adalah tempat yang tepat untuk menerapkan kasus penggunaan ini.
Jadi, mari kita periksa implementasi sebenarnya.
Pertama, kami menelusuri kata-kata yang ditandai dan menemukan semua kecocokan.
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)
}
}
}Perhatikan juga bahwa kueri adalah status yang dapat berubah.
Hal ini karena OutputTransformation mendukung snapshot.
Saat status yang dapat diubah ini diperbarui, OutputTransformation akan diterapkan kembali secara otomatis.
Kemudian, saat kita melakukan loop pada setiap kecocokan dan memanggil addStyle untuk menggambar sorotan kuning di atas rentang kecocokan.
Sekali lagi, OutputTransformation hanya mengubah teks visual, dan status teks asli tidak tersentuh.
Oke, itu semua kasus penggunaan yang kami miliki.
Sekadar mengingatkan, semua API yang ditampilkan sebelumnya tersedia mulai Compose 1.12.
Sebagai penutup, saya ingin membagikan sekilas info tentang project baru yang sedang kami kerjakan.
Library ini adalah library teks markup untuk Jetpack Compose, yang mendukung format rich text seperti Markdown.
Salah satu kemampuan paling menarik yang kami dukung adalah penguraian inkremental dan rendering inkremental.
Jadi, model ini sangat cocok untuk pengeditan langsung atau streaming Markdown.
Berikut adalah rekaman prototipe kami yang berfungsi, dan Anda dapat melihat area yang memudar adalah bagian yang diperbarui di UI.
Jika Anda tertarik, atau memiliki beberapa kasus penggunaan di aplikasi Anda, jangan ragu untuk menghubungi kami.
Terima kasih semuanya.
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)
}
}
}