سبک ورودی نوشتار غنی‌شده

ورودی نوشتار غنی‌شده به کاربران اجازه می‌دهد آنچه را تایپ می‌کنند قالب‌بندی کنند، ازجمله پررنگ کردن کلمه، افزودن عنوان، تراز کردن پاراگراف، یا درج کردن @mention برجسته‌شده. در Compose، این را روی فیلدهای نوشتاری مبتنی بر وضعیت می‌سازید (BasicTextField و TextField). سبک‌ها در TextFieldState در کنار نوشتار قرار دارند، بنابراین در ویرایش‌ها، تغییرات انتخاب، و ترکیب‌بندی‌های مجدد باقی می‌مانند و با تایپ کردن، حذف کردن، و جای‌گذاری کردن کاربر همگام می‌مانند.

این صفحه نشان می‌دهد که چگونه می‌توان سبک‌ها را در TextFieldState بااستفاده از TextFieldBuffer اعمال، به‌روزرسانی، پُرسمان، استخراج، و حذف کرد، و چگونه می‌توان یک نوارابزار قالب‌بندی را از TextFieldState.textStyles هدایت کرد.

اعمال سبک‌های درون‌خطی و پاراگراف

می‌توانید قالب‌بندی سطح نویسه و سطح پاراگراف را در هر TextFieldBuffer دامنه گیرنده، ازجمله TextFieldState.edit، InputTransformation.transformInput، و OutputTransformation.transformOutput اعمال کنید.

کنترل گسترش مرز با ExpandPolicy

وقتی addStyle را فراخوانی می‌کنید، می‌توانید ExpandPolicy را ارسال کنید تا تعریف کنید که محدوده سبک‌دار وقتی کاربر نویسه‌های جدید را مستقیماً در مرز start یا end آن درج می‌کند، چگونه رفتار کند:

  • درج در داخل (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 که به‌طور پویا قالب‌بندی قرمز پررنگ و ایتالیک را هنگام تایپ کاربر اعمال می‌کند.
شکل ۱. اعمال کردن قالب‌بندی قرمز پررنگ و مورب به درونداد کاربر به‌صورت پویا.

اعمال قالب‌بندی بلوک با 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 تغییر می‌دهد.
شکل ۲. تغییر تراز پاراگراف بین شروع، مرکز، و پایان بااستفاده از 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 را برمی‌گرداند، یا اگر ویرایش‌ها باعث شده باشد طول آن به 0 کاهش یابد یا اگر برداشته شده باشد، false را برمی‌گرداند.

// 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 که قالب‌بندی پررنگ را برای کلمات انتخاب‌شده روشن و خاموش می‌کند.
شکل ۳.‏ تغییر وضعیت بین پررنگ کردن و پررنگ نکردن نوشتار.

استخراج یا سریال‌سازی نوشتار غنی‌شده

برای نمایش ورودی قالب‌بندی‌شده در 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 که قالب‌بندی متن غنی را در نوار ورودی پیام نشان می‌دهد.
شکل ۴. ویرایش نوشتار غنی‌شده در Jetchat.