Жесты

При работе с обработкой жестов в приложении важно понимать некоторые термины и концепции. На этой странице объясняются термины "указатели", "события указателя" и "жесты", а также рассказывается о разных уровнях абстракции для жестов. Также в нем более подробно рассматриваются вопросы использования и распространения событий.

Определения

Чтобы понять, о чем идет речь на этой странице, ознакомьтесь с определениями некоторых терминов:

  • Указатель. Физический объект, который можно использовать для взаимодействия с приложением. На мобильных устройствах чаще всего используется палец, которым пользователь касается сенсорного экрана. Вместо пальца можно использовать стилус. На больших экранах можно использовать мышь или сенсорную панель, чтобы взаимодействовать с дисплеем. Устройство ввода должно быть способно "указывать" на координаты, чтобы считаться указателем. Например, клавиатура не может быть указателем. В Compose тип указателя включается в изменения указателя с помощью PointerType.
  • Событие указателя. Описывает взаимодействие низкого уровня одного или нескольких указателей с приложением в определенный момент времени. Любое взаимодействие с указателем, например прикосновение пальца к экрану или перетаскивание мыши, будет активировать событие. В Compose вся необходимая информация о таком событии содержится в классе PointerEvent.
  • Жест. Последовательность событий указателя, которую можно интерпретировать как одно действие. Например, нажатие можно рассматривать как последовательность событий down и up. В большинстве приложений используются стандартные жесты, такие как нажатие, перетаскивание и трансформация, но при необходимости можно создать собственный жест.

Разные уровни абстракции

Jetpack Compose предоставляет разные уровни абстракции для обработки жестов. На верхнем уровне находится поддержка компонентов. Компонуемые функции, такие как Button, автоматически поддерживают жесты. Чтобы добавить поддержку жестов в пользовательские компоненты, можно добавить модификаторы жестов, например clickable, в произвольные композиции. Если вам нужен специальный жест, используйте модификатор pointerInput.

Как правило, следует использовать самый высокий уровень абстракции, который предлагает нужные вам функции. Таким образом вы сможете воспользоваться преимуществами, которые дает этот уровень. Например, Button содержит больше семантической информации, используемой для обеспечения доступности, чем clickable, который, в свою очередь, содержит больше информации, чем реализация pointerInput без дополнительных атрибутов.

Поддержка компонентов

Многие готовые компоненты Compose включают встроенную обработку жестов. Например, компонент LazyColumn реагирует на перетаскивание прокруткой контента, Button показывает волну при нажатии, а компонент SwipeToDismiss содержит логику пролистывания для удаления элемента. Этот тип обработки жестов работает автоматически.

Помимо обработки внутренних жестов, многие компоненты также требуют, чтобы вызывающий объект обрабатывал жесты. Например, Button автоматически распознает нажатия и активирует событие клика. Вы передаете лямбда-функцию onClick в Button, чтобы реагировать на жесты. Аналогично, чтобы отреагировать на перетаскивание ползунка пользователем, добавьте лямбда-функцию onValueChange в Slider.

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

// Talkback: "Click me!, Button, double tap to activate"
Button(onClick = { /* TODO */ }) { Text("Click me!") }
// Talkback: "Click me!, double tap to activate"
Box(Modifier.clickable { /* TODO */ }) { Text("Click me!") }

Чтобы узнать больше о специальных возможностях в Compose, ознакомьтесь со статьей Специальные возможности в Compose.

Как добавить определенные жесты в произвольные композиции с помощью модификаторов

Модификаторы жестов можно применять к любой произвольной composable-функции, чтобы она прослушивала жесты. Например, вы можете разрешить общему элементу Box обрабатывать жесты нажатия, сделав его clickable, или разрешить элементу Column обрабатывать вертикальную прокрутку, применив verticalScroll.

Существует множество модификаторов для разных типов жестов:

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

Добавление пользовательского жеста к произвольным композициям с помощью модификатора pointerInput

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

В следующем коде выполняется прослушивание необработанных событий указателя:

@Composable
private fun LogPointerEvents(filter: PointerEventType? = null) {
    var log by remember { mutableStateOf("") }
    Column {
        Text(log)
        Box(
            Modifier
                .size(100.dp)
                .background(Color.Red)
                .pointerInput(filter) {
                    awaitPointerEventScope {
                        while (true) {
                            val event = awaitPointerEvent()
                            // handle pointer event
                            if (filter == null || event.type == filter) {
                                log = "${event.type}, ${event.changes.first().position}"
                            }
                        }
                    }
                }
        )
    }
}

Если разбить этот фрагмент, то основные компоненты будут следующими:

  • Модификатор pointerInput. Вы передаете ему один или несколько ключей. Когда значение одного из этих ключей меняется, лямбда-функция модификатора выполняется повторно. В семпле в composable-функцию передается необязательный фильтр. Если значение фильтра изменится, обработчик событий указателя нужно будет выполнить повторно, чтобы убедиться, что регистрируются правильные события.
  • awaitPointerEventScope создает область действия сопрограммы, которую можно использовать для ожидания событий указателя.
  • awaitPointerEvent приостанавливает работу сопрограммы до следующего события указателя.

Хотя прослушивание необработанных событий ввода – мощный инструмент, написать на его основе собственный жест довольно сложно. Чтобы упростить создание специальных жестов, доступно множество вспомогательных методов.

Распознавание жестов

Вместо того чтобы обрабатывать необработанные события указателя, вы можете отслеживать определенные жесты и реагировать на них. AwaitPointerEventScope предоставляет методы для прослушивания:

Это детекторы верхнего уровня, поэтому в один модификатор pointerInput нельзя добавить несколько детекторов. В приведенном ниже фрагменте кода регистрируются только нажатия, а не перетаскивания:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
                // Never reached
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Внутренне метод detectTapGestures блокирует сопрограмму, и второй детектор никогда не достигается. Если вам нужно добавить к composable-функции несколько прослушивателей жестов, используйте отдельные экземпляры модификатора pointerInput:

var log by remember { mutableStateOf("") }
Column {
    Text(log)
    Box(
        Modifier
            .size(100.dp)
            .background(Color.Red)
            .pointerInput(Unit) {
                detectTapGestures { log = "Tap!" }
            }
            .pointerInput(Unit) {
                // These drag events will correctly be triggered
                detectDragGestures { _, _ -> log = "Dragging" }
            }
    )
}

Обработка событий для каждого жеста

Жесты по определению начинаются с события нажатия указателя. Вместо цикла while(true), который проходит через каждое необработанное событие, можно использовать вспомогательный метод awaitEachGesture. Метод awaitEachGesture перезапускает содержащий блок, когда все указатели подняты, что означает завершение жеста:

@Composable
private fun SimpleClickable(onClick: () -> Unit) {
    Box(
        Modifier
            .size(100.dp)
            .pointerInput(onClick) {
                awaitEachGesture {
                    awaitFirstDown().also { it.consume() }
                    val up = waitForUpOrCancellation()
                    if (up != null) {
                        up.consume()
                        onClick()
                    }
                }
            }
    )
}

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

Ожидание определенного события или поджеста

Существует набор методов, которые помогают определить общие части жестов:

  • Приостановить до тех пор, пока указатель не будет опущен с помощью awaitFirstDown, или подождать, пока все указатели не будут подняты с помощью waitForUpOrCancellation.
  • Создайте слушателя перетаскивания низкого уровня, используя awaitTouchSlopOrCancellation и awaitDragOrCancellation. Сначала обработчик жестов приостанавливается, пока указатель не достигнет зоны нечувствительности, а затем приостанавливается до получения первого события перетаскивания. Если вас интересует перетаскивание только по одной оси, используйте awaitHorizontalTouchSlopOrCancellation и awaitHorizontalDragOrCancellation или awaitVerticalTouchSlopOrCancellation и awaitVerticalDragOrCancellation.
  • Приостановить до долгого нажатия с помощью awaitLongPressOrCancellation.
  • Используйте метод drag, чтобы непрерывно отслеживать события перетаскивания, или horizontalDrag или verticalDrag, чтобы отслеживать события перетаскивания по одной оси.

Как применять расчеты для событий с несколькими точками взаимодействия

Когда пользователь выполняет многопальцевый жест, используя более одного указателя, сложно понять, какое преобразование требуется, на основе необработанных значений. Если модификатор transformable или методы detectTransformGestures не позволяют вам достаточно точно управлять данными, вы можете отслеживать необработанные события и применять к ним нужные вычисления. Вспомогательные методы: calculateCentroid, calculateCentroidSize, calculatePan, calculateRotation и calculateZoom.

Отправка событий и проверка попадания

Не каждое событие указателя отправляется каждому модификатору pointerInput. Отправка событий работает следующим образом:

  • События указателя отправляются в иерархию композиции. Когда новый указатель вызывает первое событие указателя, система начинает проверку попадания для композиций, которые соответствуют требованиям. Composable-функция считается подходящей, если она может обрабатывать ввод с помощью указателя. Проверка попадания выполняется сверху вниз по дереву интерфейса. Компонуемый элемент считается "затронутым", если событие указателя произошло в его границах. В результате этого процесса создается цепочка композиций, которые успешно проходят проверку на попадание.
  • По умолчанию, если на одном уровне дерева есть несколько подходящих composable-функций, "обращение" регистрируется только для composable-функции с самым высоким значением Z-индекса. Например, если добавить в Box два перекрывающихся элемента Button, то события указателя будут получать только те, которые отрисованы поверх. Теоретически вы можете переопределить это поведение, создав собственную реализацию PointerInputModifierNode и установив для sharePointerInputWithSiblings значение true.
  • Последующие события для того же указателя отправляются в ту же цепочку композиций и обрабатываются в соответствии с логикой распространения событий. Система больше не выполняет проверку попадания для этого указателя. Это означает, что каждая composable-функция в цепочке получает все события для этого указателя, даже если они происходят за пределами границ этой composable-функции. Компонуемые функции, не входящие в цепочку, никогда не получают события указателя, даже если указатель находится в их границах.

События наведения, которые активируются при наведении курсора мыши или стилуса, являются исключением из правил, описанных в этом разделе. События наведения указателя отправляются в любой компонент, на который они попадают. Поэтому, когда пользователь перемещает указатель с границ одной composable-функции на границы другой, события отправляются не первой, а второй composable-функции.

Потребление событий

Если нескольким элементам composable назначен обработчик жестов, эти обработчики не должны конфликтовать. Например, посмотрите на этот интерфейс:

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

Когда пользователь нажимает кнопку добавления в закладки, лямбда-функция onClick обрабатывает это действие. Если пользователь нажмет на любую другую часть элемента списка, ListItem обработает это действие и перейдет к статье. При вводе с помощью указателя кнопка должна поглотить это событие, чтобы родительский элемент не реагировал на него. Жесты, включенные в стандартные компоненты и общие модификаторы жестов, включают такое поведение, но если вы создаете собственный жест, то должны вручную обрабатывать события. Для этого используйте метод PointerInputChange.consume:

Modifier.pointerInput(Unit) {

    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            // consume all changes
            event.changes.forEach { it.consume() }
        }
    }
}

Потребление события не останавливает его распространение на другие компоненты. Вместо этого composable-функция должна явно игнорировать использованные события. При создании собственных жестов проверяйте, не было ли событие уже использовано другим элементом:

Modifier.pointerInput(Unit) {
    awaitEachGesture {
        while (true) {
            val event = awaitPointerEvent()
            if (event.changes.any { it.isConsumed }) {
                // A pointer is consumed by another gesture handler
            } else {
                // Handle unconsumed event
            }
        }
    }
}

Распространение событий

Как уже упоминалось, изменения указателя передаются каждой composable-функции, которую он затрагивает. Но если таких composable-функций несколько, в каком порядке распространяются события? Если взять пример из предыдущего раздела, то этот интерфейс будет представлен следующим деревом, в котором только элементы ListItem и Button реагируют на события указателя:

Структура дерева. Верхний слой – ListItem, второй слой – Image, Column и Button, а Column разделен на два элемента Text. Выделены элементы ListItem и Button.

События указателя проходят через каждый из этих компонентов три раза, во время трех "проходов":

  • На первом этапе поток событий идет от верхней части дерева UI к нижней. Этот процесс позволяет родительскому элементу перехватить событие до того, как его обработает дочерний элемент. Например, всплывающие подсказки должны перехватывать долгое нажатие, а не передавать его дочерним элементам. В нашем примере ListItem получает событие раньше, чем Button.
  • В основном проходе события передаются от конечных узлов дерева интерфейса к его корню. На этом этапе обычно обрабатываются жесты. Он используется по умолчанию при прослушивании событий. Обработка жестов на этом этапе означает, что приоритет имеют конечные узлы, а не их родительские элементы. Это наиболее логичное поведение для большинства жестов. В нашем примере Button получает событие раньше, чем ListItem.
  • На этапе Final pass событие ещё раз проходит по дереву интерфейса от верхних узлов к конечным. Этот поток позволяет элементам, расположенным выше в стеке, реагировать на события, потребляемые их родительским элементом. Например, кнопка удаляет индикацию волны, когда нажатие превращается в перетаскивание ее родительского элемента с возможностью прокрутки.

Визуально поток событий можно представить следующим образом:

После того как входные данные будут обработаны, эта информация будет передаваться дальше по цепочке:

В коде можно указать, какой именно билет вам нужен:

Modifier.pointerInput(Unit) {
    awaitPointerEventScope {
        val eventOnInitialPass = awaitPointerEvent(PointerEventPass.Initial)
        val eventOnMainPass = awaitPointerEvent(PointerEventPass.Main) // default
        val eventOnFinalPass = awaitPointerEvent(PointerEventPass.Final)
    }
}

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

Как проверить жесты

В методах тестирования можно вручную отправлять события указателя с помощью метода performTouchInput. Это позволяет выполнять как высокоуровневые жесты (например, сведение пальцев или долгое нажатие), так и низкоуровневые (например, перемещение курсора на определенное количество пикселей):

composeTestRule.onNodeWithTag("MyList").performTouchInput {
    swipeUp()
    swipeDown()
    click()
}

Другие примеры можно найти в документации по performTouchInput.

Подробнее…

Подробнее о жестах в Jetpack Compose можно узнать из следующих ресурсов: