מעבר לשדות טקסט מבוססי-מצב

בדף הזה מופיעות דוגמאות להעברה של TextFieldמבוססי-ערך לTextFieldמבוססי-מצב. במאמר הגדרת שדות טקסט יש מידע על ההבדלים בין TextFields מבוססי ערך לבין TextFields מבוססי מצב.

שימוש בסיסי

מבוסס-ערך

@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)
        }
    )
}

  • מחליפים את לולאת הקריאה החוזרת של הערך ב-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 כדי להגדיר את האורך המקסימלי של הקלט.
  • מחליפים את 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)
}

  • מחליפים את לולאת הקריאה החוזרת של הערך ב-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 כדי להפוך את הטקסט לטקסט מודגש סביב הסמן או הבחירה הנוכחית. בנוסף, הוא שומר על מיקום הבחירה אחרי השינויים.

  • מחליפים את לולאת הקריאה החוזרת של הערך ב-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 שמשתמשים בפרדיגמת העברת המצב 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 composable.

גישה תואמת

שינויים אדריכליים מהסוג הזה הם לא תמיד פשוטים. יכול להיות שאין לכם את החופש לבצע את השינויים האלה, או שהזמן שנדרש להשקעה עלול להיות ארוך יותר מהזמן שייחסך בזכות השימוש ב-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(usernameState) {
            snapshotFlow { usernameState.text.toString() }.collectLatest {
                loginViewModel.updatePassword(it)
            }
        }
        SecureTextField(passwordState)
    }
}

  • הכיתות ViewModel ו-UiState צריכות להיות זהות.
  • במקום להעלות את הסטייט ישירות אל ViewModel ולהפוך אותו למקור האמת של TextFields, הופכים את ViewModel למאגר נתונים פשוט.
    • כדי לעשות את זה, צריך לעקוב אחרי השינויים בכל TextFieldState.text על ידי איסוף snapshotFlow בLaunchedEffect.
  • ב-ViewModel עדיין יוצגו הערכים האחרונים מממשק המשתמש, אבל uiState: StateFlow<UiState> לא יניב את TextField.
  • כל לוגיקה אחרת של שמירת נתונים שהוטמעה ב-ViewModel יכולה להישאר ללא שינוי.