בדף הזה מפורטות דוגמאות להעברת TextField מבוסס-ערך ל-TextField מבוסס-מצב. בדף הגדרת שדות טקסט מוסבר על ההבדלים בין TextField שמבוססים על ערכים לבין כאלה שמבוססים על מצבים.
שימוש בסיסי
מבוסס-ערך
@Composable fun OldSimpleTextField() { var state by rememberSaveable { mutableStateOf("") } TextField( value = state, onValueChange = { state = it }, singleLine = true, ) }
לפי מדינה
@Composable fun NewSimpleTextField() { TextField( state = rememberTextFieldState(), lineLimits = TextFieldLineLimits.SingleLine ) }
- מחליפים את
value, onValueChangeואתremember { mutableStateOf("")ב-rememberTextFieldState(). - מחליפים את
singleLine = trueב-lineLimits = TextFieldLineLimits.SingleLine.
סינון לפי onValueChange
מבוסס-ערך
@Composable fun OldNoLeadingZeroes() { var input by rememberSaveable { mutableStateOf("") } TextField( value = input, onValueChange = { newText -> input = newText.trimStart { it == '0' } } ) }
לפי מדינה
@Preview @Composable fun NewNoLeadingZeros() { TextField( state = rememberTextFieldState(), inputTransformation = InputTransformation { while (length > 0 && charAt(0) == '0') delete(0, 1) } ) }
- מחליפים את לולאת ה-callback של הערך ב-
rememberTextFieldState(). - מטמיעים מחדש את לוגיקת הסינון ב-
onValueChangeבאמצעותInputTransformation. - צריך להשתמש ב-
TextFieldBufferמההיקף של המקבל שלInputTransformationכדי לעדכן אתstate.- הפונקציה
InputTransformationנקראת מיד אחרי שמזוהה קלט של משתמש. - שינויים שמוצעים על ידי
InputTransformationבאמצעותTextFieldBufferמיושמים באופן מיידי, וכך נמנעת בעיית סנכרון בין המקלדת הווירטואלית לביןTextField.
- הפונקציה
מעצב כרטיסי אשראי TextField
מבוסס-ערך
@Composable fun OldTextFieldCreditCardFormatter() { var state by remember { mutableStateOf("") } TextField( value = state, onValueChange = { if (it.length <= 16) state = it }, visualTransformation = VisualTransformation { text -> // Making XXXX-XXXX-XXXX-XXXX string. var out = "" for (i in text.indices) { out += text[i] if (i % 4 == 3 && i != 15) out += "-" } TransformedText( text = AnnotatedString(out), offsetMapping = object : OffsetMapping { override fun originalToTransformed(offset: Int): Int { if (offset <= 3) return offset if (offset <= 7) return offset + 1 if (offset <= 11) return offset + 2 if (offset <= 16) return offset + 3 return 19 } override fun transformedToOriginal(offset: Int): Int { if (offset <= 4) return offset if (offset <= 9) return offset - 1 if (offset <= 14) return offset - 2 if (offset <= 19) return offset - 3 return 16 } } ) } ) }
לפי מדינה
@Composable fun NewTextFieldCreditCardFormatter() { val state = rememberTextFieldState() TextField( state = state, inputTransformation = InputTransformation.maxLength(16), outputTransformation = OutputTransformation { if (length > 4) insert(4, "-") if (length > 9) insert(9, "-") if (length > 14) insert(14, "-") }, ) }
- מחליפים את הסינון ב-
onValueChangeב-InputTransformationכדי להגדיר את האורך המקסימלי של הקלט.- עיינו בקטע סינון באמצעות
onValueChange.
- עיינו בקטע סינון באמצעות
- מחליפים את
VisualTransformationב-OutputTransformationכדי להוסיף מקפים.- כשמשתמשים ב-
VisualTransformation, אתם אחראים גם ליצירת הטקסט החדש עם המקפים וגם לחישוב המיפוי של האינדקסים בין הטקסט החזותי לבין המצב התומך. -
OutputTransformationמטפל במיפוי ההיסט באופן אוטומטי. אתם רק צריכים להוסיף את המקפים במקומות הנכונים באמצעותTextFieldBufferמההיקף של המקבל שלOutputTransformation.transformOutput.
- כשמשתמשים ב-
עדכון המצב (פשוט)
מבוסס-ערך
@Composable fun OldTextFieldStateUpdate(userRepository: UserRepository) { var username by remember { mutableStateOf("") } LaunchedEffect(Unit) { username = userRepository.fetchUsername() } TextField( value = username, onValueChange = { username = it } ) }
לפי מדינה
@Composable fun NewTextFieldStateUpdate(userRepository: UserRepository) { val usernameState = rememberTextFieldState() LaunchedEffect(Unit) { usernameState.setTextAndPlaceCursorAtEnd(userRepository.fetchUsername()) } TextField(state = usernameState) }
- מחליפים את לולאת ה-callback של הערך ב-
rememberTextFieldState(). - משנים את הקצאת הערך באמצעות
TextFieldState.setTextAndPlaceCursorAtEnd.
עדכון המצב (מורכב)
מבוסס-ערך
@Composable fun OldTextFieldAddMarkdownEmphasis() { var markdownState by remember { mutableStateOf(TextFieldValue()) } Button(onClick = { // add ** decorations around the current selection, also preserve the selection markdownState = with(markdownState) { copy( text = buildString { append(text.take(selection.min)) append("**") append(text.substring(selection)) append("**") append(text.drop(selection.max)) }, selection = TextRange(selection.min + 2, selection.max + 2) ) } }) { Text("Bold") } TextField( value = markdownState, onValueChange = { markdownState = it }, maxLines = 10 ) }
לפי מדינה
@Composable fun NewTextFieldAddMarkdownEmphasis() { val markdownState = rememberTextFieldState() LaunchedEffect(Unit) { // add ** decorations around the current selection markdownState.edit { insert(originalSelection.max, "**") insert(originalSelection.min, "**") selection = TextRange(originalSelection.min + 2, originalSelection.max + 2) } } TextField( state = markdownState, lineLimits = TextFieldLineLimits.MultiLine(1, 10) ) }
במקרה הזה, לחיצה על כפתור מוסיפה את העיטורים של Markdown כדי להדגיש את הטקסט מסביב לסמן או לבחירה הנוכחית. הוא גם שומר על מיקום הבחירה אחרי השינויים.
- מחליפים את לולאת ה-callback של הערך ב-
rememberTextFieldState(). - מחליפים את
maxLines = 10ב-lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10). - לשנות את הלוגיקה של חישוב
TextFieldValueחדש באמצעות קריאה ל-TextFieldState.edit.- נוצר
TextFieldValueחדש על ידי פיצול הטקסט הקיים על סמך הבחירה הנוכחית, והוספת עיטורי Markdown ביניהם. - בנוסף, הבחירה מותאמת לפי האינדקסים החדשים של הטקסט.
- ב-
TextFieldState.editיש דרך טבעית יותר לערוך את המצב הנוכחי באמצעותTextFieldBuffer. - הבחירה מגדירה באופן מפורש איפה להוסיף את העיטורים.
- אחר כך משנים את החלק שנבחר, בדומה לגישה של
onValueChange.
- נוצר
ארכיטקטורת ViewModel StateFlow
אפליקציות רבות פועלות לפי ההנחיות לפיתוח אפליקציות מודרניות, שמעודדות שימוש ב-StateFlow כדי להגדיר את מצב ממשק המשתמש של מסך או רכיב באמצעות מחלקה אחת שלא ניתנת לשינוי, שכוללת את כל המידע.
באפליקציות מהסוג הזה, טופס כמו מסך התחברות עם שדה להזנת טקסט בדרך כלל מעוצב כך:
class LoginViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> get() = _uiState.asStateFlow() fun updateUsername(username: String) = _uiState.update { it.copy(username = username) } fun updatePassword(password: String) = _uiState.update { it.copy(password = password) } } data class UiState( val username: String = "", val password: String = "" ) @Composable fun LoginForm( loginViewModel: LoginViewModel, modifier: Modifier = Modifier ) { val uiState by loginViewModel.uiState.collectAsStateWithLifecycle() Column(modifier) { TextField( value = uiState.username, onValueChange = { loginViewModel.updateUsername(it) } ) TextField( value = uiState.password, onValueChange = { loginViewModel.updatePassword(it) }, visualTransformation = PasswordVisualTransformation() ) } }
העיצוב הזה מתאים באופן מושלם ל-TextFields שמשתמשים בפרדיגמה של העלאת הרמה של מצב (state hoisting) ב-value,
onValueChange. עם זאת, יש לגישה הזו חסרונות בלתי צפויים כשמדובר בקלט טקסט. בפוסט בבלוג ניהול מצב יעיל של TextField ב-Compose מוסברות בפירוט בעיות הסנכרון העמוקות בגישה הזו.
הבעיה היא שהעיצוב החדש של TextFieldState לא תואם ישירות למצב ממשק המשתמש של ViewModel שמגובה על ידי StateFlow. יכול להיות שזה נראה מוזר להחליף את username: String ו-password: String ב-username: TextFieldState וב-password: TextFieldState, כי TextFieldState הוא מבנה נתונים שניתן לשינוי באופן מהותי.
המלצה נפוצה היא להימנע מהצבת יחסי תלות של ממשק המשתמש ב-ViewModel.
למרות שזו בדרך כלל שיטה מומלצת, לפעמים היא עלולה להתפרש באופן שגוי.
זה נכון במיוחד לגבי יחסי תלות של Compose שהם רק מבני נתונים ולא כוללים רכיבים בממשק המשתמש, כמו TextFieldState.
מחלקות כמו MutableState או TextFieldState הן מאחסני מצב פשוטים שמגובים על ידי מערכת המצב Snapshot של Compose. הם לא שונים מיחסי תלות כמו StateFlow או RxJava. לכן,מומלץ לבדוק מחדש איך אתם מיישמים בקוד את העיקרון "אין תלות בממשק המשתמש ב-ViewModel". שמירת הפניה ל-TextFieldState בתוך ViewModel היא לא בהכרח שיטה גרועה.
הגישה הפשוטה המומלצת
מומלץ לחלץ ערכים כמו username או password מ-UiState,
ולשמור להם הפניה נפרדת ב-ViewModel.
class LoginViewModel : ViewModel() { val usernameState = TextFieldState() val passwordState = TextFieldState() } @Composable fun LoginForm( loginViewModel: LoginViewModel, modifier: Modifier = Modifier ) { Column(modifier) { TextField(state = loginViewModel.usernameState,) SecureTextField(state = loginViewModel.passwordState) } }
- מחליפים את
MutableStateFlow<UiState>בכמה ערכים שלTextFieldState. - מעבירים את האובייקטים האלה מסוג
TextFieldStateאלTextFieldsבפונקציה הקומפוזבילית שלLoginForm.
גישה קונפורמית
שינויים כאלה בארכיטקטורה לא תמיד קלים. יכול להיות שאין לכם אפשרות לבצע את השינויים האלה, או שהזמן שצריך להשקיע לא מצדיק את היתרונות של השימוש ב-TextField החדשים. במקרה כזה, עדיין אפשר להשתמש בשדות טקסט מבוססי-מצב עם שינוי קטן.
class LoginViewModel : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState: StateFlow<UiState> get() = _uiState.asStateFlow() fun updateUsername(username: String) = _uiState.update { it.copy(username = username) } fun updatePassword(password: String) = _uiState.update { it.copy(password = password) } } data class UiState( val username: String = "", val password: String = "" ) @Composable fun LoginForm( loginViewModel: LoginViewModel, modifier: Modifier = Modifier ) { val initialUiState = remember(loginViewModel) { loginViewModel.uiState.value } Column(modifier) { val usernameState = rememberTextFieldState(initialUiState.username) LaunchedEffect(usernameState) { snapshotFlow { usernameState.text.toString() }.collectLatest { loginViewModel.updateUsername(it) } } TextField(usernameState) val passwordState = rememberTextFieldState(initialUiState.password) LaunchedEffect(passwordState) { snapshotFlow { passwordState.text.toString() }.collectLatest { loginViewModel.updatePassword(it) } } SecureTextField(passwordState) } }
- הכיתות
ViewModelו-UiStateצריכות להיות זהות. - במקום להעביר את המצב ישירות אל
ViewModelולהפוך אותו למקור האמת שלTextFields, הופכים אתViewModelלמחזיק נתונים פשוט.- כדי לעשות את זה, צריך לבחון את השינויים בכל
TextFieldState.textעל ידי איסוףsnapshotFlowבLaunchedEffect.
- כדי לעשות את זה, צריך לבחון את השינויים בכל
- ה-
ViewModelעדיין יכלול את הערכים העדכניים ביותר מממשק המשתמש, אבל ה-uiState: StateFlow<UiState>שלו לא יניע את ה-TextField. - כל לוגיקת שמירה אחרת שהוטמעה ב-
ViewModelיכולה להישאר ללא שינוי.