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



