يتيح إدخال النص المنسَّق للمستخدمين تنسيق ما يكتبونه، بما في ذلك جعل كلمة غامقة أو إضافة عنوان أو محاذاة فقرة أو إدراج @mention مميّز.
في Compose، يمكنك إنشاء ذلك فوق حقول النص المستندة إلى الحالة
(BasicTextField وTextField). تظهر الأنماط في
TextFieldState إلى جانب النص، لذا تظلّ متوفّرة بعد التعديلات وتغييرات
الاختيار وإعادة الإنشاء، وتظلّ متزامنة أثناء كتابة المستخدم وحذفه
ولصقه.
توضّح هذه الصفحة كيفية تطبيق الأنماط وتعديلها وطلبها واستخراجها وإزالتها في
TextFieldState باستخدام TextFieldBuffer، وكيفية تشغيل شريط أدوات التنسيق
من TextFieldState.textStyles.
تطبيق أنماط الفقرات والأنماط المضمّنة
يمكنك تطبيق التنسيق على مستوى الحرف والفقرة داخل أي نطاق استقبال TextFieldBuffer، بما في ذلك TextFieldState.edit وInputTransformation.transformInput وOutputTransformation.transformOutput.
التحكّم في توسيع الحدود باستخدام ExpandPolicy
عند استدعاء addStyle، يمكنك تمرير ExpandPolicy لتحديد سلوك النطاق المنسَّق عندما يُدرج المستخدم أحرفًا جديدة مباشرةً عند حدوده start أو end:
- Insertion inside
(start, end): يوسّع هذا الخيار دائمًا النطاق المنسَّق. - الإدراج قبل
start: يؤدي دائمًا إلى إزاحة كل منstartوendإلى اليمين. - الإدراج مباشرةً في
startأوend: يتم التحكّم فيه من خلالExpandPolicyالذي تحدّده.
يوضّح الجدول التالي خيارات ExpandPolicy المتاحة وحالات استخدامها الشائعة:
| السياسة | سلوك الحدود | حالات الاستخدام المقترَحة | مثال |
|---|---|---|---|
ExpandPolicy.InsideOnly |
يظل النص المكتوب في start أو end خارج النطاق المنسَّق. |
الرموز المميزة المنفصلة، مثل @mentions أو #hashtags أو الأقراص في الخلفية، حيث لا يجب أن تكتسب الكتابة بجانب العلامة لون خلفية العلامة. |
![]() |
ExpandPolicy.AtStart |
يأخذ النص المكتوب في start النمط، ويظل النص المكتوب في end بدون نمط. |
يجب أن يعتمد النص المكتوب مباشرةً أمام النص المنسَّق التنسيق نفسه أيضًا. | ![]() |
ExpandPolicy.AtEnd |
يأخذ النص المكتوب في end النمط، ويظل النص المكتوب في start بدون نمط. |
الكتابة العادية في محرِّر النصوص المنسّقة، حيث يؤدي الاستمرار في الكتابة بعد كلمة غامقة أو مائلة إلى مواصلة هذا التنسيق. | ![]() |
ExpandPolicy.AtBoth |
يؤدي النص المكتوب في start أو end إلى توسيع النطاق واكتساب النمط. |
مقتطفات نشطة أو مجموعات منسَّقة مستمرة يجب أن تتوسّع من أيّ من الحواف | ![]() |
تطبيق تنسيق الأحرف باستخدام SpanStyle
استخدِم addStyle مع SpanStyle لتطبيق تنسيق على مستوى الأحرف،
مثل لون النص أو تمييز الخلفية أو FontWeight أو FontStyle
أو وضع خط تحت TextRange معيّن في المخزن المؤقت.
السلوكيات الرئيسية لنطاقات SpanStyle
- ترتيب الطبقات: يتم تطبيق الأنماط بالترتيب الذي تضيفها به إلى المخزن المؤقت. إذا
تم ضبط السمة نفسها بقيم مختلفة في نطاقات
SpanStyleمتعددة متداخلة، سيتم عرض النمط الذي تمت إضافته لاحقًا في الأعلى. - متطلبات النطاق غير الفارغ: يجب تمرير نطاق غير مصغّر حيث
start<end. إنّ استدعاءaddStyleعلى تحديد مصغّر بطول0ليس له أي تأثير، ويؤدي تمرير فهارس معكوسة أو خارج الحدود إلى ظهورIllegalArgumentException.
TextField( state = rememberTextFieldState(), inputTransformation = InputTransformation { for (i in 0 until changes.changeCount) { val range = changes.getRange(i) if (!range.collapsed) { addStyle( spanStyle = SpanStyle( fontWeight = FontWeight.ExtraBold, fontStyle = FontStyle.Italic, color = Color.Red, ), range = range, expandPolicy = ExpandPolicy.AtEnd ) } } } )
تطبيق تنسيق الفقرة باستخدام ParagraphStyle
استخدِم addStyle مع ParagraphStyle لتطبيق تنسيق هيكلي على مستوى الكتلة، مثل textAlign أو lineHeight أو textDirection أو textIndent على نطاق فقرة.
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أخطاء يجب تجنُّبها في ParagraphStyle
نظرًا لأن ParagraphStyle ينطبق على مجموعات كاملة من النص، تجنب استخدامه في
السيناريوهات التالية:
- التوسيع إلى الفقرات المجاورة: اضبط
ExpandPolicyبعناية حتى لا تتداخل نطاقاتParagraphStyleالمجاورة بعد تعديلات المستخدم. على سبيل المثال، إذا كان[0, 5)يستخدمExpandPolicy.AtEndو[5, 10)يستخدمExpandPolicy.AtStart، يؤدي إدراج نص في الفهرس5إلى توسيع كلا النمطين إلى تداخل ويؤدي إلى ظهورIllegalArgumentException. - تداخل نطاقات
ParagraphStyle: على الرغم من أنّ النطاقات المتداخلة بالكامل (مثل[0, 10)و[2, 8)) صالحة فيAnnotatedString، تجنَّب تداخل نطاقاتParagraphStyleفي حقل نصي. يقسّم التنسيق النطاقات المتداخلة إلى فقرات منفصلة، ويمكن أن تؤدي التعديلات اللاحقة إلى تحويل النطاقات المتداخلة عن غير قصد إلى تداخل جزئي.
تعديل نمط في مكانه باستخدام TrackedRange
يؤدي استدعاء addStyle باستخدام TextRange وExpandPolicy إلى عرض مؤشر
TrackedRange، والذي يمكن أن يكون TrackedRange<SpanStyle> أو
TrackedRange<ParagraphStyle>. TrackedRange هو مرجع ديناميكي
لفاصل نمط في TextFieldBuffer يتم تعديله تلقائيًا
وإزاحة الأحرف start وend عند إدراج النص أو حذفه أو استبداله حوله.
قابلية التغيير في المكان
ليس عليك استدعاء removeStyle وaddStyle لتعديل نمط حالي
أو ضبط حدوده. داخل نطاق TextFieldBuffer، يوفّر TrackedRange
خصائص الإضافة التي تعدّل الفاصل الزمني في مكانه مع الحفاظ على
ترتيب الطبقات الأصلي بالنسبة إلى الأنماط الأخرى:
TrackedRange<SpanStyle>.spanStyle: يقرأ حمولةSpanStyleأو يستبدلها في مكانها علىTrackedRange<SpanStyle>.-
TrackedRange<ParagraphStyle>.paragraphStyle: لقراءة حمولةParagraphStyleأو استبدالها فيTrackedRange<ParagraphStyle> -
TrackedRange<*>.textRange: لقراءة حدودTextRangeالحالية أو تعديلها في مكانها يؤدي ضبط نطاق مطوي أو معكوس أو خارج الحدود إلى عرضIllegalArgumentException. -
TrackedRange<*>.expandPolicy: لقراءةExpandPolicyأو تعديله في مكانه -
TrackedRange<*>.isValid: تعرضtrueإذا كان الفاصل الزمني للنمط لا يزال نشطًا في المخزن المؤقت، أوfalseإذا تسببت التعديلات في تقليص طوله إلى0أو إذا تمت إزالته.
// 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) }
قواعد حصر النطاق ومراحل النشاط
يُرجى مراعاة قواعد النطاق ومراحل النشاط التالية عند العمل مع مثيلات TrackedRange:
- محصورة في
TextFieldBuffer: لا تتوفّر سمات الإضافةTrackedRangeإلا ضمن نطاقTextFieldBufferحيث تم إنشاء الاسم المعرِّف أو الاستعلام عنه. - التقليص التلقائي عند التصغير: إذا أدّت تعديلات المستخدم إلى حذف جميع الأحرف التي يغطيها
TrackedRange، يتقلّص طوله إلى0، وتُرجعisValidالقيمةfalse، وتتم إزالة النمط من المخزن المؤقت.
الاستعلام عن الأنماط وإزالتها
توفّر أداة Compose طريقتَين لفحص الأنماط النشطة، وذلك حسب ما إذا كنت تغيّر حالة المخزن المؤقت أو حالة القراءة لواجهة المستخدم:
- داخل كتلة
TextFieldBuffer: يمكنك استدعاءgetSpanStylesوgetParagraphStylesمباشرةً على جهاز استقبالTextFieldBufferللحصول على مؤشراتTrackedRangeقابلة للتغيير. - خارج مجموعة التعديل: يمكنك قراءة
TextFieldState.textStylesالتي تعرض قوائمAnnotatedString.Rangeغير قابلة للتغيير تعكس الحالة النهائية.
الاستعلام عن الأنماط المتقاطعة في TextFieldBuffer
تعرض الدالتان TextFieldBuffer.getSpanStyles وTextFieldBuffer.getParagraphStyles جميع مثيلات TrackedRange التي تتقاطع مع TextRange المحدّد، ويتم ترتيبها حسب وقت إضافتها إلى المخزن المؤقت.
على سبيل المثال، يمكنك الاستعلام عن getSpanStyles(selection) داخل state.edit لفحص أو تعديل الأنماط التي تغطي اختيار المستخدم الحالي:
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 } // ... }
تستخدم فواصل الأنماط نطاقًا رياضيًا شبه مفتوح [start, end). يعتمد عرض نمط ملامس على ما إذا كنت تستعلم عن تحديد أو نقطة مؤشر واحدة:
- بالنسبة إلى التحديد غير الفارغ
[start, end):- النتيجة: أي نمط يتداخل مع نطاق الاختيار أو يتضمّنه أو يقع داخله على سبيل المثال، يتطابق طلب البحث
[5, 15)مع[0, 20)أو[8, 12)أو[0, 10). - الأنماط التي لا يتم عرضها: الأنماط التي تلامس حواف الحدود الخارجية فقط بدون
تداخل الأحرف على سبيل المثال، يستبعد طلب البحث
[5, 15)[0, 5)و[15, 25).
- النتيجة: أي نمط يتداخل مع نطاق الاختيار أو يتضمّنه أو يقع داخله على سبيل المثال، يتطابق طلب البحث
- بالنسبة إلى المؤشر المصغّر
[index, index]:- النتيجة: أي نمط يحتوي على المؤشر أو يبدأ من موضع المؤشر على سبيل المثال، يتطابق المؤشر في
10مع[0, 20)و[10, 20). - عدم الإرجاع (قاعدة نهاية اللمس): أي نمط ينتهي عند موضع المؤشر على سبيل المثال، يستبعد المؤشر عند
10السمة[0, 10).
- النتيجة: أي نمط يحتوي على المؤشر أو يبدأ من موضع المؤشر على سبيل المثال، يتطابق المؤشر في
إزالة الأنماط أو تبديلها باستخدام removeStyle
مرِّر مؤشر TrackedRange إلى removeStyle لحذف الفاصل الزمني المحدّد للنمط من TextFieldBuffer.
تعرض الدالة removeStyle القيمة true إذا تم العثور على TrackedRange وإزالته، أو
false إذا سبق أن تمت إزالته أو كان ينتمي إلى مخزن مؤقت مختلف. وتزيل فقط الاسم المعرِّف المستهدَف على TrackedRange، مع ترك الأنماط المتداخلة الأخرى كما هي.
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 ) } } }
استخراج النص المنسَّق أو تسلسله
لعرض الإدخال المنسَّق في دالة مركّبة Text للقراءة فقط، أو لتحويله إلى تسلسل
لخدمة خلفية، يمكنك قراءة TextFieldState.textStyles خارج كتلة التعديل.
تعرض الدالتان state.textStyles.getSpanStyles وstate.textStyles.getParagraphStyles السمتَين List<AnnotatedString.Range<SpanStyle>> وList<AnnotatedString.Range<ParagraphStyle>>، ويمكنك تمريرهما مباشرةً إلى الدالة الإنشائية AnnotatedString:
fun TextFieldState.toAnnotatedString(): AnnotatedString { val fullRange = TextRange(0, text.length) return AnnotatedString( text = text.toString(), spanStyles = textStyles.getSpanStyles(fullRange), paragraphStyles = textStyles.getParagraphStyles(fullRange) ) }
مثال كامل في Jetchat
للاطّلاع على عملية تنفيذ كاملة تجمع بين أنماط الأحرف المضمّنة (الخط الغامق والمائل والمسطر والمشطوب واللون)، يمكنك الاطّلاع على نموذج Jetchat على GitHub:
-
RichTextState.kt: يدمج طلبات البحث عن نمطTextFieldState، وتبديل الامتداد، والاقتطاع أو التقسيم عبر حدود الاختيار، والمزيد. -
UserInput.kt: يربطRichTextStateبشريط أدوات تنسيق النص المنسّق وحقل إدخال الرسالة.



