Zengin metin girişini biçimlendirme

Zengin metin girişi, kullanıcıların yazdıklarını biçimlendirmesine olanak tanır. Örneğin, bir kelimeyi kalın yapabilir, başlık ekleyebilir, paragrafı hizalayabilir veya vurgulanmış bir @mention ekleyebilirler. Compose'da bunu, duruma dayalı metin alanlarının (BasicTextField ve TextField) üzerine inşa edersiniz. Stiller, metinle birlikte TextFieldState içinde bulunur. Bu sayede düzenlemeler, seçim değişiklikleri ve yeniden oluşturma işlemlerinden etkilenmez ve kullanıcı yazarken, silerken ve yapıştırırken senkronize kalır.

Bu sayfada, TextFieldBuffer kullanarak TextFieldState içinde stilleri nasıl uygulayacağınız, güncelleyeceğiniz, sorgulayacağınız, ayıklayacağınız ve kaldıracağınız, ayrıca TextFieldState.textStyles üzerinden biçimlendirme araç çubuğunu nasıl kullanacağınız açıklanmaktadır.

Satır içi ve paragraf stilleri uygulama

TextFieldState.edit, InputTransformation.transformInput ve OutputTransformation.transformOutput dahil olmak üzere herhangi bir TextFieldBuffer alıcı kapsamı içinde karakter düzeyinde ve paragraf düzeyinde biçimlendirme uygulayabilirsiniz.

ExpandPolicy ile sınır genişletmeyi kontrol etme

addStyle yöntemini çağırdığınızda, kullanıcı doğrudan start veya end sınırına yeni karakterler eklediğinde stilize edilmiş aralığın nasıl davranacağını tanımlamak için bir ExpandPolicy iletebilirsiniz:

  • (start, end) içine ekleme: Her zaman stilli aralığı genişletir.
  • start öncesine ekleme: Her zaman hem start hem de end ofsetlerini sağa kaydırır.
  • Doğrudan start veya end konumuna ekleme: Belirttiğiniz ExpandPolicy tarafından kontrol edilir.

Aşağıdaki tabloda, kullanılabilir ExpandPolicy seçenekleri ve bunların tipik kullanım alanları açıklanmaktadır:

Politika Sınır davranışı Önerilen kullanım alanları Örnek
ExpandPolicy.InsideOnly start veya end konumunda yazılan metin, stil uygulanan aralığın dışında kalır. @mentions, #hashtags gibi ayrı jetonlar veya etiketin yanına yazarken etiketin arka plan rengini devralmaması gereken arka plan hapları. ExpandPolicy.InsideOnly aralığının başına veya sonuna yazılan metnin stilinin değişmediğini gösteren animasyon.
ExpandPolicy.AtStart start konumunda yazılan metin stili devralır, end konumunda yazılan metin ise stilsiz kalır. Stil uygulanmış metnin hemen önüne yazılan metin de aynı biçimlendirmeyi kullanmalıdır. ExpandPolicy.AtStart aralığının başında yazılan metnin stili devraldığını, sonunda yazılan metnin ise stil uygulanmadan kaldığını gösteren animasyon.
ExpandPolicy.AtEnd end konumunda yazılan metin stili devralır, start konumunda yazılan metin ise stilsiz kalır. Standart zengin metin düzenleyici yazma işlemi. Kalın veya italik bir kelimeden sonra yazmaya devam ettiğinizde bu biçimlendirme devam eder. ExpandPolicy.AtEnd aralığının sonuna yazılan metnin stili devraldığını, başına yazılan metnin ise stilinin olmadığını gösteren animasyon.
ExpandPolicy.AtBoth start veya end konumunda yazılan metin, aralığı genişletir ve stili devralır. Her iki kenardan da büyümesi gereken etkin vurgular veya sürekli biçimlendirilmiş bloklar. ExpandPolicy.AtBoth aralığının başında veya sonunda yazılan metnin stili devraldığını gösteren animasyon.

SpanStyle ile karakter biçimlendirmesi uygulama

Arabellekteki belirli bir TextRange öğesine metin rengi, arka plan vurgusu, FontWeight, FontStyle veya altı çizili gibi karakter düzeyinde biçimlendirme uygulamak için SpanStyle ile addStyle kullanın.

SpanStyle aralıkları için temel davranışlar

  • Katman sırası: Stiller, arabelleğe eklediğiniz sırayla uygulanır. Çakışan birden fazla SpanStyle aralığı, aynı özelliği farklı değerlerle ayarlarsa daha sonra eklenen stil en üstte oluşturulur.
  • Boş olmayan aralık koşulu: start < end olacak şekilde daraltılmamış bir aralık iletmeniz gerekir. 0 uzunluğundaki daraltılmış bir seçimde addStyle çağrıldığında hiçbir etki olmaz ve ters çevrilmiş veya sınırların dışındaki dizinler iletildiğinde IllegalArgumentException hatası oluşur.

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

Kullanıcı yazarken kalın ve italik kırmızı biçimlendirmeyi dinamik olarak uygulayan bir TextField.
Şekil 1. Kullanıcı girişine dinamik olarak kalın ve italik kırmızı biçimlendirme uygulanıyor.

ParagraphStyle ile blok biçimlendirme uygulama

Bir paragraf aralığına textAlign, lineHeight, textDirection veya textIndent gibi blok düzeyinde yapısal biçimlendirme uygulamak için addStyle yöntemini ParagraphStyle ile kullanın.

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

ParagraphStyle kullanarak paragraf hizalamasını başlangıç, orta ve son arasında değiştiren bir TextField.
Şekil 2. ParagraphStyle tuşunu kullanarak paragraf hizalamasını başlangıç, orta ve son arasında değiştirme.

ParagraphStyle ile ilgili kaçınılması gereken hatalar

ParagraphStyle, metin bloklarının tamamına uygulandığından aşağıdaki senaryolarda kullanmaktan kaçının:

  • Bitişik paragraflara genişletme: Kullanıcı düzenlemelerinden sonra bitişik ParagraphStyle aralıklarının çakışmaması için ExpandPolicy değerini dikkatli bir şekilde ayarlayın. Örneğin, [0, 5) ExpandPolicy.AtEnd'i, [5, 10) ise ExpandPolicy.AtStart'yi kullanıyorsa 5 dizinine metin eklemek her iki stili de genişleterek çakışmaya neden olur ve IllegalArgumentException hatası verir.
  • İç içe yerleştirilmiş ParagraphStyle aralıkları: Tamamen iç içe yerleştirilmiş aralıklar (ör. [0, 10) ve [2, 8)) AnnotatedString içinde geçerli olsa da metin alanında ParagraphStyle aralıklarını iç içe yerleştirmekten kaçının. Düzen, iç içe yerleştirilmiş aralıkları ayrı paragraflara böler ve sonraki düzenlemeler, iç içe yerleştirilmiş aralıkları yanlışlıkla kısmi bir çakışmaya dönüştürebilir.

Bir stili TrackedRange ile yerinde güncelleme

addStyle işlevini TextRange ve ExpandPolicy ile çağırmak, TrackedRange<SpanStyle> veya TrackedRange<ParagraphStyle> olabilen bir TrackedRange herkese açık anahtarı döndürür. TrackedRange, TextFieldBuffer içindeki bir stil aralığına dinamik bir referanstır. Metin eklendikçe, silindikçe veya değiştirildikçe start ve end karakter ofsetlerini otomatik olarak günceller.

Yerinde değiştirilebilirlik

Mevcut bir stili güncellemek veya sınırlarını ayarlamak için removeStyle ve addStyle yöntemlerini çağırmanız gerekmez. TextFieldBuffer kapsamı içinde TrackedRange, aralığı yerinde güncelleyen ve diğer stillere göre orijinal katman sırasını koruyan uzantı özellikleri sağlar:

  • TrackedRange<SpanStyle>.spanStyle: TrackedRange<SpanStyle> üzerindeki SpanStyle yükünü okur veya değiştirir.
  • TrackedRange<ParagraphStyle>.paragraphStyle: TrackedRange<ParagraphStyle> üzerindeki ParagraphStyle yükünü okur veya değiştirir.
  • TrackedRange<*>.textRange: Mevcut TextRange sınırlarını okur veya günceller. Daraltılmış, ters çevrilmiş veya sınırların dışında bir aralık ayarlandığında IllegalArgumentException hatası döndürülür.
  • TrackedRange<*>.expandPolicy: ExpandPolicy değerini yerinde okur veya günceller.
  • TrackedRange<*>.isValid: Stil aralığı arabellekte hâlâ etkinse true, düzenlemeler nedeniyle uzunluğu 0 değerine düşürüldüyse veya kaldırıldıysa false değerini döndürür.

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

Kapsam sınırlaması ve yaşam döngüsü kuralları

TrackedRange örnekleriyle çalışırken aşağıdaki kapsam ve yaşam döngüsü kurallarını göz önünde bulundurun:

  • TextFieldBuffer ile sınırlı: TrackedRange uzantı mülkleri yalnızca, herkese açık kullanıcı adının oluşturulduğu veya sorgulandığı TextFieldBuffer kapsamında bulunur.
  • Daraltma sırasında otomatik budama: Kullanıcı düzenlemeleri, bir TrackedRange tarafından kapsanan tüm karakterleri silerse uzunluğu 0 değerine küçülür, isValid false değerini döndürür ve stil, arabellekten kaldırılır.

Stilleri sorgulama ve kaldırma

Compose, kullanıcı arayüzünüz için arabelleği değiştirmenize veya durumu okumanıza bağlı olarak etkin stilleri incelemenin iki yolunu sunar:

  • TextFieldBuffer bloğunun içinde: Değiştirilebilir TrackedRange tutamaçları elde etmek için doğrudan TextFieldBuffer alıcısında getSpanStyles ve getParagraphStyles yöntemlerini çağırın.
  • Düzenleme bloğunun dışında: Onaylanan durumu yansıtan, sabit AnnotatedString.Range listeleri döndüren TextFieldState.textStyles'yi okuyun.

TextFieldBuffer içinde kesişen stilleri sorgulama

TextFieldBuffer.getSpanStyles ve TextFieldBuffer.getParagraphStyles, belirtilen TextRange ile kesişen tüm TrackedRange örneklerini, tampona eklenme zamanlarına göre sıralanmış olarak döndürür.

Örneğin, kullanıcının mevcut seçimini kapsayan stilleri incelemek veya güncellemek için state.edit içinde getSpanStyles(selection) sorgusu yapabilirsiniz:

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

Stil aralıkları, yarı açık bir matematiksel aralık [start, end) kullanır. Dokunma stilinin döndürülüp döndürülmeyeceği, bir seçimi mi yoksa tek bir imleç noktasını mı sorguladığınıza bağlıdır:

  • Boş olmayan bir seçim için [start, end):
    • Döndürülenler: Seçim aralığıyla çakışan, seçim aralığını içeren veya seçim aralığının içinde yer alan tüm stiller. Örneğin, [5, 15) sorgusu [0, 20), [8, 12) veya [0, 10) ile eşleşir.
    • Döndürülmez: Karakterlerle çakışmadan yalnızca dış sınır kenarlarına dokunan stiller. Örneğin, [5, 15) sorgusu [0, 5) ve [15, 25) sorgularını hariç tutar.
  • Daraltılmış imleç için [index, index]:
    • Döndürülen: İmleci içeren veya imleç konumunda başlayan tüm stiller. Örneğin, 10 değerindeki bir imleç [0, 20) ve [10, 20) ile eşleşir.
    • Döndürülmedi (bitiş kuralına dokunuyor): İmleç konumunda biten tüm stiller. Örneğin, 10 değerindeki bir imleç [0, 10) değerini hariç tutar.

removeStyle ile stilleri kaldırın veya açıp kapatın

TextFieldBuffer öğesinden belirli bir stil aralığını silmek için removeStyle işlevine bir TrackedRange tutamacı iletin.

removeStyle, TrackedRange bulunup kaldırıldıysa true, zaten kaldırılmışsa veya farklı bir arabelleğe aitse false değerini döndürür. Yalnızca hedeflenen TrackedRange tutamacını kaldırır ve diğer çakışan stilleri olduğu gibi bırakır.

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

Seçili kelimeler için kalın biçimlendirmeyi açıp kapatan bir TextField.
Şekil 3. Metni kalınlaştırma ve kalınlaştırmayı kaldırma arasında geçiş yapma

Zengin metni ayıklama veya serileştirme

Biçimlendirilmiş girişi salt okunur bir Text composable'da görüntülemek veya bir arka uç hizmetine serileştirmek için düzenleme bloğunun dışında TextFieldState.textStyles değerini okuyun.

state.textStyles.getSpanStyles ve state.textStyles.getParagraphStyles, doğrudan AnnotatedString oluşturucuya iletebileceğiniz List<AnnotatedString.Range<SpanStyle>> ve List<AnnotatedString.Range<ParagraphStyle>> değerlerini döndürür:

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

Jetchat'teki eksiksiz örnek

Satır içi karakter stilini (kalın, italik, altı çizili, üstü çizili ve renkli) birleştiren eksiksiz bir uygulama için GitHub'daki Jetchat örneğine bakın:

  • RichTextState.kt: TextFieldState stil sorgularını, span açma/kapatma, kırpma veya seçim sınırları boyunca bölme ve daha fazlasını kapsar.
  • UserInput.kt: RichTextState uygulamasını zengin metin biçimlendirme araç çubuğuna ve mesaj giriş alanına bağlar.
Mesaj giriş çubuğunda zengin metin biçimlendirmesini gösteren Jetchat örnek uygulaması.
Şekil 4. Jetchat'te zengin metin düzenleme