Обработка взаимодействия с пользователем

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

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

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

Взаимодействия

Во многих случаях вам не нужно знать, как ваш компонент Compose интерпретирует взаимодействие с пользователем. Например, Button использует Modifier.clickable чтобы выяснить, нажал ли пользователь кнопку. Если вы добавляете в свое приложение типичную кнопку, вы можете определить код onClick кнопки, и Modifier.clickable запускает этот код, когда это необходимо. Это означает, что вам не нужно знать, коснулся ли пользователь экрана или нажал кнопку с помощью клавиатуры; Modifier.clickable определяет, что пользователь выполнил щелчок, и отвечает запуском вашего кода onClick .

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

Когда пользователь взаимодействует с компонентом пользовательского интерфейса, система представляет его поведение, генерируя ряд событий Interaction . Например, если пользователь касается кнопки, кнопка генерирует PressInteraction.Press . Если пользователь поднимает палец внутри кнопки, он генерирует PressInteraction.Release , сообщая кнопке, что нажатие завершено. С другой стороны, если пользователь выводит палец за пределы кнопки, а затем поднимает палец, кнопка генерирует PressInteraction.Cancel , чтобы указать, что нажатие кнопки было отменено, а не завершено.

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

Эти взаимодействия обычно происходят парами, с началом и концом. Второе взаимодействие содержит ссылку на первое. Например, если пользователь касается кнопки, а затем поднимает палец, это прикосновение создает взаимодействие PressInteraction.Press , а отпускание создает PressInteraction.Release ; Release имеет свойство press , идентифицирующее исходный PressInteraction.Press .

Вы можете увидеть взаимодействия для конкретного компонента, наблюдая за его InteractionSource . InteractionSource построен на основе потоков Kotlin , поэтому вы можете собирать взаимодействия из него так же, как и с любым другим потоком. Дополнительную информацию об этом дизайнерском решении можно найти в записи блога Illumination Interactions .

Состояние взаимодействия

Возможно, вы захотите расширить встроенную функциональность ваших компонентов, также самостоятельно отслеживая взаимодействия. Например, возможно, вы хотите, чтобы кнопка меняла цвет при нажатии. Самый простой способ отслеживать взаимодействия — наблюдать за соответствующим состоянием взаимодействия. InteractionSource предлагает ряд методов, которые раскрывают различные статусы взаимодействия как состояния. Например, если вы хотите узнать, нажата ли определенная кнопка, вы можете вызвать ее метод InteractionSource.collectIsPressedAsState() :

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(
    onClick = { /* do something */ },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Помимо collectIsPressedAsState() , Compose также предоставляет collectIsFocusedAsState() , collectIsDraggedAsState() и collectIsHoveredAsState() . Эти методы на самом деле являются удобными методами, созданными на основе API-интерфейсов InteractionSource более низкого уровня. В некоторых случаях вы можете захотеть использовать эти функции более низкого уровня напрямую.

Например, предположим, что вам нужно знать, нажимается ли кнопка, а также перетаскивается ли она. Если вы используете и collectIsPressedAsState() , и collectIsDraggedAsState() , Compose выполняет много дублирующей работы, и нет никакой гарантии, что вы получите все взаимодействия в правильном порядке. В подобных ситуациях вы можете работать напрямую с InteractionSource . Дополнительные сведения о самостоятельном отслеживании взаимодействий с помощью InteractionSource см. в разделе Работа с InteractionSource .

В следующем разделе описывается, как использовать и генерировать взаимодействия с InteractionSource и MutableInteractionSource соответственно.

Потребляйте и излучайте Interaction

InteractionSource представляет собой поток Interactions , доступный только для чтения — невозможно передать Interaction в InteractionSource . Чтобы генерировать Interaction , вам нужно использовать MutableInteractionSource , который является наследником InteractionSource .

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

Пример использования модификатора

Для модификатора, который рисует границу для сфокусированного состояния, вам нужно только наблюдать Interactions , поэтому вы можете принять InteractionSource :

fun Modifier.focusBorder(interactionSource: InteractionSource): Modifier {
    // ...
}

Из сигнатуры функции ясно, что этот модификатор является потребителем — он может потреблять Interaction , но не может их испускать.

Пример создания модификатора

Для модификатора, который обрабатывает события наведения, например Modifier.hoverable , вам необходимо создать Interactions и вместо этого принять MutableInteractionSource в качестве параметра:

fun Modifier.hover(interactionSource: MutableInteractionSource, enabled: Boolean): Modifier {
    // ...
}

Этот модификатор является производителем — он может использовать предоставленный MutableInteractionSource для генерации HoverInteractions при наведении или снятии курсора.

Создавайте компоненты, которые потребляют и производят

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

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,

    // exposes MutableInteractionSource as a parameter
    interactionSource: MutableInteractionSource? = null,

    elevation: ButtonElevation? = ButtonDefaults.elevatedButtonElevation(),
    shape: Shape = MaterialTheme.shapes.small,
    border: BorderStroke? = null,
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
    content: @Composable RowScope.() -> Unit
) { /* content() */ }

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

Если вы создаете свои собственные интерактивные компоненты высокого уровня, мы рекомендуем вам таким образом предоставить MutableInteractionSource в качестве параметра . Помимо следования передовым практикам поднятия состояния, это также упрощает чтение и управление визуальным состоянием компонента так же, как можно читать и контролировать любой другой тип состояния (например, включенное состояние).

Compose следует многоуровневому архитектурному подходу , поэтому компоненты Material высокого уровня строятся поверх базовых строительных блоков, которые создают Interaction , необходимые для управления рябью и другими визуальными эффектами. Базовая библиотека предоставляет модификаторы взаимодействия высокого уровня, такие как Modifier.hoverable , Modifier.focusable и Modifier.draggable .

Чтобы создать компонент, реагирующий на события наведения, вы можете просто использовать Modifier.hoverable и передать MutableInteractionSource в качестве параметра. Всякий раз, когда на компонент наводится курсор, он генерирует HoverInteraction , и вы можете использовать это, чтобы изменить внешний вид компонента.

// This InteractionSource will emit hover interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Чтобы сделать этот компонент фокусируемым, вы можете добавить Modifier.focusable и передать тот же MutableInteractionSource в качестве параметра. Теперь и HoverInteraction.Enter/Exit , и FocusInteraction.Focus/Unfocus создаются через один и тот же MutableInteractionSource , и вы можете настроить внешний вид для обоих типов взаимодействия в одном и том же месте:

// This InteractionSource will emit hover and focus interactions
val interactionSource = remember { MutableInteractionSource() }

Box(
    Modifier
        .size(100.dp)
        .hoverable(interactionSource = interactionSource)
        .focusable(interactionSource = interactionSource),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Modifier.clickable — это абстракция еще более высокого уровня, чем hoverable и focusable : чтобы компонент был кликабельным, он неявно доступен для наведения, а компоненты, на которые можно щелкнуть, также должны быть фокусируемыми. Вы можете использовать Modifier.clickable для создания компонента, который обрабатывает взаимодействие при наведении, фокусе и нажатии без необходимости объединения API более низкого уровня. Если вы хотите, чтобы ваш компонент также был кликабельным, вы можете заменить hoverable и focusable на clickable :

// This InteractionSource will emit hover, focus, and press interactions
val interactionSource = remember { MutableInteractionSource() }
Box(
    Modifier
        .size(100.dp)
        .clickable(
            onClick = {},
            interactionSource = interactionSource,

            // Also show a ripple effect
            indication = ripple()
        ),
    contentAlignment = Alignment.Center
) {
    Text("Hello!")
}

Работа с InteractionSource

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

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
        }
    }
}

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

val interactionSource = remember { MutableInteractionSource() }
val interactions = remember { mutableStateListOf<Interaction>() }

LaunchedEffect(interactionSource) {
    interactionSource.interactions.collect { interaction ->
        when (interaction) {
            is PressInteraction.Press -> {
                interactions.add(interaction)
            }
            is PressInteraction.Release -> {
                interactions.remove(interaction.press)
            }
            is PressInteraction.Cancel -> {
                interactions.remove(interaction.press)
            }
            is DragInteraction.Start -> {
                interactions.add(interaction)
            }
            is DragInteraction.Stop -> {
                interactions.remove(interaction.start)
            }
            is DragInteraction.Cancel -> {
                interactions.remove(interaction.start)
            }
        }
    }
}

Теперь, если вы хотите узнать, нажимается или перетаскивается компонент в данный момент, все, что вам нужно сделать, это проверить, пусто ли interactions :

val isPressedOrDragged = interactions.isNotEmpty()

Если вы хотите узнать, каким было последнее взаимодействие, просто посмотрите на последний элемент в списке. Например, вот как реализация пульсации Compose определяет подходящее наложение состояния, которое будет использоваться для самого последнего взаимодействия:

val lastInteraction = when (interactions.lastOrNull()) {
    is DragInteraction.Start -> "Dragged"
    is PressInteraction.Press -> "Pressed"
    else -> "No state"
}

Поскольку все Interaction следуют одной и той же структуре, при работе с разными типами взаимодействия с пользователем нет большой разницы в коде — общий шаблон один и тот же.

Обратите внимание, что предыдущие примеры в этом разделе представляют Flow взаимодействий с использованием State — это позволяет легко наблюдать за обновленными значениями, поскольку чтение значения состояния автоматически вызывает рекомпозицию. Однако композиция группируется предварительно. Это означает, что если состояние изменится, а затем изменится обратно в том же кадре, компоненты, наблюдающие за состоянием, не увидят изменения.

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

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()

Button(onClick = { /* do something */ }, interactionSource = interactionSource) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

Если нажатие начинается и заканчивается в одном и том же фрейме, текст никогда не будет отображаться как «Нажато!». В большинстве случаев это не проблема — отображение визуального эффекта в течение такого небольшого промежутка времени приведет к мерцанию и не будет очень заметно для пользователя. В некоторых случаях, например при отображении волнового эффекта или подобной анимации, вам может потребоваться отображать эффект хотя бы минимальное время, а не немедленно останавливать его, если кнопка больше не нажимается. Для этого вы можете напрямую запускать и останавливать анимацию изнутри лямбды-сборки вместо записи в состояние. Пример этого шаблона приведен в разделе «Создание расширенной Indication с анимированной рамкой» .

Пример. Создание компонента с настраиваемой обработкой взаимодействия.

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

Анимация кнопки, которая при нажатии динамически добавляет значок корзины с продуктами
Рисунок 3. Кнопка, которая динамически добавляет значок при нажатии.

Для этого создайте собственный компонуемый элемент на основе Button и попросите его использовать дополнительный параметр icon для рисования значка (в данном случае корзины покупок). Вы вызываете collectIsPressedAsState() чтобы отслеживать, наводит ли пользователь курсор на кнопку; когда они есть, вы добавляете значок. Вот как выглядит код:

@Composable
fun PressIconButton(
    onClick: () -> Unit,
    icon: @Composable () -> Unit,
    text: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource? = null
) {
    val isPressed = interactionSource?.collectIsPressedAsState()?.value ?: false

    Button(
        onClick = onClick,
        modifier = modifier,
        interactionSource = interactionSource
    ) {
        AnimatedVisibility(visible = isPressed) {
            if (isPressed) {
                Row {
                    icon()
                    Spacer(Modifier.size(ButtonDefaults.IconSpacing))
                }
            }
        }
        text()
    }
}

И вот как выглядит использование этого нового составного объекта:

PressIconButton(
    onClick = {},
    icon = { Icon(Icons.Filled.ShoppingCart, contentDescription = null) },
    text = { Text("Add to cart") }
)

Поскольку этот новый PressIconButton построен поверх существующей Material Button , он реагирует на действия пользователя всеми обычными способами. Когда пользователь нажимает кнопку, она слегка меняет свою непрозрачность, как и обычная Button материала.

Создайте и примените многоразовый пользовательский эффект с помощью Indication

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

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

  • Каждому компоненту системы дизайна нужен один и тот же шаблон.
  • Легко забыть применить этот эффект к вновь созданным компонентам и пользовательским кликабельным компонентам.
  • Может быть сложно объединить пользовательский эффект с другими эффектами.

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

  • IndicationNodeFactory : фабрика, создающая экземпляры Modifier.Node , визуализирующие визуальные эффекты для компонента. Для более простых реализаций, которые не изменяются в разных компонентах, это может быть одиночный элемент (объект), который можно повторно использовать во всем приложении.

    Эти экземпляры могут быть с сохранением или без сохранения состояния. Поскольку они создаются для каждого компонента, они могут получать значения из CompositionLocal , чтобы изменить их внешний вид или поведение внутри определенного компонента, как и любой другой Modifier.Node .

  • Modifier.indication : модификатор, отображающий Indication для компонента. Modifier.clickable и другие модификаторы взаимодействия высокого уровня напрямую принимают параметр индикации, поэтому они не только излучают Interaction , но также могут рисовать визуальные эффекты для Interaction , которые они излучают. Итак, в простых случаях вы можете просто использовать Modifier.clickable без необходимости Modifier.indication .

Заменить эффект Indication

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

Следующий код создает кнопку, которая уменьшается при нажатии:

val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(targetValue = if (isPressed) 0.9f else 1f, label = "scale")

Button(
    modifier = Modifier.scale(scale),
    onClick = { },
    interactionSource = interactionSource
) {
    Text(if (isPressed) "Pressed!" else "Not pressed")
}

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

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

    Узлу необходимо реализовать DrawModifierNode , чтобы он мог переопределить ContentDrawScope#draw() и визуализировать эффект масштабирования, используя те же команды рисования, что и для любого другого графического API в Compose.

    Вызов drawContent() , доступный из приемника ContentDrawScope , отрисует фактический компонент, к которому должно быть применено Indication , поэтому вам просто нужно вызвать эту функцию в рамках преобразования масштаба. Убедитесь, что ваши реализации Indication всегда в какой-то момент вызывают drawContent() ; в противном случае компонент, к которому вы применяете Indication , не будет нарисован.

    private class ScaleNode(private val interactionSource: InteractionSource) :
        Modifier.Node(), DrawModifierNode {
    
        var currentPressPosition: Offset = Offset.Zero
        val animatedScalePercent = Animatable(1f)
    
        private suspend fun animateToPressed(pressPosition: Offset) {
            currentPressPosition = pressPosition
            animatedScalePercent.animateTo(0.9f, spring())
        }
    
        private suspend fun animateToResting() {
            animatedScalePercent.animateTo(1f, spring())
        }
    
        override fun onAttach() {
            coroutineScope.launch {
                interactionSource.interactions.collectLatest { interaction ->
                    when (interaction) {
                        is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                        is PressInteraction.Release -> animateToResting()
                        is PressInteraction.Cancel -> animateToResting()
                    }
                }
            }
        }
    
        override fun ContentDrawScope.draw() {
            scale(
                scale = animatedScalePercent.value,
                pivot = currentPressPosition
            ) {
                this@draw.drawContent()
            }
        }
    }

  2. Создайте IndicationNodeFactory . Его единственная обязанность — создать новый экземпляр узла для предоставленного источника взаимодействия. Поскольку параметров для настройки индикации нет, фабрикой может быть объект:

    object ScaleIndication : IndicationNodeFactory {
        override fun create(interactionSource: InteractionSource): DelegatableNode {
            return ScaleNode(interactionSource)
        }
    
        override fun equals(other: Any?): Boolean = other === ScaleIndication
        override fun hashCode() = 100
    }

  3. Modifier.clickable использует Modifier.indication внутри себя, поэтому, чтобы создать кликабельный компонент с ScaleIndication , все, что вам нужно сделать, это предоставить Indication в качестве параметра для clickable :

    Box(
        modifier = Modifier
            .size(100.dp)
            .clickable(
                onClick = {},
                indication = ScaleIndication,
                interactionSource = null
            )
            .background(Color.Blue),
        contentAlignment = Alignment.Center
    ) {
        Text("Hello!", color = Color.White)
    }

    Это также упрощает создание высокоуровневых многоразовых компонентов с использованием пользовательской Indication — кнопка может выглядеть так:

    @Composable
    fun ScaleButton(
        onClick: () -> Unit,
        modifier: Modifier = Modifier,
        enabled: Boolean = true,
        interactionSource: MutableInteractionSource? = null,
        shape: Shape = CircleShape,
        content: @Composable RowScope.() -> Unit
    ) {
        Row(
            modifier = modifier
                .defaultMinSize(minWidth = 76.dp, minHeight = 48.dp)
                .clickable(
                    enabled = enabled,
                    indication = ScaleIndication,
                    interactionSource = interactionSource,
                    onClick = onClick
                )
                .border(width = 2.dp, color = Color.Blue, shape = shape)
                .padding(horizontal = 16.dp, vertical = 8.dp),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
            content = content
        )
    }

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

ScaleButton(onClick = {}) {
    Icon(Icons.Filled.ShoppingCart, "")
    Spacer(Modifier.padding(10.dp))
    Text(text = "Add to cart!")
}

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

Создайте расширенную Indication с анимированной рамкой.

Indication не ограничивается только эффектами преобразования, такими как масштабирование компонента. Поскольку IndicationNodeFactory возвращает Modifier.Node , вы можете нарисовать любой эффект над или под содержимым, как и в случае с другими API-интерфейсами рисования. Например, вы можете нарисовать анимированную рамку вокруг компонента и наложение поверх компонента при его нажатии:

Кнопка с причудливым эффектом радуги при нажатии.
Рисунок 5. Эффект анимированной границы, нарисованный с помощью Indication .

Реализация Indication здесь очень похожа на предыдущий пример — она просто создает узел с некоторыми параметрами. Поскольку анимированная граница зависит от формы и границы компонента, для которого используется Indication , реализация Indication также требует указания формы и ширины границы в качестве параметров:

data class NeonIndication(private val shape: Shape, private val borderWidth: Dp) : IndicationNodeFactory {

    override fun create(interactionSource: InteractionSource): DelegatableNode {
        return NeonNode(
            shape,
            // Double the border size for a stronger press effect
            borderWidth * 2,
            interactionSource
        )
    }
}

Реализация Modifier.Node концептуально такая же, даже если код рисования более сложен. Как и прежде, он наблюдает за присоединенным InteractionSource , запускает анимацию и реализует DrawModifierNode для отрисовки эффекта поверх содержимого:

private class NeonNode(
    private val shape: Shape,
    private val borderWidth: Dp,
    private val interactionSource: InteractionSource
) : Modifier.Node(), DrawModifierNode {
    var currentPressPosition: Offset = Offset.Zero
    val animatedProgress = Animatable(0f)
    val animatedPressAlpha = Animatable(1f)

    var pressedAnimation: Job? = null
    var restingAnimation: Job? = null

    private suspend fun animateToPressed(pressPosition: Offset) {
        // Finish any existing animations, in case of a new press while we are still showing
        // an animation for a previous one
        restingAnimation?.cancel()
        pressedAnimation?.cancel()
        pressedAnimation = coroutineScope.launch {
            currentPressPosition = pressPosition
            animatedPressAlpha.snapTo(1f)
            animatedProgress.snapTo(0f)
            animatedProgress.animateTo(1f, tween(450))
        }
    }

    private fun animateToResting() {
        restingAnimation = coroutineScope.launch {
            // Wait for the existing press animation to finish if it is still ongoing
            pressedAnimation?.join()
            animatedPressAlpha.animateTo(0f, tween(250))
            animatedProgress.snapTo(0f)
        }
    }

    override fun onAttach() {
        coroutineScope.launch {
            interactionSource.interactions.collect { interaction ->
                when (interaction) {
                    is PressInteraction.Press -> animateToPressed(interaction.pressPosition)
                    is PressInteraction.Release -> animateToResting()
                    is PressInteraction.Cancel -> animateToResting()
                }
            }
        }
    }

    override fun ContentDrawScope.draw() {
        val (startPosition, endPosition) = calculateGradientStartAndEndFromPressPosition(
            currentPressPosition, size
        )
        val brush = animateBrush(
            startPosition = startPosition,
            endPosition = endPosition,
            progress = animatedProgress.value
        )
        val alpha = animatedPressAlpha.value

        drawContent()

        val outline = shape.createOutline(size, layoutDirection, this)
        // Draw overlay on top of content
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha * 0.1f
        )
        // Draw border on top of overlay
        drawOutline(
            outline = outline,
            brush = brush,
            alpha = alpha,
            style = Stroke(width = borderWidth.toPx())
        )
    }

    /**
     * Calculates a gradient start / end where start is the point on the bounding rectangle of
     * size [size] that intercepts with the line drawn from the center to [pressPosition],
     * and end is the intercept on the opposite end of that line.
     */
    private fun calculateGradientStartAndEndFromPressPosition(
        pressPosition: Offset,
        size: Size
    ): Pair<Offset, Offset> {
        // Convert to offset from the center
        val offset = pressPosition - size.center
        // y = mx + c, c is 0, so just test for x and y to see where the intercept is
        val gradient = offset.y / offset.x
        // We are starting from the center, so halve the width and height - convert the sign
        // to match the offset
        val width = (size.width / 2f) * sign(offset.x)
        val height = (size.height / 2f) * sign(offset.y)
        val x = height / gradient
        val y = gradient * width

        // Figure out which intercept lies within bounds
        val intercept = if (abs(y) <= abs(height)) {
            Offset(width, y)
        } else {
            Offset(x, height)
        }

        // Convert back to offsets from 0,0
        val start = intercept + size.center
        val end = Offset(size.width - start.x, size.height - start.y)
        return start to end
    }

    private fun animateBrush(
        startPosition: Offset,
        endPosition: Offset,
        progress: Float
    ): Brush {
        if (progress == 0f) return TransparentBrush

        // This is *expensive* - we are doing a lot of allocations on each animation frame. To
        // recreate a similar effect in a performant way, it would be better to create one large
        // gradient and translate it on each frame, instead of creating a whole new gradient
        // and shader. The current approach will be janky!
        val colorStops = buildList {
            when {
                progress < 1 / 6f -> {
                    val adjustedProgress = progress * 6f
                    add(0f to Blue)
                    add(adjustedProgress to Color.Transparent)
                }
                progress < 2 / 6f -> {
                    val adjustedProgress = (progress - 1 / 6f) * 6f
                    add(0f to Purple)
                    add(adjustedProgress * MaxBlueStop to Blue)
                    add(adjustedProgress to Blue)
                    add(1f to Color.Transparent)
                }
                progress < 3 / 6f -> {
                    val adjustedProgress = (progress - 2 / 6f) * 6f
                    add(0f to Pink)
                    add(adjustedProgress * MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 4 / 6f -> {
                    val adjustedProgress = (progress - 3 / 6f) * 6f
                    add(0f to Orange)
                    add(adjustedProgress * MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                progress < 5 / 6f -> {
                    val adjustedProgress = (progress - 4 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
                else -> {
                    val adjustedProgress = (progress - 5 / 6f) * 6f
                    add(0f to Yellow)
                    add(adjustedProgress * MaxYellowStop to Yellow)
                    add(MaxOrangeStop to Orange)
                    add(MaxPinkStop to Pink)
                    add(MaxPurpleStop to Purple)
                    add(MaxBlueStop to Blue)
                    add(1f to Blue)
                }
            }
        }

        return linearGradient(
            colorStops = colorStops.toTypedArray(),
            start = startPosition,
            end = endPosition
        )
    }

    companion object {
        val TransparentBrush = SolidColor(Color.Transparent)
        val Blue = Color(0xFF30C0D8)
        val Purple = Color(0xFF7848A8)
        val Pink = Color(0xFFF03078)
        val Orange = Color(0xFFF07800)
        val Yellow = Color(0xFFF0D800)
        const val MaxYellowStop = 0.16f
        const val MaxOrangeStop = 0.33f
        const val MaxPinkStop = 0.5f
        const val MaxPurpleStop = 0.67f
        const val MaxBlueStop = 0.83f
    }
}

Основное отличие здесь в том, что теперь существует минимальная продолжительность анимации с помощью функции animateToResting() , поэтому даже если нажатие будет немедленно отпущено, анимация нажатия продолжится. Также имеется обработка нескольких быстрых нажатий в начале animateToPressed — если нажатие происходит во время существующей анимации нажатия или покоя, предыдущая анимация отменяется, и анимация нажатия начинается с начала. Для поддержки нескольких одновременных эффектов (например, ряби, когда новая анимация ряби будет рисоваться поверх других ряби), вы можете отслеживать анимации в списке вместо того, чтобы отменять существующие анимации и запускать новые.

{% дословно %} {% дословно %} {% дословно %} {% дословно %}