Проблема выбора номера в Jetpack Compose

В моем приложении для 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,
        )
    }
}
Стоит ли изучать PHP в 2026-2027 годах?
Стоит ли изучать PHP в 2026-2027 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
3
0
136
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Ответ принят как подходящий

Я думаю, что есть проблема с логикой этой строки:

var pickerValue by remember { mutableStateOf(if (heightType) 185 else 5) }

Оператор if внутри mutableStateOf() будет выполнен ровно один раз при первой композиции. После этого текущее значение будет учитываться при рекомпозициях.
Итак, когда вы измените remember, heightType останется прежним. Обновятся только pickerValue и range, поскольку вы не использовали для них text.

Я не могу точно объяснить странное поведение, которое вы наблюдаете, но я предполагаю, что происходит следующее.

  • Для remember установлено значение см, а для heightType изначально установлено значение 185.
  • pickerValue установлен на дюймы, но heightType остается на 185.
  • поскольку 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(
        // ...
    )
}

Другие вопросы по теме