Kıdemli Yazılım Mühendisi
Compose'da güvenilir metin düzenleme yolculuğu, BasicTextField'ın tamamen yeniden yazılmasıyla sonuçlandı. Tampon tabanlı mimariye geçiş, IME yarışma durumlarını tamamen ortadan kaldırdı. Bu oturumda, eski giriş senkronizasyonunun neden kaldırılması gerektiği ve TextFieldState'in klavye güncellemelerini nasıl senkronize ettiği açıklanıyor. Ardından, Markdown oluşturmaya erken bir bakış atmadan önce TrackedRange'in mekaniğini, düzenleme bloklarının performans maliyetlerini ve stil sınırlarının arkasındaki gerçek dünyadaki sorunları keşfetmek için biçimlendirilmiş düzenlemeyi ayrıntılı bir şekilde inceleyin.
TextFieldState API aşırı yüklemesi, Compose'daki geri çağırma tabanlı aşırı yüklemelerin yerini alır. Kullanıcı yazarken eşzamanlı olarak güncellenir. Böylece yazılım klavyeleriyle ilgili zamanlama sorunları ve gereksiz yeniden oluşturma işlemleri ortadan kalkar.TextFieldBuffer kullanarak metinleri değiştirmenize olanak tanır. Düzenlemeleri kaydetmeden önce müdahale edebilir veya ofset eşlemelerini manuel olarak hesaplamadan görünen metni biçimlendirebilirsiniz.TextFieldState öğesini yukarı taşıma işlemi, mimari ihtiyaçlarınıza bağlıdır. Bu durumu, bir composable'da yerel olarak tutabilir veya iş mantığı erişimi için doğrudan bir ViewModel içinde saklayabilirsiniz.TextFieldBuffer için biçimlendirilmiş metin düzenleme API'leri ekler. Özelleştirilebilir ExpandPolicy seçenekleriyle stiller uygulayabilirsiniz. Mevcut stil aralıkları, kullanıcılar düzenleme yaptıkça otomatik olarak güncellenir.Transkript
Herkese merhaba.
Sohbetimize katıldığınız için teşekkür ederiz.
Ben Anastasia. Meslektaşım Haoyu da bana eşlik ediyor.
Bugün size güvenilir bir metin düzenleme deneyimini nasıl oluşturacağınızı göstereceğiz.
İster basit bir giriş ekranı, ister yazdıkça arama yapan bir çubuk, ister karmaşık bir mesajlaşma uygulaması geliştiriyor olun, metin alanları kullanıcıların Android uygulamalarınızla etkileşim kurma şeklinin merkezinde yer alır.
Bu nedenle, çoğunuz muhtemelen Material metin alanlarını veya ana hatları belirgin metin alanlarını kullanıyorsunuzdur.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())Kendi tasarım sisteminizi oluşturuyorsanız veya basit bir düzenleme kutusuna ihtiyacınız varsa BasicTextField da kullanabilirsiniz.
BasicTextField(state = rememberTextFieldState())Şifre girmek için özel olarak tasarlanmış SecureTextField da vardır.
SecureTextField(state = rememberTextFieldState())BasicTextField ise stil uygulanmamış bir bileşendir ve aslında Compose'daki diğer tüm metin alanlarını destekler.
Son birkaç yılda paralel olarak iki API şeklimiz olduğunu fark etmiş olabilirsiniz. Bunlardan biri değer ve değer değişikliği geri çağırma işlevini, diğeri ise TextFieldState nesnesini alır.
@Composable
public fun BasicTextField(
state: TextFieldState,
)Hangisini önerdiğimizi merak ediyor olabilirsiniz.
TextFieldState aşırı yüklemesini kullanmanızı önemle tavsiye ederiz.
Bu konuşmada, API, özellikler ve performans açısından avantajlarını ele alacağız.
Compose 1.7'den beri kararlı bir şekilde çalışmaktadır.
Geri çağırma aşırı yüklemelerini ise Compose 1.13'te BasicTextField ve materyal sarmalayıcılar genelinde kademeli olarak kullanımdan kaldırıyoruz.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> UnitYani kritik güvenlik sorunları veya erişilebilirlik dışında yeni özellikler eklemeyecek ya da hataları düzeltmeyeceğiz.
Değer değişikliği aşırı yüklemesindeki durum bilgisiz değer, eşzamanlı girişler için gerçekten çok iyi çalıştı.
Ancak bunu eşzamansız akışlarla (ör. StateFlow) eşleştirdiğinizde IME'de veya teknik terimle yazılım klavyesinde zamanlama sorunları ortaya çıkıyordu.
Bu nedenle, bu kayıtta da görebileceğiniz gibi karakterler düşüyor, yineleniyor ve imleç atlıyordu.
Bu sorunu kökten çözmek için TextFieldState özelliğini kullanıma sunduk.
Kullanıcı, asenkron gidiş dönüşleri beklemeden yazarken eşzamanlı olarak güncellenir. Bu, tam olarak IME'nin ihtiyaç duyduğu şeydir.
TextFieldState, salt okunur metne ek olarak etkin bir düzenleme oturumu oluşturan başka özelliklere de sahiptir.
Seçim, geri alma ve yeniden yapma durumunun yanı sıra biçimlendirilmiş metin düzenleme için span ve span stilleri ile paragraf stilleri de buna dahildir. Bu konuya daha sonra değineceğiz.
TextFieldState öğesinin dahili özellikleri, Compose Snapshot System tarafından desteklenir.
Yani Compose'da bir yerde bu metni veya seçimi TextFieldState nesnesinden okuduğunuzda, okuduğunuz belirli kapsam güncellemelere abone olur.
BasicTextField'ın yalnızca düzen veya çizim aşamalarında durumu okumasına olanak tanır ve bazılarıyla birlikte kullanıcı yazarken gereksiz yeniden oluşturma işlemini ortadan kaldırır.
Diğer bazı değişikliklerle birlikte bu, TextFieldState aşırı yüklemesi için önemli bir performans iyileştirmesi sağladı.
TextFieldState'in metni salt okunur bir özellikse giriş alanınızda programatik değişiklikleri nasıl yaparsınız?
TextFieldBuffer bu noktada devreye girer.
Değişiklik yapmanızı sağlayan API'ler sunan, değiştirilebilir bir nesnedir.
Bu nedenle, programatik değişiklik yapmak istediğinizde TextFieldState öğesinin düzenleme işlevini çağırırsınız.
Bu lambda içinde bir TextFieldBuffer elde edersiniz.
TextFieldBuffer, gerektiğinde karakter eklemenize, değiştirmenize ve silmenize olanak tanıyan çeşitli işlevler sunar.
Düzenlemeyle ilgili kısa bir ipucu: Müşterileri arka arkaya birkaç kez aramaktan kaçının.
// 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) }Bunun yerine, tüm işlemlerinizi tek bir düzenleme çağrısında toplayın.
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}Bu sayede Compose, TextFieldBuffer'ın yalnızca tek bir örneğini oluşturur, bu örneği yerinde değiştirir ve ardından IME ve Snapshot System ile bir kez senkronize eder.
Başka bir ipucu da, ihtiyacınız olacak en yaygın toplu işlemler için TextFieldState üzerinde uzantı işlevlerimiz olmasıdır.
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}Bu nedenle, bu işlemleri gruplandırmayı düşünmenize gerek yoktur.
Bunlardan birini aramanız yeterli.
Örneğin, clearText ve setTextAndPlaceCursorAtEnd.
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}Peki, kullanıcı girişini filtreleme konusunda ne yapabiliriz?
Örneğin, karakter sınırlaması uygulamak veya yalnızca rakamları kabul etmek isteyebilirsiniz.
Eski API'de, onValueChange içindeki filtreleme, metin zaten onaylandıktan sonra çalışıyordu.
onValueChange { newString ->
// full diffing new String vs old String
}Bu nedenle, bu girişi reddetmek veya değiştirmek klavyeyle mücadele etmek gibiydi.
Ayrıca, onValueChange geri çağırma işlevi yalnızca son dizeyi döndürüyordu.
Bu nedenle, neyin değiştiğini anlamak için tam dize karşılaştırması yapmanız gerekiyordu.
Bunun yerine, yeni aşırı yüklemede giriş dönüşümü var.
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}Bu sayede, kullanıcı girişini metin alanı durumuna ulaşmadan önce yakalar ve size aynı TextFieldBuffer API'lerini sunar.
Giriş dönüşümüyle yalnızca rakamları filtreleme işlemini bu şekilde uygulayabilirsiniz. Temel olarak, değişiklikleri almanız yeterlidir.
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}Rakam olmayan karakterler olup olmadığını kontrol edersiniz. Varsa bu TextFieldBuffer'da revertAllChanges işlevini çağırarak girişi reddedersiniz.
Bu giriş dönüşümünün temel özelliği ChangeList'tir.
Bu sayede, dizelerin tamamını karşılaştırmak yerine tam düzenleme aralıklarını inceleyebilir ve ardından bunları yerinde değiştirebilir veya gerekirse girişi tamamen reddedebilirsiniz.
Compose, klavyeyi ve imleci sizin için otomatik olarak senkronize eder.
Son olarak, girişinizi görsel olarak biçimlendirmek isterseniz ne yapmanız gerekir?
Örneğin, bir kredi kartı numarasına veya telefon numarasına parantez ya da tire eklemek istiyorsunuz.
Evet.
Bunları veritabanınızda saklamak istemezsiniz, değil mi?
Daha önce görsel dönüşüm API'sini kullanmıştınız.
Bu durumda, ham girişten görsel çıkışa ve ardından tekrar geri giden çift yönlü bir eşleme uygulamanız gerekiyordu.
Bunu doğru şekilde yapmak gerçekten zordu ve genellikle çalışma zamanı istisnalarına yol açıyordu.
Artık bir çıkış dönüşümümüz var ve bu dönüşüm de arka planda aynı TextFieldBuffer tarafından destekleniyor. Bu nedenle, hemen hemen aynı API'yi elde edersiniz.
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}Bu telefon numarası örneğinde, belirli bir dizine tire eklemek için TextFieldBuffer'ın ekleme işlevini çağırmanız yeterlidir. Sistem, çift yönlü ofset eşlemeyi sizin için hesaplar.
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}Böylece manuel hesaplama yapma ihtiyacı ortadan kalkar ve kilitlenme sayısı önemli ölçüde azalır.
Şimdi en önemli kısma geçelim: Taşıma işlemini nasıl yapacaksınız ve TextFieldState bayrağını nereye dikeceksiniz?
Uygulamada, neredeyse her kullanım alanı bahsedeceğim kategorilerden birine girer.
Böyle bir durum söz konusu değilse genellikle alışılmadık bir mimariniz olduğu anlamına gelir ancak bu örnekler yine de taşıma işleminizde size yol gösterir.
En basit senaryoda TextFieldState, metin alanının bulunduğu composable'ın içinde tutulur.
@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()) }) { }
)
}Bu, metin alanınızın bağımsız bir giriş alanı olduğu (örneğin, promosyon kodu giriş alanı) ve yalnızca kullanıcı gönder düğmesine bastığında bu metne erişmeniz gerektiği durumdur, değil mi?
Bu nedenle, görünüm modelinizin her değişiklikte kullanıcı girişini kesmesi gerekmez.
Yani rememberTextFieldState yöntemini yerel olarak çağırırsınız ve rememberTextFieldState, işlem sonlandırmadan sonra durumun geri yüklenmesine de yardımcı olur.
Ardından, filtreleme için giriş dönüşümünü kullanırsınız ve bu durumdan (TextFieldState nesnesi) metni alırsınız. Kullanıcı gönderdiğinde de bunu lambda aracılığıyla iletirsiniz.
Peki, karakter sayacı veya gönder düğmesi gibi kardeş kullanıcı arayüzü öğelerinin de metni okuması veya değiştirmesi gerekiyorsa ne olur?
Bu durumda, TextFieldState nesnesini ortak bir composable işleve, ortak bir üst composable işleve taşırsınız.
@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)
}
}TextFieldState, anlık görüntü sistemi tarafından desteklendiğinden üst öğe üzerinden iletildiğinde üst öğelerin yeniden oluşturulmasını tetiklemez.
Yalnızca eyalet metnini okuduğunuz yerler güncellenir.
Anladım.
Bu nedenle, TextFieldState, giriş ve çıkış dönüşümünüzün durumu dahili olarak koordine etmesi gereken karmaşık kullanıcı arayüzü kullanım alanlarında, bunları ayrı ayrı kaldırmak ve composable işlevde birbirine bağlamaya çalışmak yerine bir durum bilgisi depolayıcı nesnesi kullanın.
@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,
)
}Satır içi bir hashtag'i işlemek istediğinizi varsayalım.
TextFieldState, ham metin ve imleç konumunu içerir.
Giriş dönüştürme, hashtag'leri algılamaktan sorumludur. Ayrıca giriş dönüştürme aracılığıyla atomik etiket silme işlemi de yapabilirsiniz.
Çıkış dönüşümü, ek görsel stil ekler ve hashtag'i silmenize de yardımcı olabilir.
Bunların hepsi tek bir durum bilgisi depolayıcı nesnesinde toplanır. Bu nesneye TagEditorState diyelim.
@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) { ... }
}Ardından, bu örnekteki tagRanges gibi ara hesaplamaları yeniden kullanabilirler.
Ardından, bu durum bilgisi depolayıcı nesnesini composable'ınıza iletirsiniz.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}Başka bir kullanım alanı da, iş mantığınızın yazarken arama veya karmaşık bir doğrulama için bu metne erişmesi gerektiğinde, bu metin alanını ViewModel içine yükseltmektir.
Değiştirilebilir metin alanı nesnesini, değiştirilemez veri sınıfı kullanıcı arayüzü durumuna yerleştirmemeniz önemlidir. Çünkü bu nesne, bir kopyalama yöntemiyle kopyalanır ve her değişiklikte StateFlow içine aktarılır.
data class FormUiState(val username: TextFieldState)TextFieldState, Snapshot System tarafından desteklenen uzun ömürlü, değiştirilebilir bir nesnedir ve referans nesnesi hiçbir zaman değişmez.
Bu nedenle, referans aynı kaldığı için StateFlow, kullanıcının o anda yazmakta olduğunu bile bilmez.
Daha da kötüsü, kullanıcı arayüzü durumunun yeni bir örneğini oluşturur ve yeni bir TextFieldState ayırırsanız giriş alanınız tüm durumunu kaybeder.
Yani seçim, imleç konumu veya geri alma/yeniden yapma geçmişi gibi şeyler.
Bunun yerine, TextFieldState özelliğini doğrudan ViewModel öğenizin bir özelliği yapın.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}Buradaki ViewModel kuralında kullanıcı arayüzü olmaması konusunda endişelenmeyin. Bu durumda TextFieldState, Android View veya Context nesnesine ya da Compose yaşam döngüsüne referans vermez.
Bu, yalnızca anlık görüntü destekli bir durum bilgisi depolayıcı nesnesidir.
Bu durumda, kavramsal olarak StateFlow ile arasında bir fark yoktur.
ViewModel'ın rememberSaveable olmadığını unutmayın. Bu nedenle, metin alanının işlem sonlandırmadan etkilenmemesi ve durumunu geri yüklemesi için TextFieldState.Saver bağlamanız gerekir.
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}Peki ya ekibinizde ViewModel'lerin yalnızca saf Kotlin modüllerinde bulunduğu katı modül kuralları varsa ve compose-foundation bağımlılığını getiremiyorsanız ne olur?
Ya da örneğin, karmaşık bir eski ekranı taşıyorsunuz ve kullanıcı arayüzü durumunu bugün güncelleyemiyor veya yeniden düzenleyemiyor musunuz?
Bu durumda, uyumlu yaklaşımı kullanın.
Bu nedenle, bu metin alanı için tek doğruluk kaynağı olarak composable'da TextFieldState değerini tutarsınız.
Ancak daha sonra, bu metin değişikliklerini mevcut ViewModel dosyanıza geri göndermek için snapshotFlow ile LaunchedEffect kullanırsınız.
@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, Compose'dan bağımsız kalır. Kullanıcı arayüzü durum özellikleri her değişiklikte metin alanına geri bildirim göndermediği için eş zamansız gidiş dönüş hatalarından kaçınmaya devam edersiniz.
Evet, burada sık yapılan bir hataya dikkat etmek gerekiyor.
@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)
}Çoğu durumda ilk etkinliği atlamak istersiniz.
Bu sayede, arama sorgularının gereksiz yere kopyalanması veya ViewModel değerinizin ilk değerle üzerine yazılması önlenir.
Mantığınızın, durumdaki değişiklikleri geri bildirmesi gerekiyorsa (örneğin, kullanıcı gönder düğmesine bastığında metni temizlemek istiyorsanız) ViewModel'in ViewModel değerini boş dize olarak ayarlayamazsınız.
@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)
}Bunun yerine, açık bir metin etkinliği yayınlamanız gerekir. Ardından, bu TextFieldState nesnesini barındıran composable işlev, bu nesnede clearText işlevini çağırır.
Geçiş sürecinde size daha fazla yardımcı olmak için bu geçiş rehberini hazırladık.
Bu bağlantıyı kullanarak erişebilirsiniz.
Ayrıca hem geçişi hem de genel TextFieldState değişikliklerini gözlemlemeyi kolaylaştırmak için ek API'ler üzerinde çalışıyoruz. Bu konudaki gelişmeleri takip edin.
Şimdiye kadar ele aldığımız TextFieldState, TextFieldBuffer, giriş ve çıkış dönüşümü gibi her şey yalnızca düz metin girişi için oluşturulmadı.
Bunlar, biçimlendirilmiş metin düzenleme için en yeni API grubumuzu destekleyen temeldir.
Şimdi bu konuda daha fazla bilgi vermesi için sözü Haoyu'ya bırakıyorum.
İşte böyle.
Teşekkür ederiz.
Herkese merhaba.
Şimdi, biçimlendirilmiş metin düzenleme için kullanıma sunduğumuz yeni API'lerden bahsedeceğim.
Yeni API'leri incelemeden önce, statik metinlerde stil oluşturmanın nasıl çalıştığına bakalım.
Bu nedenle, çoğunuz AnnotatedString'i zaten çok iyi biliyorsunuz.
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)Statik metinleri biçimlendirmenin standart yolu budur.
Belirli aralıklarda SpanStyle veya ParagraphStyle uygulamak için buildAnnotatedString kullanır ve add-- call addStyle işlevini çağırırız.
Statik metinler için iyi bir seçenektir.
Biçimlendirilmiş metin düzenlemeye baktığımızda ilk düşüncemiz, AnnotatedString'i düzenlenebilir hale getirip getiremeyeceğimizdi.
Ancak bunun çok basit olmadığını hemen fark ettik.
AnnotatedString, sabit bir veri yapısıdır.
Ancak düzenleme dünyasında kullanıcı sürekli yazıyor.
Her tuş vuruşunda yeni bir AnnotatedString nesnesi tahsis etmemiz gerekir.
Her AnnotatedString'de çok sayıda AnnotatedString.Range var.
Bu nedenle, bu nesnelerin de yeniden oluşturulması gerekir.
Bu yöntem çok iyi ölçeklenmez ve önemli ölçüde tahsisat ek yüküne neden olur.
Bu örneğe bakalım.
Kelimeyi ortaya eklediğimizde, ondan sonraki her stil aralığını kaydırır ve yeniden tahsis edilir.
Çok sayıda stiliniz varsa bu yöntem işe yaramaz.
Bu nedenle, Anastasia'nın daha önce tanıttığı TextFieldBuffer'da özel stil API'leri kullanıma sunduk.
Bunlar, AnnotatedString'deki stil API'lerine çok benzer.
Şimdi bunların nasıl çalıştığını inceleyelim.
AnnotatedString.Builder gibi TextFieldBuffer da artık bir aralığa SpanStyle veya ParagraphStyle uygulamak için addStyle işlevine sahip.
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>Metin tam olarak stil sınırlarında yazıldığında stilin nasıl davranacağını kontrol eden bir ExpandPolicy parametresi de iletebilirsiniz.
Buna birazdan bakacağız.
İlk olarak stil takibinin nasıl çalıştığına bakalım.
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)
}TextFieldBuffer'a bir stil eklediğinizde, metin düzenlendikçe stilin aralığı otomatik olarak güncellenir.
Bu örnekte, önce metnin bir kısmını kalın yapıyoruz.
Metni, stil aralığından önce eklediğinizde stil aralığı ileri kayar.
İçine metin eklediğinizde metin aralığı genişler.
Aralıktaki metni sildiğimizde stil aralığı da buna göre küçülür.
Stil aralığındaki tüm metin silinirse stil daraltılır ve arabellekten otomatik olarak kaldırılır.
Peki metin, bir stil aralığının tam sınırlarına eklendiğinde ne olur?
Yeni eklenen metin, stil kapsamında mı?
Bu nedenle, bunu kontrol edebilmeniz için ExpandPolicy API'lerini kullanıma sunduk.
Şimdi bunlara göz atalım.
İlk olarak 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")Bu modda, sınıra metin eklemek stil aralıklarını genişletmez.
Burada önce kalın yazı aralığı uyguluyoruz.
Kalın metin aralığının başına metin eklersek stil aralığı kayar ancak genişlemez.
Benzer şekilde, kalın aralığın sonuna metin eklersek stil aralığı da genişlemez.
Şimdi bu özelliğin bir düzenleyicide nasıl çalıştığını gösteren bir demo izleyelim.
Sırada ExpandPolicy.AtStart var.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")Adından da anlaşılacağı üzere, aralığın başına metin eklemek stil aralığını genişletir.
Bu nedenle, metni hemen önce eklediğimizde stil aralığı, yeni eklenen metni kapsayacak şekilde genişler.
Nasıl çalıştığını gösteren başka bir demoya bakalım.
Üçüncü ise ExpandPolicy.AtEnd.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")Bu nedenle, stil aralığını genişletmek için aralığın sonuna metin ekliyoruz.
Bu nedenle, editörler için en yaygın ayar da budur.
Şimdi bu özelliğin nasıl çalıştığını gösteren bir demo izleyelim.
Son olarak, metin aralığın başına ve sonuna eklendiğinde stili genişleten ExpandPolicy.AtBoth var.
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")
}Bunun için başka bir demo daha var.
Şimdiye kadar stil uygulayan tüm API'leri ele aldık.
Ayrıca, belirli bir aralıkta uygulanan stilleri almak için getSpanStyles, getParagraphStyles gibi yeni sorgu API'lerini de kullanıma sunduk.
// 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>>Sorgu API'lerinin iki sürümü vardır.
TextFieldState üzerinde stil sorgusu API'leri salt okunurdur.
Değiştirilemez AnnotatedString.Range nesnelerinin bir listesini döndürürler.
Ancak TextFieldBuffer'da getSpanStyles, yeni tanıtılan TrackedRange nesnelerinin bir listesini döndürdü. Bu nesneleri daha sonra tanıtacağım.
Bu nedenle, tüm stil sorguları sıraya duyarlıdır.
Stil aralıklarını, stilin uygulandığı sırayla döndürürler.
Bu sayede, AnnotatedString'de olduğu gibi, mevcut bir stilin üzerine başka bir stil uygulayarak önceki stillerin üzerine yazabilirsiniz.
Sırada TrackedRange API var.
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<*>): BooleanTrackedRange nesneleri, TextFieldBuffer'da addStyle veya getSpanStyles API'leri tarafından döndürülür.
Bunu, arabellekteki stil aralığının canlı bir tutamacı olarak düşünebilirsiniz.
Bu seçeneği kullanarak metin aralığını, ExpandPolicy veya stilin kendisini güncelleyebilirsiniz.
Ayrıca, stil aralığının hâlâ TextFieldBuffer'da olup olmadığını kontrol etmek için bir isValid özelliği de vardır.
Bunun nedeni, düzenleme sırasında daraltılırsa stilin kaldırılabilecek olması veya removeStyle tarafından kasıtlı olarak kaldırılmasıdır.
Şimdi bu API'lerin gerçek hayattaki kullanım alanlarında nasıl bir araya geldiğini görelim.
İlk kullanım alanı, "What You See Is What You Get" (WYSIWYG) düzenleyicisi oluşturmaktır.
Bu örnekte, kalın düğme özelliğini uyguladık.
Kalın düğmesini tıkladığınızda seçili metinde kalın stil açılır veya kapatılır.
Şimdi koda göz atalım.
Kullanıcı kalın düğmesine dokunduğunda, önce bir seçimde getSpanStyles yöntemini çağırarak tüm çakışan stilleri buluruz.
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())
}
}Ardından, seçimin tamamının kalın olup olmadığını kontrol ederiz.
Burada, seçilen kelime kalın değildir.
Kalın olmadığı için seçili aralığa addStyle.
Kullanıcının bu kelimeden hemen sonra kalın yazmaya devam edebilmesi için ExpandPolicy.AtEnd değerini iletiyoruz.
Şimdi, seçili aralık zaten kalınsa ne olacağına bakalım.
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)
}
}Bu durumda, seçili aralıktaki kalın stilini temizlememiz gerekir.
Burada, kullanıcının seçtiği kelime daha uzun olan kalın harfli ifadenin içindedir.
Kalın stil seçimden önce başladığı için stil aralığını güncellememiz ve seçili kelimeyi kalın olmayan şekilde bırakmamız gerekir.
Bunun için TrackedRange API'sini kullanabiliriz.
Bu özel durumda, bu stili silip yeniden oluşturmak yerine, kalın metin aralığını 10. dizindeki seçimden önce bitecek şekilde ayarlayabiliriz.
Elbette bu, son derece basitleştirilmiş bir uygulamadır.
Tam bir demo için Compose örnekleri depomuza göz atabilirsiniz.
Şimdi de desene dayalı stil oluşturmaya bakalım.
Bu demoda, kullanıcı bir kelimenin başına ve sonuna Markdown yıldız işaretleri eklediğinde düzenleyici, yazılan metni otomatik olarak kalın metne dönüştürür.
Şimdi, InputTransformation kullanarak nasıl uygulandığını görelim.
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
)
}
}
}Dönüştürme giriş filtremizde, düğme üzerindeki tüm eşleşen kalıpları buluruz.
Her eşleşme için çift yıldız işaretlerinin içindeki içeriği ayıklarız.
Bu örnekte, "tilki" kelimesidir.
Ardından, yıldız işaretlerini kaldırıp eşleşen kalıbı içeriğiyle değiştiririz.
Son olarak, içerik metnine kalın stilini uygulamak için addStyle öğesini çağırıyoruz.
InputTransformation, metin alanının son durumunu gönderir.
Bu, kalın stilin duruma kalıcı olarak uygulandığı anlamına gelir.
Son olarak, arama vurgulama örneğine bakalım.
Bu örnekte, kullanıcı bir arama sorgusu yazdığında metin alanlarındaki eşleşen kelimeler, metin alanının durumu değiştirilmeden vurgulanır.
Şimdi, OutputTransformation içinde nasıl uygulandığına bakalım.
Bir arama kelimesini vurgularken metin durumunu hiçbir zaman değiştirmemelisiniz.
OutputTransformation, bu kullanım alanını uygulamak için mükemmel bir yerdir.
Şimdi gerçek uygulamayı kontrol edelim.
Önce vurgulanan kelimeleri arar ve tüm eşleşmeleri buluruz.
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)
}
}
}Sorgunun değiştirilebilir bir durum olduğunu unutmayın.
Bunun nedeni, OutputTransformation aracının anlık görüntüleri algılayabilmesidir.
Bu değiştirilebilir durum güncellendiğinde OutputTransformation otomatik olarak yeniden uygulanır.
Ardından, her eşleşme için döngü oluşturup addStyle yöntemini çağırarak eşleşme aralığını sarı renkle vurgularız.
OutputTransformation yalnızca görsel metni değiştirir ve orijinal metin durumlarına dokunulmaz.
Tüm kullanım alanlarını inceledik.
Daha önce gösterilen tüm API'lerin Compose 1.12'den itibaren kullanılabileceğini hatırlatmak isteriz.
Son olarak, şu anda üzerinde çalıştığımız yeni projeye dair kısa bir önizleme paylaşmak istiyorum.
Markdown gibi zengin metin biçimlerini destekleyen, Jetpack Compose için bir işaretleme metni kitaplığıdır.
Desteklediğimiz en heyecan verici özelliklerden biri, artımlı ayrıştırma ve artımlı oluşturmadır.
Bu nedenle, canlı düzenleme veya Markdown akışı için çok uygundur.
Çalışan prototipimizin kaydını burada görebilirsiniz. Soluklaşan alanlar, kullanıcı arayüzünde güncellenen bölümlerdir.
Bu konuyla ilgileniyorsanız veya uygulamanızda bu tür bir kullanım alanınız varsa lütfen bizimle iletişime geçin.
Herkese teşekkürler.
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)
}
}
}