При работе с Jetpack Compose и Compose Multiplatform разработчики часто не замечают, как элементы Material и Material 3 дизайн-систем вплетаются в их код. Один из таких элементов - это индикация клика, реализованная в Material как круги на воде (англ. ripple effect). В этой статье мы разберем, в чем недостатки дефолтной реализации риппл-эффекта в Compose и как сделать свою.

А зачем?
Прежде, чем писать код, зададимся вопросом: а точно ли нам это нужно? Чем нас не устраивает реализация риппл-эффекта по умолчанию? Мне кажется, у индикации клика из Material в Compose есть ряд заметных недостатков:
В Android риппл-эффект зависит от версии SDK. Так, в Android 15 он очень заметен, в Android 13 его почти не видно, а в Android 8 вы увидите четко очерченные круги. Это происходит от того, что под капотом
ripple()
из Material использует RippleView, который в свою очередь поставляется Android SDK. Такое поведение дает нативный "look and feel", но если вы реализуете свою дизайн-систему, это может пойти в разрез с гайдлайнами и макетами вашего дизайнера.Если вы используете Compose Multiplatform - риппл-эффект будет отличаться еще и между платформами: на Android он будет нативным, а на iOS и всех остальных платформах (Desktop, WASM/JS) будет реализован в виде очень четко очерченных расходящихся кругов наподобие Android 8. На iOS это выглядит далеко не нативно, особенно если сравнивать с обычным эффектом нажатия на
Button
в SwiftUI (об этом ниже). Это связано с тем, что для всех Skiko таргетов (то есть всех, кроме Android) используется единый RippleNode авторства Jetbrains.

Как решать?
Итак, мы принимаем (или не принимаем) решение, что перечисленные проблемы для нас существенны и хотим их устранить. По мне ими можно и пренебречь, правда на iOS риппл-эффект выглядит действительно уродливо и ненативно. Далее речь пойдет о том, как сделать индикацию клика единой на всех платформах.
Вообще в Compose за это отвечает специальный CompositionLocal
- LocalIndication
. Переопределив его один раз, вы измените поведение всех вызовов Modifier.clickable()
. Когда вы вызываете Composable-обертку MaterialTheme {}
, это и происходит, но подставляется обычная индикация ripple()
.
Чтобы написать собственный Indication, пойдем по проторенному пути и напишем свою реализацию IndicationNodeFactory
. Именно это и сделали разработчики Compose Multiplatform из Jetbrains, когда им нужно было портировать Material на другие платформы помимо Android. Весь код здесь я приводить не буду, он сильно дублируется с открытым кодом из Compose Multiplatform (который тем не менее весь internal или private, поэтому напрямую мы его вызывать не можем).
Под капотом архитектура нашего Indication будет выглядеть так:
Фабрика
RippleNodeFactory
создает узлы модификатораDelegatingRippleNode
. Фабрика нужна для того, чтобы узлы не пересоздавались при рекомпозиции, а переиспользовались, если параметры не были изменены.DelegatingRippleNode
читает параметры иCompositionLocal
, а именноLocalRippleConfiguration
, и создает делегируемый узел, если конфигурация есть, и удаляет делегат, если она null.Делегируемым узлом в нашем случае будет
CommonRippleNode
, который наследуетDrawModifierNode
и благодаря этому мы можем рисовать индикацию вDrawScope
. Он хранит хэшмапу объектовRippleAnimation
, каждый из которых рисует свой "круг на воде" с анимацией.
В этом классе в реализации Compose Multiplatform происходит самое для нас интересное:
fun DrawScope.draw(color: Color) {
if (startRadius == null) {
startRadius = getRippleStartRadius(size)
}
if (origin == null) {
origin = center
}
if (targetCenter == null) {
targetCenter = Offset(size.width / 2.0f, size.height / 2.0f)
}
val alpha =
if (finishRequested && !finishedFadingIn) {
// If we are still fading-in we should immediately switch to the final alpha.
1f
} else {
animatedAlpha.value
}
val radius = lerp(startRadius!!, radius, animatedRadiusPercent.value)
val centerOffset =
Offset(
lerp(origin!!.x, targetCenter!!.x, animatedCenterPercent.value),
lerp(origin!!.y, targetCenter!!.y, animatedCenterPercent.value),
)
val modulatedColor = color.copy(alpha = color.alpha * alpha)
if (bounded) {
clipRect { drawCircle(modulatedColor, radius, centerOffset) }
} else {
drawCircle(modulatedColor, radius, centerOffset)
}
}
Здесь происходит много чего, но ключевое для нас - это последние 5 строк кода, где круги рисуются... просто через drawCircle
. Да, самым обычным, даже примитивным методом DrawScope
.

Эта реализация уже может работать одинаково на всех платформах, но из-за того, что она обернута в expect/actual и ограничена модификаторами доступа, мы не можем это даже переопределить. Придется копировать с небольшими изменениями.
Android 15
Чтобы сделать реализацию из Android 15 по-настоящему универсальной, нам нужно всего лишь заменить этот метод таким же, но с градиентом:
val SmoothRippleCommand: RippleDrawCommand = { color, center, radius ->
val correctedRadius = radius * 1.5f
drawCircle(
brush = Brush.radialGradient(
listOf(
color,
color,
Color.Transparent
),
center = center,
radius = correctedRadius
),
radius = correctedRadius,
center = center
)
}
Думаю, нет смысла подробно разбирать этот код. По сути, мы просто смегчаем очертания кругов при помощи кругового градиента. Смягчение происходит на одну треть. Это позволяет сделать реализацию максимально похожей на Android 15.
Реализация для iOS
Если вы работали со SwiftUI, то знаете, что в этом UI-фреймворке эффект нажатия на кнопку сильно отличается от того, который используется в Android Compose: кнопка при нажатии мгновенно выцветает (становится полупрозрачной), а когда мы ее отпускаем - анимированно возвращается к исходному состоянию.

Если ваша цель - создать иллюзию "бесшовного" встраивания кода на Compose Multiplatform в iOS-приложение, то потребуется совершенно иная реализация IndicationNodeFactory
с другим ModifierNode
.
Это может показаться странным, но чтобы изменить прозрачность элемента в Compose под капотом используется LayoutModifierNode
и дальше лишь применяется функция-расширение placeWithLayer
, чтобы изменить состояние GraphicsLayer
при рендере:
class OpacityRippleNode(
private val interactionSource: InteractionSource,
private val fadeInSpec: FiniteAnimationSpec<Float>,
private val fadeOutSpec: FiniteAnimationSpec<Float>,
private val minAlpha: Float
) : Modifier.Node(),
DelegatableNode,
LayoutModifierNode {
val animatedAlpha = Animatable(1f)
override val shouldAutoInvalidate: Boolean = false
private val layerBlock: GraphicsLayerScope.() -> Unit = {
alpha = animatedAlpha.value
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
placeable.placeWithLayer(0, 0, layerBlock = layerBlock)
}
}
private var pressJob: Job? = null
override fun onAttach() {
coroutineScope.launch {
interactionSource.interactions
.filterIsInstance<PressInteraction>()
.collect { interaction ->
handlePressInteraction(interaction)
}
}
}
private fun (
pressInteraction: PressInteraction
) {
pressJob?.cancel()
pressJob = coroutineScope.launch {
when (pressInteraction) {
is PressInteraction.Press -> fadeOut()
is PressInteraction.Release,
is PressInteraction.Cancel -> fadeIn()
}
}
}
private suspend fun fadeOut() {
animatedAlpha.animateTo(
targetValue = minAlpha,
animationSpec = fadeOutSpec
)
}
private suspend fun fadeIn() {
animatedAlpha.animateTo(
targetValue = 1f,
animationSpec = fadeInSpec
)
}
}
В этом классе все довольно просто и понятно. Все нажатия воспринимаются как череда сменяющих друг друга нажатий и "отжатий" (простите мой французкий). Если предыдущее взаимодействие происходит быстрее, чем отыграла анимация - предыдущая анимация отменяется.
Скрытый текст
В данном случае, вероятно, можно было бы использовать collectLatest
, однако я заметил, что в этом случае самый первый PressInteraction
всегда игнорируется. Поэтому пришлось взять джобу анимации под ручное управление. Возможно, позднее я найду лучшее решение.
Теперь, чтобы сымитировать поведение SwiftUI-кнопки на Compose Multiplatform, нам достаточно вызвать следующее переопредение LocalIndication
:
LocalIndication provides opacityRipple(
fadeInDuration = 280,
fadeOutDuration = 0,
minAlpha = 0.25f
)
Библиотека Rippler
Для того, чтобы обернуть вышеописанные реализации в единое удобное API, я опубликовал их в виде отдельной компактной (буквально 1300 строк кода) библиотеки под названием Rippler.
Библиотека предоставляет возможности кастомизировать начальный радиус расходящихся кругов от клика по кнопке, длительность анимации кругов и даже конкретный метод поверх DrawScope
, которым можно рисовать самые разнообразные формы в качестве риппл-эффекта.
Наиболее продвинутая анимация, которой можно достигнуть при помощи библиотеки - waterRipple
, анимация, приближенная к реальным кругам на воде:

Что в итоге?
В качестве итогов отмечу, что Compose предоставляет очень ограниченные возможности для создания кастомных индикаций нажатия из коробки (ripple из Material является по сути единственной). На самом деле, чтобы это исправить, требуется не так много кода. С целью продемонстрировать эти возможности была написана эта статья и библиотека Rippler.
Напоследок хотелось бы отметить несколько разрозненных, но важных наблюдений, которые всплыли в ходе рисерча для статьи и создания библиотеки:
Если вы используете Compose и при этом реализуете свою дизайн-систему, обращайте внимание на источник используемых вами API: так,
clickable
иLocalIndication
свободны от Material зависимостей, аripple
и, скажем,Button
- полностью завязан на Material (точно так же, какText()
идет из Material, аBasicText()
- из Foundation).Чтобы переопределить индикацию нажатий во всех модулях, свободных от Material, достаточно подменить
LocalIndication
. Однако если вы не свободны от этой дизайн-системы и используете такие компоненты, какSurface
,Button
и т. д., полностью переехать не получится: они используютripple
под капотом.-
Есть несколько способов полностью отключить индикацию глобально и в конретном случае:
Подменить
LocalRippleConfiguration
наnull
. Тогда делегирующий узел уберет все делегируемые дляclickable
узлов. Это "убьет" индикацию даже Material-компонентов.Передать в конкретный
clickable
параметрindication
, равныйnull
. Но злоупотребоять этим не стоит, ведь ваш код засорится одним и тем же повторяющимся выражением.Написать собственный
LocalIndication
, который будет заглушкой и не будет рисовать ничего (что-то вроде no-op). Но это будет иметь такие же ограничения, как были описаны выше, то есть не будет работать для Material-based компонентов.