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

בדף הזה מפורטות דוגמאות להעברת 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)
}

  • מחליפים את לולאת ה-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 יכולה להישאר ללא שינוי.