Сделайте слайдер маркированного диапазона интерактивным

Мы создали приятный на вид пользовательский интерфейс, но на данный момент он довольно бесполезен. У нас все еще нет возможности взаимодействовать с ним. Давайте это исправим.

Наши сенсорные ручки должны перетаскиваться по полосе, мгновенно позиционироваться при нажатии на них и привязываться к ближайшему значению после завершения взаимодействия.

Переместить

Во второй части этой серии мы рассмотрели, как использовать детекторы жестов. Для этого мы могли использовать detectTapGestures и detectDragGestures. Но поскольку при касании или перетаскивании мы хотим делать примерно одно и то же — позиционировать ручку на точку касания, мы можем использовать кратко упомянутый awaitPointerEventScope для реализации более гибкого и лучше подходящего обработчика касаний.

Мы можем определить интересующие нас состояния прикосновения как запечатанный класс.

sealed class TouchInteraction {
    object NoInteraction : TouchInteraction()
    object Up : TouchInteraction()
    data class Move(val position: Offset) : TouchInteraction()
}
Вход в полноэкранный режим Выход из полноэкранного режима

Нам достаточно знать, что в данный момент нет взаимодействия, что ручка должна быть перемещена в определенное положение и что пользователь поднял палец.

Наш обработчик прикосновений реализуется с помощью модификатора pointerInput.

fun Modifier.touchInteraction(key: Any, block: (TouchInteraction) -> Unit): Modifier =
    pointerInput(key) {
        forEachGesture {
            awaitPointerEventScope {
                do {
                    val event: PointerEvent = awaitPointerEvent()

                    event.changes
                        .forEach { pointerInputChange: PointerInputChange ->
                            if (pointerInputChange.positionChange() != Offset.Zero) pointerInputChange.consume()
                        }

                    block(TouchInteraction.Move(event.changes.first().position))
                } while (event.changes.any { it.pressed })

                block(TouchInteraction.Up)
            }
        }
    }
Вход в полноэкранный режим Выход из полноэкранного режима

Мы ожидаем сенсорного ввода от пользователя с помощью функции awaitPointerEventScope, когда мы его получаем, мы знаем, что пользователь теперь взаимодействует с нашим ползунком Labeled Range. Мы выполняем итерацию событий, пока палец пользователя остается на нашем Composable, мы получаем абсолютную позицию события и сами передаем ее как событие TouchInteraction.Move. Как только пользователь поднимает палец, мы отвечаем событием TouchInteraction.Up, давая нашему пользовательскому интерфейсу возможность отреагировать, привязав ручку к ближайшему шагу.

В нашем Composable мы добавляем модификатор на холст, добавляем три переменные состояния для отслеживания текущего состояния взаимодействия и добавляем логику для обновления положения наших ручек.

var touchInteractionState by remember { mutableStateOf<TouchInteraction>(TouchInteraction.NoInteraction) }
var moveLeft by remember { mutableStateOf(false) }
var moveRight by remember { mutableStateOf(false) }
...

Canvas(
    modifier = modifier
        .touchInteraction(remember { MutableInteractionSource() }) {
            touchInteractionState = it
        }
) {
    ...
}

when (val touchInteraction = touchInteractionState) {
    is TouchInteraction.Move -> {
        val touchPositionX = touchInteraction.position.x
        if (abs(touchPositionX - leftCirclePosition.x) < abs(touchPositionX - rightCirclePosition.x)) {
            leftCirclePosition = calculateNewLeftCirclePosition(touchPositionX, leftCirclePosition, rightCirclePosition, stepSpacing, stepXCoordinates.first())
            moveLeft = true
        } else {
            rightCirclePosition = calculateNewRightCirclePosition(touchPositionX, leftCirclePosition, rightCirclePosition, stepSpacing, stepXCoordinates.last())
            moveRight = true
        }
    }
    is TouchInteraction.Up   -> {
        moveLeft = false
        moveRight = false
        touchInteractionState = TouchInteraction.NoInteraction
    }
    else                     -> {
        // nothing to do
    }
}

Вход в полноэкранный режим Выход из полноэкранного режима

Нам нужно знать, какую ручку нужно переместить. Для этого мы посмотрим на позицию x сенсорного взаимодействия, вычислим расстояние между левой и правой ручкой и переместим ручку, к которой взаимодействие было ближе всего. При расчете нового положения ручки нужно учитывать, что ручка не должна покидать панель и что две ручки не должны пересекаться при перемещении. Для наглядности рассмотрим расчет обновленной позиции левой ручки.

private fun calculateNewLeftCirclePosition(
    touchPositionX: Float,
    leftCirclePosition: Offset,
    rightCirclePosition: Offset,
    stepSpacing: Float,
    firstStepXPosition: Float
): Offset = when {
    touchPositionX < firstStepXPosition                    -> leftCirclePosition.copy(x = firstStepXPosition)
    touchPositionX > (rightCirclePosition.x - stepSpacing) -> leftCirclePosition
    else                                                   -> leftCirclePosition.copy(x = touchPositionX)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Как мы видим, в зависимости от положения касания, положения другой ручки, расстояния между шагами и, в данном случае, положения первого шага, мы рассчитываем новое положение, которое может занять левая ручка.

Ручки перемещаются при касании ползунка, мы можем нажать на позицию, и ручка мгновенно переместится на нее, и мы даже можем перемещаться между двумя ручками, не поднимая пальца.

Сделайте его более быстрым

Ручки еще не ведут себя так, как мы хотим. Они должны переходить на ближайший шаг после того, как пользователь поднимает палец, а также при смене управляемой ручки. Для этого мы обновим логику сенсорного взаимодействия, найдя ближайшую ступеньку и ее координату x, и обновим положение ручки соответствующим образом.

is TouchInteraction.Move -> {
    val touchPositionX = touchInteraction.position.x
    if (abs(touchPositionX - leftCirclePosition.x) < abs(touchPositionX - rightCirclePosition.x)) {
        leftCirclePosition = calculateNewLeftCirclePosition(touchPositionX, leftCirclePosition, rightCirclePosition, stepSpacing, stepXCoordinates.first())
        moveLeft = true

        if (moveRight) {
            val (closestRightValue, _) = stepXCoordinates.getClosestNumber(rightCirclePosition.x)
            rightCirclePosition = rightCirclePosition.copy(x = closestRightValue)
            moveRight = false
        }
    } else {
        rightCirclePosition = calculateNewRightCirclePosition(touchPositionX, leftCirclePosition, rightCirclePosition, stepSpacing, stepXCoordinates.last())
        moveRight = true

        if (moveLeft) {
            val (closestRightValue, _) = stepXCoordinates.getClosestNumber(leftCirclePosition.x)
            leftCirclePosition = leftCirclePosition.copy(x = closestRightValue)
            moveLeft = false
        }
    }
}
is TouchInteraction.Up   -> {
    val (closestLeftValue, closestLeftIndex) = stepXCoordinates.getClosestNumber(leftCirclePosition.x)
    val (closestRightValue, closestRightIndex) = stepXCoordinates.getClosestNumber(rightCirclePosition.x)
    if (moveLeft) {
        leftCirclePosition = leftCirclePosition.copy(x = closestLeftValue)
        moveLeft = false
    } else if (moveRight) {
        rightCirclePosition = rightCirclePosition.copy(x = closestRightValue)
        moveRight = false
    }
    touchInteractionState = TouchInteraction.NoInteraction
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь это уже похоже на конечный результат, которого мы хотим добиться :-). Но не хватает одной небольшой детали: Нам все еще нужно передать обновленный диапазон обратно нашему вызывающему устройству, чтобы оно могло на него отреагировать ;-).

Этот последний шаг теперь довольно прост. Мы добавляем обратный вызов onRangeChanged в качестве параметра к нашему Composable.

@Composable
fun <T : Number> LabeledRangeSlider(
    selectedLowerBound: T,
    selectedUpperBound: T,
    steps: List<T>,
    onRangeChanged: (lower: T, upper: T) -> Unit,
    modifier: Modifier = Modifier,
    sliderConfig: SliderConfig = SliderConfig()
)
Вход в полноэкранный режим Выйти из полноэкранного режима

И просто вызываем его каждый раз, когда пользователь поднимает палец со значением выбранных шагов.

is TouchInteraction.Up   -> {
    val (closestLeftValue, closestLeftIndex) = stepXCoordinates.getClosestNumber(leftCirclePosition.x)
    val (closestRightValue, closestRightIndex) = stepXCoordinates.getClosestNumber(rightCirclePosition.x)
    if (moveLeft) {
        leftCirclePosition = leftCirclePosition.copy(x = closestLeftValue)
        onRangeChanged(steps[closestLeftIndex], steps[closestRightIndex])
        moveLeft = false
    } else if (moveRight) {
        rightCirclePosition = rightCirclePosition.copy(x = closestRightValue)
        onRangeChanged(steps[closestLeftIndex], steps[closestRightIndex])
        moveRight = false
    }
    touchInteractionState = TouchInteraction.NoInteraction
}
Войти в полноэкранный режим Выход из полноэкранного режима

Заключение

Мы сделали это 🎉. Мы создали свой собственный Labeled Range Slider с нуля, сами нарисовав все, что нужно нашему Composable и сделав его интерактивным с помощью соответствующего модификатора 🥳.

Весь исходный код Labeled Range Slider можно найти на GitHub.

Надеюсь, вам понравилось следить за этой серией статей и вы получили немного полезного вдохновения :-).

Оцените статью
devanswers.ru
Добавить комментарий