В моем приложении для Android есть компонент пользовательского интерфейса под названием HeightPicker, который позволяет пользователям выбирать свой рост, используя либо сантиметры («см»), либо футы («футы»). Проблема возникает, когда пользователи переключаются между единицами измерения «см» и «футы»: выбранное значение высоты неожиданно меняется. В частности, если пользователь выбирает высоту в сантиметрах, переключается на футы, а затем возвращается к сантиметрам, ранее выбранное значение высоты увеличивается на 1.
Воспроизвести: Запустите приложение и перейдите к экрану с окном выбора высоты. Изначально рост отображается в сантиметрах со значением по умолчанию 185. Нажмите кнопку «ft», чтобы переключиться на футы. Выберите рост в футах, а затем снова переключитесь на сантиметры. Обратите внимание, что ранее выбранная высота в сантиметрах увеличилась на 1.
Ожидаемое поведение: При переключении между «см» и «футами» выбранное значение высоты должно оставаться неизменным. Например, если пользователь выбирает 186 см, переключается на футы, а затем возвращается к сантиметрам, рост все равно должен составлять 186 см, а не 187 см. . Я добавил ссылку на свой диск, чтобы вы могли посмотреть демонстрационное видео этой проблемы Демонстрационное видео
Что я пробовал: Предпринята попытка использовать логические флаги и строковые идентификаторы («см» и «футы») для отслеживания выбранной единицы измерения и соответствующей обработки значения. Пробовал хранить данные с использованием изменяемого состояния с помощью ViewModel, но проблема не устранена. Соответствующие фрагменты кода:
Предоставлены фрагменты кода для HeightPicker, UserModel, DigitPicker и ListItemPicker. Упоминается реализация кнопок выбора «см» и «футов», а также логика обновления выбранного значения высоты.
Пользовательский экран
@Composable
fun HeightPicker() {
var heightType by remember { mutableStateOf(true) }
val range = if (heightType) 50..300 else 2..12
val text = if (heightType) "cm" else "ft"
var pickerValue by remember { mutableStateOf(if (heightType) 185 else 5) }
Column {
Row(modifier = Modifier.padding(top = 20.sdp)) {
// Button for "cm"
Button(
modifier = Modifier,
colors = if (heightType) ButtonDefaults.buttonColors(containerColor = AppColor) else ButtonDefaults.buttonColors(
containerColor = LightestAppColor
),
onClick = { heightType = true }
) {
SimpleTextComponent(
modifier = Modifier,
text = "cm",
textColor = if (heightType) Color.White else AppColor,
textSize = 12.ssp,
fontFamily = TitleTextFont.fontFamily
)
}
Spacer(modifier = Modifier.width(20.sdp))
// Button for "ft"
Button(
modifier = Modifier,
colors = if (!heightType) ButtonDefaults.buttonColors(containerColor = AppColor) else ButtonDefaults.buttonColors(
containerColor = LightestAppColor
),
onClick = {
heightType = false
}
) {
SimpleTextComponent(
modifier = Modifier,
text = "ft",
textColor = if (!heightType) Color.White else AppColor,
textSize = 12.ssp,
fontFamily = TitleTextFont.fontFamily
)
}
}
Box(
modifier = Modifier
.fillMaxHeight()
.wrapContentWidth()
.align(Alignment.CenterHorizontally)
.padding(bottom = 120.sdp),
contentAlignment = Alignment.Center
) {
DigitPicker(
modifier = Modifier.width(100.sdp),
value = pickerValue,
range = range,
onValueChange = {
pickerValue = it
model.height = it
model.heightType = text},
)
SimpleTextComponent(
modifier = Modifier.align(Alignment.CenterEnd),
text = text,
fontFamily = TitleTextFont.fontFamily,
textSize = 12.ssp
)
}
}
}
Модель пользователя
data class UserModel(
var gender : String = "",
var sedentary : String = "",
var age : Int = 18,
var height : Int = 185,
var heightType : String = "cm",
var weight : Int = 76,
var weightType : String = "kg",
var step : Int = 6000,
)
Выбор цифр
@Composable
fun DigitPicker(
modifier: Modifier = Modifier,
label: (Int) -> String = {
it.toString()
},
value: Int,
range: Iterable<Int>,
onValueChange: (Int) -> Unit,
dividersColor: Color = AppColor,
textStyle: TextStyle = LocalTextStyle.current,
) {
ListItemPicker(
modifier = modifier,
label = label,
value = value,
onValueChange = onValueChange,
dividersColor = dividersColor,
list = range.toList(),
textStyle = textStyle,
)
}
ListItemPicker
private fun <T> getItemIndexForOffset(
range: List<T>,
value: T,
offset: Float,
halfNumbersColumnHeightPx: Float
): Int {
val indexOf = range.indexOf(value) - (offset / halfNumbersColumnHeightPx).toInt()
return maxOf(0, minOf(indexOf, range.count() - 1))
}
@Composable
fun <T> ListItemPicker(
modifier: Modifier = Modifier,
label: (T) -> String = { it.toString() },
value: T,
onValueChange: (T) -> Unit,
dividersColor: Color = AppColor,
list: List<T>,
textStyle: TextStyle = LocalTextStyle.current,
dividerHeight: Dp = 2.dp
) {
val minimumAlpha = 0.3f
val verticalMargin = 15.dp
val numbersColumnHeight = 150.dp
val halfNumbersColumnHeight = numbersColumnHeight / 2
val halfNumbersColumnHeightPx = with(LocalDensity.current) { halfNumbersColumnHeight.toPx() }
val coroutineScope = rememberCoroutineScope()
val animatedOffset = remember { Animatable(0f) }
.apply {
val index = list.indexOf(value)
val offsetRange = remember(value, list) {
-((list.count() - 1) - index) * halfNumbersColumnHeightPx to
index * halfNumbersColumnHeightPx
}
updateBounds(offsetRange.first, offsetRange.second)
}
val coercedAnimatedOffset = animatedOffset.value % halfNumbersColumnHeightPx
val indexOfElement =
getItemIndexForOffset(list, value, animatedOffset.value, halfNumbersColumnHeightPx)
var dividersWidth by remember { mutableStateOf(0.dp) }
Layout(
modifier = modifier
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { deltaY ->
coroutineScope.launch {
animatedOffset.snapTo(animatedOffset.value + deltaY)
}
},
onDragStopped = { velocity ->
coroutineScope.launch {
val endValue = animatedOffset.fling(
initialVelocity = velocity,
animationSpec = exponentialDecay(frictionMultiplier = 4f),
adjustTarget = { target ->
val coercedTarget = target % halfNumbersColumnHeightPx
val coercedAnchors =
listOf(
-halfNumbersColumnHeightPx,
0f,
halfNumbersColumnHeightPx
)
val coercedPoint =
coercedAnchors.minByOrNull { abs(it - coercedTarget) }!!
val base =
halfNumbersColumnHeightPx * (target / halfNumbersColumnHeightPx).toInt()
coercedPoint + base
}
).endState.value
val result = list.elementAt(
getItemIndexForOffset(list, value, endValue, halfNumbersColumnHeightPx)
)
onValueChange(result)
animatedOffset.snapTo(0f)
}
}
)
.padding(vertical = numbersColumnHeight / 3 + verticalMargin * 2),
content = {
Box(
modifier
.height(dividerHeight)
.background(color = dividersColor)
)
Box(
modifier = Modifier
.padding(vertical = verticalMargin, horizontal = 20.dp)
.offset { IntOffset(x = 0, y = coercedAnimatedOffset.roundToInt()) }
) {
val baseLabelModifier = Modifier.align(Alignment.Center)
ProvideTextStyle(textStyle) {
if (indexOfElement > 0)
Label(
text = label(list.elementAt(indexOfElement - 1)),
modifier = baseLabelModifier
.offset(y = -halfNumbersColumnHeight)
.alpha(
maxOf(
minimumAlpha,
coercedAnimatedOffset / halfNumbersColumnHeightPx
)
)
)
Label(
text = label(list.elementAt(indexOfElement)),
modifier = baseLabelModifier
.alpha(
(maxOf(
minimumAlpha,
1 - abs(coercedAnimatedOffset) / halfNumbersColumnHeightPx
))
)
)
if (indexOfElement < list.count() - 1)
Label(
text = label(list.elementAt(indexOfElement + 1)),
modifier = baseLabelModifier
.offset(y = halfNumbersColumnHeight)
.alpha(
maxOf(
minimumAlpha,
-coercedAnimatedOffset / halfNumbersColumnHeightPx
)
)
)
}
}
Box(
modifier
.height(dividerHeight)
.background(color = dividersColor)
)
}
) { measurables, constraints ->
// Don't constrain child views further, measure them with given constraints
// List of measured children
val placeables = measurables.map { measurable ->
// Measure each children
measurable.measure(constraints)
}
dividersWidth = placeables
.drop(1)
.first()
.width
.toDp()
// Set the size of the layout as big as it can
layout(dividersWidth.toPx().toInt(), placeables
.sumOf {
it.height
}
) {
// Track the y co-ord we have placed children up to
var yPosition = 0
// Place children in the parent layout
placeables.forEach {
// Position item on the screen
it.placeRelative(x = 0, y = yPosition)
// Record the y co-ord placed up to
yPosition += it.height
}
}
}
}
@Composable
private fun Label(text: String, modifier: Modifier) {
Text(
modifier = modifier.pointerInput(Unit) {
detectTapGestures(onLongPress = {
// FIXME: Empty to disable text selection
})
},
text = text,
textAlign = TextAlign.Center,
fontSize = 24.ssp,
fontFamily = TitleTextFont.fontFamily,
color = AppColor
)
}
private suspend fun Animatable<Float, AnimationVector1D>.fling(
initialVelocity: Float,
animationSpec: DecayAnimationSpec<Float>,
adjustTarget: ((Float) -> Float)?,
block: (Animatable<Float, AnimationVector1D>.() -> Unit)? = null,
): AnimationResult<Float, AnimationVector1D> {
val targetValue = animationSpec.calculateTargetValue(value, initialVelocity)
val adjustedTarget = adjustTarget?.invoke(targetValue)
return if (adjustedTarget != null) {
animateTo(
targetValue = adjustedTarget,
initialVelocity = initialVelocity,
block = block
)
} else {
animateDecay(
initialVelocity = initialVelocity,
animationSpec = animationSpec,
block = block,
)
}
}





Я думаю, что есть проблема с логикой этой строки:
var pickerValue by remember { mutableStateOf(if (heightType) 185 else 5) }
Оператор if внутри mutableStateOf() будет выполнен ровно один раз при первой композиции. После этого текущее значение будет учитываться при рекомпозициях.
Итак, когда вы измените remember, heightType останется прежним. Обновятся только pickerValue и range, поскольку вы не использовали для них text.
Я не могу точно объяснить странное поведение, которое вы наблюдаете, но я предполагаю, что происходит следующее.
remember установлено значение см, а для heightType изначально установлено значение 185.pickerValue установлен на дюймы, но heightType остается на 185.pickerValue, значение будет принудительно изменено.Если вы хотите, чтобы предыдущие значения были восстановлены после переключения с см на дюймы, а затем обратно на см, вам понадобятся две переменные состояния:
var cmPickerValue by remember { mutableStateOf(185) }
var inchPickerValue by remember { mutableStateOf(5) }
//...
DigitPicker(
modifier = Modifier.width(100.sdp),
value = if (heightType) cmPickerValue else inchPickerValue,
range = range,
onValueChange = {
if (heightType) {
cmPickerValue = it
} else {
inchPickerValue = it
}
model.height = it
model.heightType = text
},
)
Альтернативно, вы можете просто вызвать range Composable дважды, один раз для см и один раз для дюймов:
if (heightType) {
DigitPicker(
// use cmPickerValue in here
)
} else {
DigitPicker(
// use inchPickerValue in here
)
}
Пожалуйста, сообщите, если это сработает или, по крайней мере, улучшит текущую реализацию.
Кроме того, я добавил model.height = cmPickerValue и model.heightType = text, чтобы, если пользователь вернется из «ft» и не изменит значение в см, данные в см будут установлены в классе данных.
if (heightType) {
model.height = cmPickerValue
model.heightType = text
DigitPicker(
// ...
)
} else {
model.height = ftPickerValue
model.heightType = text
DigitPicker(
// ...
)
}