עיצוב של קלט טקסט עשיר

הזנת טקסט עשיר מאפשרת למשתמשים לעצב את מה שהם מקלידים, כולל הדגשת מילה, הוספת כותרת, יישור פסקה או הוספת @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 שמחיל באופן דינמי עיצוב מודגש ונטוי באדום בזמן שהמשתמש מקליד.
איור 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: קריאה או החלפה של המטען הייעודי (payload) של SpanStyle במקום ב-TrackedRange<SpanStyle>.
  • ‫TrackedRange<ParagraphStyle>.paragraphStyle: קריאה או החלפה של המטען הייעודי (payload) של 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 כדי לקבל נקודות אחיזה (handles) ניתנות לשינוי של 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
            )
        }
    }
}

שדה טקסט שבו אפשר להפעיל ולהשבית את העיצוב המודגש של מילים נבחרות.
איור 3. הדגשה של טקסט או ביטול ההדגשה.

חילוץ או סריאליזציה של טקסט עשיר

כדי להציג קלט מעוצב ברכיב קומפוזבילי Text לקריאה בלבד, או כדי להעביר אותו לשירות לקצה העורפי, קוראים את TextFieldState.textStyles מחוץ לבלוק העריכה.

הפונקציות state.textStyles.getSpanStyles ו-state.textStyles.getParagraphStyles מחזירות את List<AnnotatedString.Range<SpanStyle>> ו-List<AnnotatedString.Range<ParagraphStyle>>, שאפשר להעביר ישירות ל-constructor ‏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, החלפת מצב של span, חיתוך או פיצול של גבולות הבחירה ועוד.
  • UserInput.kt: מקשרת את RichTextState לסרגל הכלים של עיצוב הטקסט העשיר ולשדה להזנת קלט.
האפליקציה לדוגמה Jetchat שמדגימה עיצוב טקסט עשיר בסרגל להזנת הודעות.
איור 4. עריכת טקסט עשיר ב-Jetchat.