Fix button width jitter using rememberTextMeasurer to derive stable min-width

This commit is contained in:
ganfra
2026-06-16 11:25:08 +02:00
parent 167c1ced80
commit 889fd08b5c
@@ -10,10 +10,9 @@ package io.element.android.features.preferences.impl.userstatus
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation import androidx.compose.foundation.text.input.InputTransformation
@@ -29,8 +28,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
@@ -38,8 +39,6 @@ import io.element.android.features.preferences.impl.R
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
@@ -48,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.ModalBottomShe
import io.element.android.libraries.designsystem.theme.components.Surface import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.matrix.api.user.DisplayedStatus import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@@ -128,7 +128,9 @@ private fun CurrentStatusRow(
} }
ListItem( ListItem(
headlineContent = { Text(text = text) }, headlineContent = { Text(text = text) },
leadingContent = ListItemContent.Text(emoji), leadingContent = ListItemContent.Custom({
Text(text = emoji, modifier = Modifier.size(24.dp))
}),
trailingContent = ListItemContent.Custom({ trailingContent = ListItemContent.Custom({
IconButton(onClick = onClear) { IconButton(onClick = onClear) {
Icon(imageVector = CompoundIcons.Close(), contentDescription = null) Icon(imageVector = CompoundIcons.Close(), contentDescription = null)
@@ -178,32 +180,47 @@ private fun CustomStatusInputRow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } } val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } }
val saveLabel = stringResource(CommonStrings.action_save)
val cancelLabel = stringResource(CommonStrings.action_cancel)
val textStyle = ElementTheme.typography.fontBodyLgMedium
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
// Measure both labels and derive a stable min-width so the button never resizes.
// 24.dp = 12.dp start + 12.dp end padding of TextButton (Large, no icon).
val minButtonWidth = remember(textMeasurer, textStyle, saveLabel, cancelLabel) {
val saveWidth = textMeasurer.measure(saveLabel, textStyle).size.width
val cancelWidth = textMeasurer.measure(cancelLabel, textStyle).size.width
with(density) { maxOf(saveWidth, cancelWidth).toDp() } + 24.dp
}
ListItem( ListItem(
headlineContent = { headlineContent = {
TextField( TextField(
modifier = Modifier.fillMaxWidth(),
state = textFieldState, state = textFieldState,
placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint), placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint),
inputTransformation = InputTransformation.maxLength(maxLength = 30), inputTransformation = InputTransformation.maxLength(maxLength = 30),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onKeyboardAction = { if (hasText) onConfirm() }, onKeyboardAction = { if (hasText) onConfirm() },
lineLimits = TextFieldLineLimits.SingleLine, lineLimits = TextFieldLineLimits.SingleLine,
trailingIcon = if (hasText) { trailingIcon = {
{ if (hasText) {
IconButton(onClick = { textFieldState.clearText() }) { Box(modifier = Modifier.clickable {
Icon(imageVector = CompoundIcons.Close(), contentDescription = null) textFieldState.clearText()
}) {
Icon(imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_cancel))
} }
} }
} else null, }
) )
}, },
trailingContent = ListItemContent.Custom({ trailingContent = ListItemContent.Custom({
if (hasText) { TextButton(
TextButton(onClick = onConfirm, text = stringResource(CommonStrings.action_save)) onClick = if (hasText) onConfirm else onCancel,
} else { text = if (hasText) saveLabel else cancelLabel,
TextButton(onClick = onCancel, text = stringResource(CommonStrings.action_cancel)) modifier = Modifier.widthIn(min = minButtonWidth),
} )
}), }),
leadingContent = ListItemContent.Custom( { leadingContent = ListItemContent.Custom({
Surface( Surface(
shape = CircleShape, shape = CircleShape,
border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary), border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary),