מהנדס תוכנה בכיר
התהליך של פיתוח עריכת טקסט אמינה ב-Compose הסתיים בשכתוב מלא של BasicTextField. המעבר הזה לארכיטקטורה מבוססת-מאגר נתונים זמני פתר את בעיית מרוץ התהליכים של ה-IME אחת ולתמיד. בסשן הזה נסביר למה היה צריך להחליף את סנכרון הקלט הקודם ואיך TextFieldState שומר על סנכרון העדכונים של המקלדת. משם, נצלול לעומק העריכה המעוצבת כדי לחקור את המכניקה של TrackedRange, את עלויות הביצועים של בלוקים של עריכה ואת המלכודות בעולם האמיתי מאחורי גבולות הסגנון, לפני שנציץ מוקדם ברינדור של Markdown.
TextFieldState מחליף את עומסי היתר שמבוססים על קריאות חוזרות (callbacks) ב-Compose. הוא מתעדכן באופן סינכרוני עם ההקלדה של המשתמש, וכך מונע בעיות תזמון עם מקלדות וירטואליות ושינויים מיותרים בהרכבה.TextFieldBuffer. אתם יכולים ליירט עריכות לפני שהן מתבצעות או לעצב את הטקסט המוצג בלי לחשב באופן ידני את מיפוי ההיסט.TextFieldState תלויה בצרכים של הארכיטקטורה שלכם. אפשר לשמור אותו באופן מקומי ב-composable או לאחסן אותו ישירות ב-ViewModel כדי לגשת ללוגיקה העסקית.TextFieldBuffer. אתם יכולים להחיל סגנונות עם אפשרויות ExpandPolicy שניתנות להתאמה אישית, וטווחי הסגנונות הקיימים מתעדכנים באופן אוטומטי כשהמשתמשים עורכים את הטקסט.תמליל
שלום לכולם,
תודה שהצטרפת לשיחה שלנו.
קוראים לי אנסטסיה, ואיתי נמצא הקולגה שלי, האויו.
היום נראה לכם איך ליצור חוויית עריכת טקסט אמינה.
לא משנה אם אתם יוצרים מסך התחברות פשוט, סרגל חיפוש תוך כדי הקלדה או אפליקציית הודעות מורכבת, שדות הטקסט הם הדרך העיקרית שבה המשתמשים יוצרים אינטראקציה עם האפליקציות שלכם ל-Android.
אז רובכם כנראה כבר משתמשים בשדות טקסט של Material או בשדות טקסט עם קווי מתאר.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())יש גם BasicTextField אם אתם יוצרים מערכת עיצוב משלכם, או אם אתם צריכים תיבת עריכה פשוטה.
BasicTextField(state = rememberTextFieldState())יש גם את SecureTextField, שנועד במיוחד להזנת סיסמאות.
SecureTextField(state = rememberTextFieldState())ו-BasicTextField הוא רכיב ללא סגנון, והוא למעשה מפעיל את כל שדות הטקסט האחרים ב-Compose.
אז אולי שמתם לב שבשנים האחרונות יש לנו שתי צורות API במקביל, אחת שמקבלת ערך וקריאה חוזרת (callback) של שינוי ערך, והשנייה שמקבלת אובייקט TextFieldState.
@Composable
public fun BasicTextField(
state: TextFieldState,
)אז על מה אנחנו ממליצים?
מומלץ מאוד להשתמש בעומס היתר של TextFieldState.
בשיחה הזו נדון ביתרונות של הממשק מבחינת API, תכונות וביצועים.
הוא יציב מאז גרסה 1.7 של פיתוח נייטיב.
לגבי עומסי היתר של הקריאות החוזרות (callbacks), אנחנו מוציאים אותם משימוש בהדרגה ב-Compose 1.13 ב-BasicTextField וב-material wrappers.
@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unitזה אומר שאנחנו לא מתכוונים להוסיף תכונות חדשות או לתקן באגים, אלא אם מדובר בבעיות קריטיות שקשורות לאבטחה או לנגישות.
הערך חסר המצב בשינוי הערך של עומס היתר עבד ממש טוב בקלטים סינכרוניים.
אבל ברגע ששילבתם אותו עם זרמים אסינכרוניים, למשל StateFlow, הוא יצר בעיות תזמון עם ה-IME או במונח הטכני, עבור המקלדת הווירטואלית.
זה גרם להשמטת תווים, לשכפול תווים ולקפיצות של הסמן, כמו שאפשר לראות בהקלטה הזו.
כדי לפתור את הבעיה הזו מהיסוד, השקנו את TextFieldState.
הוא מתעדכן באופן סינכרוני כשהמשתמש מקליד, בלי לחכות לסיבובים האסינכרוניים האלה, וזה בדיוק מה שנדרש ל-IME.
בנוסף לטקסט לקריאה בלבד, ל-TextFieldState יש גם מאפיינים אחרים שיוצרים סוג של סשן עריכה פעיל.
כלומר, בחירה, מצב ביטול וביצוע מחדש, וגם סגנונות span וסגנונות פסקה לעריכת טקסט מעוצב, עוד על כך בהמשך השיחה.
המאפיינים הפנימיים של TextFieldState מגובים על ידי מערכת Snapshot של Compose.
כלומר, כשקוראים את הטקסט או את הבחירה מהאובייקט TextFieldState במקום כלשהו ב-Compose, ההיקף הספציפי שממנו קוראים את הטקסט או הבחירה נרשם לעדכונים.
הוא מאפשר ל-BasicTextField לקרוא את המצב רק בשלבי הפריסה או הציור, ובשילוב עם כמה דברים נוספים הוא מבטל את ה-recomposition המיותר כשהמשתמש מקליד.
יחד עם כמה שינויים אחרים, הוא סיפק שיפור משמעותי בביצועים של עומס היתר ב-TextFieldState.
אם הטקסט של TextFieldState הוא מאפיין לקריאה בלבד, איך אפשר לבצע שינויים פרוגרמטיים בשדה להזנת קלט?
כאן TextFieldBuffer יכול לעזור.
זהו אובייקט שניתן לשינוי שמספק ממשקי API לביצוע שינויים.
לכן, כשרוצים לבצע את השינוי באופן פרוגרמטי, צריך להפעיל את פונקציית העריכה של TextFieldState.
ובתוך ה-lambda הזה, מקבלים TextFieldBuffer.
TextFieldBuffer חושף כמה פונקציות שמאפשרות להוסיף, להחליף ולמחוק תווים לפי הצורך.
טיפ קטן לגבי העריכה: כדאי להימנע מלהפעיל את הפונקציות האלה כמה פעמים ברצף.
// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }במקום זאת, כדאי לאסוף את כל הפעולות לקריאת עריכה אחת.
// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}כך, Compose יוצר רק מופע אחד של TextFieldBuffer, משנה אותו במקום, ואז מסנכרן אותו עם ה-IME ועם Snapshot System פעם אחת.
טיפ נוסף: לפעולות האצווה הנפוצות ביותר שתצטרכו, יש לנו פונקציות הרחבה ב-TextFieldState.
fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}לכן, אתם לא צריכים לחשוב על איסוף הפעולות האלה באצווה.
פשוט מתקשרים לאחד מהם.
לדוגמה, clearText ו-setTextAndPlaceCursorAtEnd.
fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}אז מה לגבי סינון הקלט של המשתמש?
למשל, לאכוף מגבלת תווים או לאפשר רק ספרות.
ב-API הישן, הסינון ב-onValueChange פעל אחרי שהטקסט כבר נשלח.
onValueChange { newString ->
// full diffing new String vs old String
}אז דחייה או שינוי של הקלט הזה גרמו למאבק עם המקלדת.
בנוסף, הקריאה החוזרת (callback) של onValueChange נתנה לכם רק את המחרוזת הסופית.
כדי להבין מה השתנה, הייתם צריכים להשוות בין המחרוזות המלאות.
במקום זאת, בעומס היתר החדש, יש לנו טרנספורמציה של קלט.
@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}כך הוא מיירט את הקלט של המשתמש לפני שהקלט הזה מגיע למצב של שדה הטקסט, והוא נותן לכם את אותם ממשקי API של TextFieldBuffer.
ככה מטמיעים את הסינון של ספרות בלבד באמצעות טרנספורמציית הקלט, ובעצם פשוט לוקחים את השינוי.
// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}אתם בודקים אם יש תווים שהם לא ספרות, ואם כן, אתם דוחים את הקלט על ידי קריאה ל-revertAllChanges ב-TextFieldBuffer הזה.
היכולת העיקרית של טרנספורמציית הקלט הזו היא ChangeList.
כך, במקום להשוות בין המחרוזות המלאות, אתם יכולים לבדוק את טווחי העריכה המדויקים, ואז לשנות אותם במקום או לדחות את הקלט לחלוטין אם צריך.
Compose יסנכרן אוטומטית את המקלדת והסמן.
ולסיום, מה אם רוצים לעצב את הקלט באופן חזותי?
לדוגמה, אם אתם רוצים להוסיף סוגריים או מקפים למספר כרטיס אשראי או למספר טלפון.
אה, כן.
ואתם לא רוצים לשמור אותם במסד הנתונים שלכם, נכון?
בעבר השתמשתם ב-API של טרנספורמציה חזותית.
במקרה כזה, הייתם צריכים להטמיע מיפוי דו-כיווני מהקלט הגולמי לפלט החזותי, ואז בחזרה.
והיה ממש קשה לעשות את זה נכון, מה שלעיתים קרובות הוביל לחריגות בזמן ריצה.
עכשיו יש לנו טרנספורמציה של פלט, ואז, שוב, מאחורי הקלעים, היא מופעלת על ידי אותו TextFieldBuffer, כך שאתם מקבלים פחות או יותר את אותו API.
@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}אז בדוגמה הזו של מספר הטלפון, כדי להוסיף את המקפים האלה באינדקס ספציפי, פשוט קוראים לפונקציית ההוספה של TextFieldBuffer, והמערכת תחשב בשבילכם את מיפוי ההיסט הדו-כיווני.
// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}כך לא צריך לחשב ידנית את המיקום, ומספר הקריסות מצטמצם משמעותית.
עכשיו לחלק הכי חשוב: איך בפועל מעבירים את הנתונים, ואיפה מניפים את ה-TextFieldState?
בפועל, כמעט כל תרחיש לדוגמה נכלל באחת מהקטגוריות שאדבר עליהן.
ואם לא, בדרך כלל זה אומר שיש לכם ארכיטקטורה לא שגרתית, אבל הדוגמאות האלה עדיין יעזרו לכם בתהליך ההעברה.
אוקיי, אז המקרה הכי פשוט הוא לשמור את TextFieldState ממש בתוך הפונקציה הקומפוזבילית שבה נמצא שדה הטקסט הזה.
@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}זה קורה כששדה הטקסט הוא שדה להזנת קלט עצמאי, למשל שדה להזנת קוד הטבה, ואתם צריכים לקבל גישה לטקסט הזה רק כשהמשתמש, למשל, לוחץ על כפתור השליחה, נכון?
לכן, מודל התצוגה לא צריך ליירט את הקלט של המשתמש בכל שינוי.
אז פשוט קוראים ל-rememberTextFieldState באופן מקומי, ו-rememberTextFieldState גם עוזר לשחזר את המצב אחרי השבתת התהליך.
אחר כך משתמשים בטרנספורמציה של קלט לסינון, ומקבלים את הטקסט מהמצב הזה, אובייקט TextFieldState, וכשהמשתמש שולח, מעבירים אותו דרך הלמבדה.
אוקיי, מה אם רכיבים אחים בממשק המשתמש, כמו ספירת תווים או כפתור שליחה, צריכים גם לקרוא את הטקסט או לשנות אותו?
זה מביא אותנו למקרה שבו אתם מעבירים את האובייקט TextFieldState לפונקציה קומפוזבילית משותפת, פונקציה קומפוזבילית משותפת של אב קדמון.
@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}ומכיוון ש-TextFieldState מגובה על ידי מערכת תמונת המצב, העברה שלו דרך האב הקדמון לא מפעילה הרכבה מחדש של ההורים.
רק המקומות שבהם קראתם בפועל את הטקסט של המדינה יעודכנו.
אוקיי.
לכן, בתרחישים מורכבים של ממשק משתמש שבהם צריך לתאם את המצב של TextFieldState, הקלט והטרנספורמציה של הפלט באופן פנימי, במקום להעלות את הרמה שלהם בנפרד ולנסות לחבר אותם יחד בפונקציה הקומפוזבילית, כדאי להשתמש באובייקט של מאחסן מצב.
@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}נניח שאתם רוצים לטפל בהאשטאג שמופיע בתוך הטקסט.
TextFieldState מכיל טקסט גולמי ומיקום של הסמן.
טרנספורמציית הקלט אחראית לזיהוי האשטאג, ואפשר גם למחוק תג אטומי באמצעות טרנספורמציית הקלט.
טרנספורמציה של הפלט תוסיף סגנון חזותי נוסף ותעזור לכם למחוק את ההאשטאג.
וכולם ארוזים יחד באובייקט אחד של מאחסן מצב, שנקרא TagEditorState.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}ואז כולם יכולים להשתמש שוב בחישובי הביניים, כמו tagRanges במקרה הזה.
ואז פשוט מעבירים את האובייקט של מאחסן המצב הזה ל-composable.
@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}אוקיי, תרחיש שימוש נוסף הוא כשהלוגיקה העסקית שלכם צריכה גישה לטקסט הזה בשביל חיפוש תוך כדי הקלדה, או אימות מורכב כלשהו, אז צריך להעביר את שדה הטקסט הזה ל-ViewModel.
חשוב לא להכניס אובייקט של שדה טקסט שניתן לשינוי למצב ממשק משתמש של מחלקת נתונים שלא ניתנת לשינוי, כי הוא מועתק באמצעות שיטת העתקה ואז מופק אל StateFlow בכל שינוי.
data class FormUiState(val username: TextFieldState)TextFieldState הוא אובייקט שניתן לשינוי ופועל לאורך זמן, שמגובה על ידי מערכת Snapshot, ואובייקט העזר שלו לא משתנה אף פעם.
לכן, StateFlow לא ידע שהמשתמש מקליד באותו רגע, כי ההפניה נשארת זהה.
ואפילו גרוע מכך, אם יוצרים מופע חדש של מצב ממשק המשתמש ומקצים TextFieldState חדש, השדה להזנת קלט יאבד את כל המצב שלו.
למשל, בחירה, מיקום הסמן או היסטוריית הביטול או הביצוע מחדש.
במקום זאת, צריך להגדיר את TextFieldState כמאפיין ישירות ב-ViewModel.
class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}ואל תדאגו לגבי הכלל "אין ממשק משתמש ב-ViewModel" כאן, כי במקרה הזה, TextFieldState לא מפנה לאובייקט Android View או Context או למחזור החיים של Compose.
זהו אובייקט מאחסן מצב שמגובה בתמונת מצב בלבד.
אז במקרה הזה, מבחינה רעיונית זה לא שונה מ-StateFlow.
חשוב לזכור של-ViewModel אין rememberSaveable, אז צריך לחבר את TextFieldState.Saver לשדה הטקסט כדי לשרוד את השבתת התהליך ולשחזר את המצב שלו.
class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}עכשיו, מה אם לצוות שלכם יש כללי מודולים מחמירים שבהם ViewModels נמצאים רק במודולים של Kotlin טהור, ואתם לא יכולים להביא את התלות הזו של compose-foundation?
או למשל, אתם מעבירים מסך מדור קודם שהוא מורכב, ואתם לא יכולים לעדכן או לשנות את מצב ממשק המשתמש היום?
במקרה כזה, צריך להשתמש בגישה התואמת.
לכן, אתם שומרים את TextFieldState ב-composable כמקור אמת יחיד (SSOT) לשדה הטקסט הזה.
אבל אז, אתם משתמשים ב-LaunchedEffect עם snapshotFlow כדי להחזיר את השינויים בטקסט ל-ViewModel הקיים.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}וה-ViewModel שלכם נשאר ללא Compose. בנוסף, מכיוון שמאפייני המצב של ממשק המשתמש לא מזינים בחזרה את שדה הטקסט בכל שינוי, אתם עדיין נמנעים מהבאגים האלה של הלוך ושוב אסינכרוני.
כן, וצריך להיזהר ממלכודת נפוצה כאן.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}ברוב המקרים, כדאי להשמיט את האירוע הראשון.
כך אפשר למנוע שכפול מיותר של שאילתות חיפוש, או למנוע החלפה של ViewModel בערך הראשוני.
אוקיי, אז אם הלוגיקה שלכם צריכה להחזיר את השינויים האלה למצב, למשל, כשהמשתמש לוחץ על כפתור השליחה ואתם רוצים לנקות את הטקסט, אתם לא יכולים פשוט להגדיר מחרוזת ריקה ל-ViewModel של ViewModel.
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}במקום זאת, צריך להפיק אירוע טקסט ברור, ואז הפונקציה הקומפוזבילית שמכילה את האובייקט TextFieldState תקרא ל-clearText באובייקט הזה.
כדי לעזור לכם במעבר, כתבנו את המדריך הזה להעברת נתונים (מיגרציה).
אפשר לגשת אליו דרך הקישור הזה.
אנחנו גם עובדים על API נוסף כדי להקל על ההעברה ועל מעקב אחרי שינויים כלליים ב-TextFieldState, אז כדאי להמשיך לעקוב.
וכל מה שדיברנו עליו עד עכשיו, TextFieldState, TextFieldBuffer, טרנספורמציה של קלט ופלט, לא נוצרו רק לקלט של טקסט פשוט.
אלה הבסיס שמפעיל את קבוצת ממשקי ה-API החדשה שלנו לעריכת טקסט מעוצב.
עכשיו נעביר את רשות הדיבור להאויו, שיספר לכם עוד על כך.
טוב.
תודה.
שלום לכולם,
עכשיו אדבר על ממשקי API חדשים שהשקנו לעריכת טקסט מעוצב.
אז לפני שנתחיל להשתמש בממשקי ה-API החדשים, בואו נחזור אחורה ונראה איך העיצוב עובד בטקסט סטטי.
אז רובכם כבר מכירים את AnnotatedString ממש טוב.
val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)זו הדרך הסטנדרטית לעצב טקסט סטטי.
אנחנו משתמשים ב-buildAnnotatedString וקוראים ל-add-- call addStyle כדי להחיל את SpanStyle או את ParagraphStyle על טווחים נתונים מסוימים.
היא מתאימה לטקסט סטטי.
אז כשמסתכלים על עריכת טקסט מעוצב, המחשבה הראשונה שלנו הייתה, האם אנחנו יכולים פשוט להפוך את AnnotatedString לניתן לעריכה?
אבל מהר מאוד הבנו שזה לא כל כך פשוט.
AnnotatedString הוא מבנה נתונים שלא ניתן לשינוי.
אבל בעולם העריכה, המשתמש מקליד כל הזמן.
ובכל מקש – כל הקשה, אנחנו צריכים להקצות אובייקט AnnotatedString חדש.
ובכל AnnotatedString יש הרבה AnnotatedString.Range.
לכן צריך ליצור מחדש גם את האובייקטים האלה.
הוא לא מתאים לשינויים בהיקף וגורם לתקורה משמעותית בהקצאה.
למשל, בדוגמה הזו,
כשאנחנו מוסיפים את המילה באמצע, כל סגנון שאחריה משנה את הטווח שלו ומוקצה מחדש.
ברור שזה לא עובד כשיש הרבה סגנונות.
לכן, במקום זאת, הוספנו ממשקי API ייעודיים לעיצוב בתוך TextFieldBuffer שאנסטסיה הציגה קודם.
הם דומים מאוד לממשקי ה-API של הסגנון ב-AnnotatedString.
בואו נראה איך הם עובדים.
לכן, בדיוק כמו AnnotatedString.Builder, ל-TextFieldBuffer יש עכשיו את addStyle כדי להחיל את SpanStyle או את ParagraphStyle על טווח.
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>אפשר גם להעביר פרמטר ExpandPolicy, שקובע איך הסגנון מתנהג כשמקלידים טקסט ממש בגבולות הסגנון.
נבדוק את זה עוד רגע.
קודם כל, בואו נראה איך פועל המעקב אחרי הסגנון.
val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}אחרי שמוסיפים סגנון ל-TextFieldBuffer, הטווח שלו מתעדכן אוטומטית כשהטקסט נערך.
בדוגמה הזו, אנחנו מתחילים בהדגשת טקסט.
כשמוסיפים טקסט לפניו, טווח הסגנון זז קדימה.
כשמוסיפים טקסט בתוך הטווח, הטווח מתרחב.
וכשאנחנו מוחקים טקסט בתוך הטווח, טווח הסגנון מצטמצם בהתאם.
אם כל הטקסט בטווח הסגנון נמחק, הסגנון מתכווץ ומוסר אוטומטית מהמאגר.
אז מה קורה עכשיו כשהטקסט מוכנס ממש בגבולות של טווח הסגנון?
האם הטקסט החדש שהוספתם נכלל בסגנון או לא?
כדי לאפשר לכם לשלוט בזה, הוספנו ממשקי API של ExpandPolicy.
בואו נראה – בואו נעבור עליהן.
הראשון הוא ExpandPolicy.InsideOnly.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")במצב הזה, הוספת טקסט בגבול לא תרחיב את טווחי הסגנון.
כאן אנחנו קודם מחילים טווח מודגש.
אם נוסיף טקסט בתחילת הטווח המודגש, טווח הסגנון ישתנה, אבל לא יתרחב.
באופן דומה, אם מוסיפים טקסט בסוף הטווח המודגש, גם טווח הסגנון לא מתרחב.
הנה הדגמה שמראה איך זה עובד בעורך.
השלב הבא הוא ExpandPolicy.AtStart.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")כמו שהשם מרמז, הוספת טקסט בתחילת הטווח מרחיבה את טווח הסגנון.
לכן, כשמוסיפים טקסט ממש לפני טווח הסגנון, הוא מתרחב כדי לכלול את הטקסט החדש.
הנה עוד הדגמה שמראה איך זה עובד.
השלישי הוא ExpandPolicy.AtEnd.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")אז אנחנו מוסיפים טקסט בסוף הטווח כדי להרחיב את טווח הסגנון.
לכן זו גם ההגדרה הנפוצה ביותר לעורכים.
הנה הדגמה שמראה איך זה עובד.
ולבסוף, ExpandPolicy.AtBoth, שמרחיב את הסגנון כשהטקסט נוסף בתחילת הטווח ובסוף הטווח.
val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}הנה עוד הדגמה.
עד עכשיו סקרנו את כל ממשקי ה-API להחלת סגנונות.
בנוסף, הוספנו ממשקי API חדשים לשאילתות כמו getSpanStyles ו-getParagraphStyles כדי לאחזר את הסגנונות שמוחלים כרגע בטווח נתון.
// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>יש שתי גרסאות של ממשקי ה-API לשאילתות.
ב-TextFieldState, ממשקי ה-API של שאילתות הסגנון הם לקריאה בלבד.
הן מחזירות רשימה של אובייקטים מסוג AnnotatedString.Range שלא ניתנים לשינוי.
אבל ב-TextFieldBuffer, הפונקציה getSpanStyles החזירה רשימה של אובייקטים חדשים מסוג TrackedRange, שאציג בהמשך.
לכן, כל שאילתות הסגנון מודעות לסדר.
הן מחזירות את טווחי הסגנון בדיוק בסדר שבו הסגנון מוחל.
כך אפשר להחיל, בדיוק כמו AnnotatedString, סגנון נוסף על גבי סגנון קיים כדי להחליף את הסגנונות הקודמים.
השלב הבא הוא TrackedRange API.
var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): Booleanאובייקטים מסוג TrackedRange מוחזרים על ידי ממשקי ה-API של addStyle או getSpanStyles ב-TextFieldBuffer.
אפשר לחשוב על זה כעל נקודת אחיזה חיה של טווח הסגנון במאגר.
אפשר להשתמש בו כדי לעדכן את טווח הטקסט, ExpandPolicy או את הסגנון עצמו במקום.
יש לו גם מאפיין isValid כדי לבדוק אם טווח הסגנון עדיין נמצא ב-TextFieldBuffer.
הסיבה לכך היא שאפשר להסיר את הסגנון אם הוא מכווץ במהלך העריכה, או שהוא מוסר בכוונה על ידי removeStyle.
עכשיו נראה איך ממשקי ה-API האלה משתלבים בתרחישים לדוגמה מהעולם האמיתי.
תרחיש השימוש הראשון הוא פיתוח עורך WYSIWYG (מה שרואים זה מה שמקבלים).
בדוגמה הזו, הטמענו את התכונה של כפתור ההדגשה.
לחיצה על הכפתור של טקסט מודגש מפעילה או משביתה את הסגנון המודגש בטקסט שנבחר.
עכשיו נסתכל על הקוד.
לכן, כשהמשתמש לוחץ על הכפתור להדגשה, קודם אנחנו קוראים ל-getSpanStyles בבחירה כדי למצוא את כל הסגנונות החופפים.
fun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}ואז אנחנו בודקים אם כל הטקסט שנבחר כבר מודגש או לא.
כאן, המילה שנבחרה לא מודגשת.
מכיוון שהוא לא מודגש, אנחנו addStyle לטווח שנבחר.
אנחנו מעבירים את ExpandPolicy.AtEnd, כדי שהמשתמש יוכל להמשיך להקליד את הטקסט המודגש מיד אחרי המילה הזו.
עכשיו נראה מה קורה אם הטווח שנבחר כבר מודגש.
fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}במקרה כזה, אנחנו צריכים להסיר את סגנון ההדגשה מהטווח שנבחר.
בדוגמה הזו, המילה שהמשתמש בחר נמצאת בתוך הביטוי הארוך יותר שמודגש.
בגלל שהסגנון המודגש מתחיל לפני הטקסט שנבחר, אנחנו צריכים לעדכן את טווח הסגנון, ולהשאיר את המילה שנבחרה לא מודגשת.
כדי לעשות את זה, אנחנו יכולים להשתמש ב-API של TrackedRange.
במקרה הספציפי הזה, במקום למחוק את הסגנון הזה וליצור אותו מחדש, אנחנו יכולים פשוט לשנות את טווח ההדגשה במקום, כך שיסתיים לפני הבחירה באינדקס 10.
כמובן, זו הטמעה פשוטה מאוד.
כדי לראות הדגמה מלאה, אתם יכולים לעיין במאגר הדוגמאות של Compose.
עכשיו נראה איך מעצבים לפי תבנית.
בהדגמה הזו, כשהמשתמש מקליד כוכביות של Markdown מסביב למילה, העורך ממיר אותה באופן אוטומטי לטקסט מודגש תוך כדי ההקלדה.
עכשיו נראה איך מטמיעים את זה באמצעות InputTransformation.
object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}במסנן הקלט המשתנה, אנחנו מוצאים את כל התבניות התואמות מעל הלחצן.
ובכל התאמה, אנחנו מחלצים את התוכן שבתוך הכוכביות הכפולות.
בדוגמה הזו, המילה היא "fox".
בשלב הבא, אנחנו מסירים את הכוכביות ומחליפים את התבנית התואמת בתוכן שלה.
ולבסוף, אנחנו קוראים ל-addStyle כדי להחיל את הסגנון המודגש על טקסט התוכן.
חשוב לזכור: InputTransformation יגרום לשליחה של המצב הסופי של שדה הטקסט.
זה אומר שהסגנון המודגש מוחל על המצב באופן קבוע.
ולסיום, נסתכל על הדוגמה של הדגשת החיפוש.
לכן, בדוגמה הזו, כשמשתמש מקליד שאילתת חיפוש, המילים התואמות בשדות הטקסט מודגשות, בלי לשנות את המצב של שדה הטקסט.
עכשיו נראה איך זה מיושם ב-OutputTransformation.
כשמדגישים מילת חיפוש, אסור לשנות את מצב הטקסט.
ו-OutputTransformation הוא מקום מושלם ליישם את תרחיש השימוש הזה.
בואו נבדוק את ההטמעה בפועל.
קודם אנחנו מחפשים את המילים המודגשות ומוצאים את כל ההתאמות.
class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}שימו לב שהשאילתה היא מצב שניתן לשינוי.
הסיבה לכך היא ש-OutputTransformation מודע לתמונות המצב.
כשהמצבים הניתנים לשינוי מתעדכנים, המערכת מחילה מחדש את OutputTransformation באופן אוטומטי.
ואז, כשאנחנו עוברים בלולאה על כל ההתאמות וקוראים ל-addStyle כדי לצייר את ההדגשות הצהובות על טווח ההתאמה.
שוב, OutputTransformation משנה רק את הטקסט החזותי, והמצבים של הטקסט המקורי לא משתנים.
אוקיי, אלה כל תרחישי השימוש שיש לנו.
תזכורת: כל ממשקי ה-API שהוצגו קודם זמינים החל מגרסה 1.12 של Compose.
ולסיום, אני רוצה לשתף הצצה לפרויקט החדש שאנחנו עובדים עליו כרגע.
זו ספריית טקסט של שפת תגיות ל-Jetpack Compose, שתומכת בפורמטים של Rich Text כמו Markdown.
אחת מהיכולות הכי מעניינות שאנחנו תומכים בהן היא ניתוח מצטבר ועיבוד מצטבר.
לכן הוא מתאים מאוד לעריכה בזמן אמת או לסטרימינג של Markdown.
כאן אפשר לראות תיעוד של אב הטיפוס שלנו בפעולה, והאזורים שמתעמעמים הם החלקים שמתעדכנים בממשק המשתמש.
אם זה מעניין אותך, או אם יש לך תרחיש לדוגמה באפליקציה, אפשר לפנות אלינו.
ותודה לכולם.
TextField(state = rememberTextFieldState())OutlinedTextField(state = rememberTextFieldState())BasicTextField(state = rememberTextFieldState())SecureTextField(state = rememberTextFieldState())@Composable
public fun BasicTextField(
state: TextFieldState,
)@Composable
public fun BasicTextField(
value: String,
onValueChange: (String) -> Unit
)
@Composable
public fun BasicTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit// Often generated by AI agents:
state.edit { insert(5, ",") }
state.edit { delete(12, 13) }
state.edit { replace(0, 5, "goodbye") }
state.edit { selection = TextRange(9, 16) }// Batch all operations in a single atomic edit:
state.edit {
insert(5, ",")
delete(12, 13)
replace(0, 5, "goodbye")
selection = TextRange(9, 16)
}fun TextFieldState.setTextAndPlaceCursorAtEnd(text: String) {
edit {
replace(0, length, text)
placeCursorAtEnd()
}
}fun TextFieldState.clearText() {
edit {
delete(0, length)
placeCursorAtEnd()
}
}onValueChange { newString ->
// full diffing new String vs old String
}@Stable
public fun interface InputTransformation {
public fun TextFieldBuffer.transformInput()
}// Intercept and filter input during typing
inputTransformation = InputTransformation {
val text = asCharSequence()
if (!text.all { it.isDigit() }) {
// Reject non-digit characters
revertAllChanges()
}
}@Stable
public fun interface OutputTransformation {
public fun TextFieldBuffer.transformOutput()
}// Raw data untouched; formatting is purely visual
outputTransformation = OutputTransformation {
if (length > 6) {
insert(6, "-")
}
if (length > 3) {
insert(3, "-")
}
}@Composable
fun PromoCodeInput(onApply: (String) -> Unit) {
// State hoisted locally inside the composable
val state = rememberTextFieldState()
OutlinedTextField(
state = state,
inputTransformation = InputTransformation.maxLength(6).allCaps(Locale.current),
trailingIcon = { Button(onClick = { onApply(state.text.toString()) }) { }
)
}@Composable
fun PostCommentBar(onSubmit: (String) -> Unit) {
// to share between UI components
val commentState = rememberTextFieldState()
Column {
CommentTextField(commentState)
Row {
Text("${commentState.text.length} / 280")
SubmitButton(commentState)
}
}@Composable
fun PostEditor() {
// Hoisting and wiring related pieces separately in the composable
val textFieldState = rememberTextFieldState()
val tagRanges = remember { mutableStateListOf<TextRange>() }
val inputTransform = remember { TagInputTransformation(tagRanges) }
val outputTransform = remember { TagOutputTransformation(tagRanges) }
BasicTextField(
state = textFieldState,
inputTransformation = inputTransform,
outputTransformation = outputTransform,
)
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {
private val tagRanges = mutableStateListOf<TextRange>()
// Transformations coordinate state internally
val inputTransformation = TagInputTransformation(tagRanges)
val outputTransformation = TagOutputTransformation(tagRanges)
fun addTag(tag: String) { ... }
}@Stable
class TagEditorState(val textFieldState: TextFieldState = TextFieldState()) {}
@Composable
fun PostEditor(state: TagEditorState = rememberTagEditorState()) {
BasicTextField(
state = state.textFieldState,
inputTransformation = state.inputTransformation,
outputTransformation = state.outputTransformation,
)
}data class FormUiState(val username: TextFieldState)class FormViewModel : ViewModel() {
val username = TextFieldState()
val uiState: StateFlow<FormUiState> = ...
}class FormViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val username: TextFieldState = savedStateHandle.saveable(
key = "username",
saver = TextFieldState.Saver,
init = { TextFieldState() }
)
val uiState: StateFlow<FormUiState> = ...
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1)
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
val usernameState = rememberTextFieldState(initialText = uiState.initialUsername)
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
LaunchedEffect(usernameState, viewModel) {
snapshotFlow { usernameState.text.toString() }
.drop(1) // 👈 CRITICAL: Avoids firing on initial composition or rotation
.collect { viewModel.onUsernameChanged(it) }
}
BasicTextField(state = usernameState)
}@Composable
fun LoginScreen(viewModel: LoginViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// 1. Hoist TextFieldState in UI layer
// 2. Bridge UI -> ViewModel, one-way only (eliminates cursor jumps/dropped chars)
...
// 3. Handle discrete ViewModel events (e.g. clear after login)
LaunchedEffect(viewModel) {
viewModel.clearUsernameEvent.collect {
usernameState.clearText()
}
}
BasicTextField(state = usernameState)
}val styledText = buildAnnotatedString {
append("Jetpack Compose ")
// 1. Blue & Bold on "Jetpack" [0, 7)
addStyle(
style = SpanStyle(
color = Color.Blue,
fontWeight = FontWeight.Bold
),
start = 0,
end = 7
)
}
// Render static formatted text
Text(text = styledText)fun addStyle(spanStyle: SpanStyle, start: Int, end: Int)
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int)
fun addStyle(spanStyle: SpanStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<SpanStyle>
fun addStyle(paragraphStyle: ParagraphStyle, start: Int, end: Int, expandPolicy: ExpandPolicy): TrackedRange<ParagraphStyle>val state = TextFieldState("The quick fox jumps")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 13
)
insert(0, "Look! ")
insert(16, "brown ")
delete(16, 22)
delete(10, 19)
}val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.InsideOnly
)
insert(4, "quick ")
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtStart
)
insert(4, "quick ")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtEnd
)
insert(length, " jumps")val state = TextFieldState("The fox")
state.edit {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = 4,
end = 7,
policy = ExpandPolicy.AtBoth
)
insert(4, "quick ")
insert(length, " jumps")
}// Outside edits. ReadOnly API in TextFieldState.
val textFieldState.styles: TextFieldTextStyles
fun TextFieldTextStyles.getSpanStyle(range: TextRange): List<AnnotatedString.Range<SpanStyle>>
// Inside edits, returned TrackedRange
fun TextFieldBuffer.getSpanStyles(range: TextRange): List<TrackedRange<SpanStyle>>var TrackedRange<*>.textRange: TextRange
var TrackedRange<*>.expandPolicy: ExpandPolicy
var TrackedRange<SpanStyle>.spanStyle: SpanStyle
var TrackedRange<ParagraphStyle>.paragraphStyle: ParagraphStyle
val TrackedRange<*>.isValid: Boolean
fun removeStyle(trackedRange: TrackedRange<*>): Booleanfun TextFieldBuffer.toggleBoldSelection() {
if (selection.collapsed) return
val boldSpans = getSpanStyles(selection)
if (!isFullyBold(boldSpans, selection)) {
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
selection,
ExpandPolicy.AtEnd
)
} else {
unboldSelection(boldSpans.first())
}
}fun TextFieldBuffer.unboldSelection(
boldSpan: TrackedRange<SpanStyle>
) {
val hasBoldBefore = boldSpan.start < selection.min
val hasBoldAfter = boldSpan.end > selection.max
when {
hasBoldBefore ->
boldSpan.range = TextRange(range.start, selection.min)
hasBoldAfter ->
boldSpan.range = TextRange(selection.max, range.end)
hasBoldBefore && hasBoldAfter -> {
val oldEnd = boldSpan.end
boldSpan.range = TextRange(boldSpan.start, selection.min)
addStyle(
boldSpan.spanStyle,
start = selection.max,
end = oldEnd
)
}
else -> removeStyle(range)
}
}object MarkdownBoldTransformation : InputTransformation {
val regex = Regex("""**(.*?)**""")
override fun TextFieldBuffer.transformInput() {
val matches = regex.findAll(asCharSequence()).toList()
for (match in matches) {
val range = match.range
val content = match.groupValues[1]
replace(range.first, range.last + 1, content)
addStyle(
SpanStyle(fontWeight = FontWeight.Bold),
start = range.first,
end = range.first + content.length,
policy = ExpandPolicy.atEnd
)
}
}
}class SearchHighlightTransformation(
val query: MutableState<String>
) : OutputTransformation {
override fun TextFieldBuffer.transformOutput() {
val search = query.value
if (search.isEmpty()) return
val text = asCharSequence().toString()
var index = text.indexOf(search)
while (index != -1) {
addStyle(
SpanStyle(
background = Color.Yellow,
color = Color.Black
),
start = index,
end = index + search.length
)
index = text.indexOf(search, index + search.length)
}
}
}