تنسيق إدخال النص المنسَّق

يتيح إدخال النص المنسَّق للمستخدمين تنسيق ما يكتبونه، بما في ذلك جعل كلمة غامقة أو إضافة عنوان أو محاذاة فقرة أو إدراج @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.InsideOnly بدون نمط
ExpandPolicy.AtStart يأخذ النص المكتوب في start النمط، ويظل النص المكتوب في end بدون نمط. يجب أن يعتمد النص المكتوب مباشرةً أمام النص المنسَّق التنسيق نفسه أيضًا. صورة متحركة تعرض نصًا مكتوبًا في بداية نطاق ExpandPolicy.AtStart يرث النمط بينما يظل النص المكتوب في النهاية بدون نمط.
ExpandPolicy.AtEnd يأخذ النص المكتوب في end النمط، ويظل النص المكتوب في start بدون نمط. الكتابة العادية في محرِّر النصوص المنسّقة، حيث يؤدي الاستمرار في الكتابة بعد كلمة غامقة أو مائلة إلى مواصلة هذا التنسيق. صورة متحركة تعرض نصًا مكتوبًا في نهاية نطاق ExpandPolicy.AtEnd يرث النمط، بينما يظل النص المكتوب في البداية بدون نمط.
ExpandPolicy.AtBoth يؤدي النص المكتوب في start أو end إلى توسيع النطاق واكتساب النمط. مقتطفات نشطة أو مجموعات منسَّقة مستمرة يجب أن تتوسّع من أيّ من الحواف صورة متحركة تعرض نصًا مكتوبًا في بداية أو نهاية نطاق ExpandPolicy.AtBoth يرث النمط.

تطبيق تنسيق الأحرف باستخدام 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
                )
            }
        }
    }
)

حقل TextField يطبّق ديناميكيًا تنسيقًا غامقًا ومائلاً باللون الأحمر أثناء كتابة المستخدم.
الشكل 1. تطبيق تنسيق أحمر غامق ومائل على البيانات التي أدخلها المستخدم ديناميكيًا

تطبيق تنسيق الفقرة باستخدام 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
        )
    }
}

‫TextField لتبديل محاذاة الفقرة بين البداية والوسط والنهاية باستخدام ParagraphStyle
الشكل 2. تبديل محاذاة الفقرة بين البداية والوسط والنهاية باستخدام 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
            )
        }
    }
}

‫TextField يفعّل تنسيق النص الغامق ويوقفه للكلمات المحدّدة.
الشكل 3. التبديل بين جعل النص غامقًا أو عاديًا

استخراج النص المنسَّق أو تسلسله

لعرض الإدخال المنسَّق في دالة مركّبة 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 بشريط أدوات تنسيق النص المنسّق وحقل إدخال الرسالة.
نموذج تطبيق Jetchat يعرض تنسيق النصوص المنسّقة في شريط إدخال الرسائل
الشكل 4. تعديل النصوص المنسّقة في Jetchat