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

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

ארכיטקטורה של StateFlow של ViewModel

אפליקציות רבות פועלות בהתאם להנחיות לפיתוח אפליקציות מודרניות, שמעודדות שימוש ב-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 הן מאגרי מצב פשוטים שמגובים על ידי מערכת המצב של Compose ב-Snapshot. אין הבדל ביניהן לבין יחסי תלות כמו 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 ב-composable של 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(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.
  • כל לוגיקה אחרת של עקביות (persistence) שמיושמת ב-ViewModel יכולה להישאר ללא שינוי.