Такой вопрос возник у меня однажды. А потом еще раз. И я решил разобраться.
Спойлер: конкретного ответа у меня нет. Зато есть исследование.
Данный вопрос без контекста не имеет смысла. Контекст будет. Но не сразу.
Обо всем по порядку.
Что есть Ripple
Ripple effect переводится как "волновой эффект".
Забавно, что данный перевод я узнал во время написания статьи, хотя термином пользуюсь достаточно давно.
Ripple чаще всего ассоциируется с тем самым системным "волновым эффектом", визуально возникающим при нажатии в Android.
За реализацию данного эффекта отвечает RippleDrawable. Он и ведет к более точному определению термина:
Drawable that shows a ripple effect in response to state changes.
Ripple появился в Android Lollipop. Знаете, что появилось еще? Material Design.
Ripple & Material Design 2
Причем здесь Material Design? Дело в том, что там дается определение понятия State*.
*
Это несколько не то же самое, что и State в RippleDrawable, однако поговорим именно про него, потому что
Material Design — библиотека, а RippleDrawable — часть фреймворка.
Под капотом Material Design использует RippleDrawable.
Далее пойдет речь про Jetpack Compose, а там Ripple — часть Material Design.
States are visual representations used to communicate the status of a component or interactive element.
State — это визуальное представление, используемое для отображения статуса интерактивного элемента. Пример — pressed, focused.
Всего есть 11 типов под State, что не очень интересно.
Куда более интересно то, как получаются некоторые из них (типа pressed).
Знакомимся, Overlay.
An overlay is a semi-transparent covering on an element that indicates its state.
Здесь есть один важный момент, который нужно учесть (а пригодится он потом).
Что есть covering? Это поверхность, которая покрывает компонент, накладывается сверху.
Overlay — покрывающая компонент полупрозрачная поверхность для индикации State'a.
Ripple — это тоже Overlay.
Цвет для Overlay в идеале должен соответствовать цвету контента компонента.
Рассмотрим пример.
Наш компонент — кастомная кнопка
Цвет фона компонента — primary
Цвет текста, используемого на компоненте — onPrimary
Цвет для Overlay — onPrimary
В документации представлены дефолтные значения непрозрачности для Overlay, однако никто не мешает использовать свои значения.
Далее идет картинка с официальной документации с Pressed State и отображаемым Ripple (Overlay). Если посмотреть на правую карточку, то можно заметить, что, Overlay перекрывает нижний график и немного затемняет его (является covering).
Кстати, если рассматривать Ripple в рамках Material Design, то существуют аналоги под iOS и Web, однако про них ничего рассказать не могу.
Ripple & Material Design 3
В Material Design 3 кардинально ничего не поменялось, однако есть нюансы.
Сократилось общее количество State'ов.
Overlay переименовали в State Layer.
А еще документация, вероятно, содержит опечатку, так как отмечает, что контент находится над State Layer, но в следующем же месте это таковым не является.
Ripple & XML
Когда проекты еще не использовали Material Design, алгоритм по работе с Ripple в XML примерно заключался в следующем:
В атрибут
android:foreground
выставлялся?selectableItemBackground
.Добавлялся атрибут
android:clickable
, равныйtrue
, так как без этого Ripple в каких-то случаях (или даже вообще во всех) попросту не работал.До кучи добавлялся атрибут
android:focusable
со значениемtrue
, так как при наличии clickable без focusable ругался Lint.
<...
android:foreground = "?selectableItemBackground"
android:clickable = "true"
android:focusable = "true"
/>
Все? И да, и нет.
android:foreground
до API level 23 попросту не работал ни на чем, кроме FrameLayout. Его заменяли наandroid:background
, получая не overlay, а просто background.Отдельные танцы с бубном требовались для замены цвета у Ripple. Нужно было создать ThemeOverlay, в нем переопределить
android:colorControlHighlight
и выставить данный ThemeOverlay черезandroid:theme
.
Можно было создать собственный Ripple на базе RippleDrawable и не мучиться с цветами.
Кратко, все то же самое, но из официального источника можно прочитать здесь.
На упоминание Material Design в статье не обращаем внимания. Все работает и без него.
Что до Material Design, в нем есть MaterialCardView, в котором цвет Ripple подменяется через специально выделенный атрибут.
Ripple & Jetpack Compose
Что касается Jetpack Compose, то здесь Ripple — это часть Material Design.
Важный момент заключается в том, что Ripple поставляется как отдельная библиотека.
Material Design 2 и Material Design 3, в свою очередь, используют эту библиотеку.
А еще, как бонус, ничто не мешает вам придумать и реализовать свою дизайн-систему, при этом используя Ripple но, не используя ни один из Material'ов.
Ripple & Jetpack Compose под капотом
Рассмотрим пример, в котором используется Material Design 2.
(он же androidx.compose.material:material
в зависимостях).
@Composable
fun Content() {
MaterialTheme {
Text(
modifier = Modifier.clickable { },
text = "Hello world!"
)
}
}
Запускаем — видим Ripple.
Закомментируем MaterialTheme
(3 и 8 строки).
Теперь Ripple отсутствует, но есть какое-то странное затемнение.
Из этого можно предположить, что MaterialTheme
под капотом для компонентов с Modifier.clickable { }
добавляет Ripple. Но как?
Заглянем в реализацию Modifier.clickable { }
.
fun Modifier.clickable(
...
) = composed(...) {
Modifier.clickable(
...
indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }
)
}
Видим, что вызывается одноименный публичный метод с передачей параметров MutableInteractionSource
и Indication
. Почитаем документацию метода.
indication - indication to be shown when modified element is pressed. Be default, indication from
LocalIndication
will be used. Pass null to show no indication, or current value fromLocalIndication
to show theme default
Стало быть, за реализацию Ripple отвечает параметр Indication
.
Ну и, соответственно, в нашем случае Ripple предоставляется через LocalIndication
.
Но при чем здесь MutableInteractionSource
?
Разберемся.
Заодно станет понятней, когда может пригодиться прямой вызов метода с данными параметрами.
Interaction
MutableInteractionSource
— это, по сути, Flow<Interaction>
.
Interaction
отражает взаимодействие пользователя с компонентом. Например, пользователь начал или закончил нажатие, взял компонент в фокус.
Interaction
— это просто интерфейс, без каких-либо методов к реализации.
Всего существует четыре прямых наследника Interaction
: PressInteraction
, FocusInteraction
, HoverInteraction
, DragInteraction
.
Их в целом более чем достаточно, но никто не запрещает создать свой.
Интересный момент заключается в том, что каждый из прямых наследников содержит несколько дочек: одну под начало взаимодействия, вторую — под окончание, третью (опциональную) — под отмену.
Например, в PressInteraction это Press
, Release
и Cancel
.
Для чего все это вообще нужно?
MutableInteractionSource
позволяет отслеживать взаимодействие с компонентом и реагировать на него. Например, можно отслеживать нажатие следующим образом:
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
Button(
onClick = { /* do something */ },
interactionSource = interactionSource) {
Text(if (isPressed) "Pressed!" else "Not pressed")
}
Более подробно Interaction
разобран в данном гайде.
Кстати, если закопаться глубже в Modifier.clickable { }
, то выяснится, что под капотом вызываются Modifier.hoverable()
и Modifier.focusable()
, в которые передается interactionSource из Modifier.clickable { }
. Поэтому, если начать отслеживать Interaction
'ы последнего, то могут прилетать взаимодействия и по focus, и по hover.
Indication
Indication
— это визуализация возникающих Interaction
'ов.
Условно это связующий интерфейс, единственный метод которого принимает на вход Flow<Interaction>
и возвращает IndicationInstance
, занимающийся отрисовкой.
Получается, что Indication
напрямую зависит от Interaction
.
Поэтому и рассматривать термины тоже нужно в паре.
Для того, чтобы подружить компонент с каким-либо Indication
, существует Modifier.indication(...)
, параметрами которого являются InteractionSource
и зависимый от него Indication
.
Если посмотреть наследников, то можно увидеть Ripple
и DefaultDebugIndication
.
Для случая с MaterialTheme
в LocalIndication
выставляется Ripple
.
Для случая без MaterialTheme
в LocalIndication
ничего не выставляется и используется значение по умолчанию, которым является DefaultDebugIndication
. Поэтому мы и видим "странное затемнение" при нажатии.
Возвращаясь к Modifier.clickable { }
.
Зачем нужен метод с MutableInteractionSource
и Indication
?
Для отслеживания
Interaction
'ов
Например, чтобы деактивировать другие компоненты если данный компонент нажатДля выставления отличного от дефолтного (в
LocalIndication
)Indication
'a
Например, чтобы убрать вообще какой-либо визуальный эффект (взаимодействия)
Устройство библиотеки Compose Material Ripple
Разобрались, что есть Interaction
и Indication
. Также на данный момент уже выяснили, что Ripple подставляется в MaterialTheme
. Самое время заглянуть внутрь.
@Composable
fun MaterialTheme(
...
) {
...
val rippleIndication = rememberRipple()
CompositionLocalProvider(
LocalIndication provides rippleIndication,
LocalRippleTheme provides MaterialRippleTheme,
...
) {
...
}
}
Получается, что в LocalIndication
выставляется rememberRipple()
.
rememberRipple()
— это условно первая публичная часть библиотеки Compose Material Ripple.
Провалимся в реализацию. Видим, что в качестве Indication
используется PlatformRipple
. Перейдем в последний.
PlatformRipple
, в который мы перешли, помечен как actual
.
Кроме того, файл, в котором мы находимся, в IDE отображается как Ripple.android.kt. Получается, что даже не зная того, мы имеем дело с Kotlin Multiplatform.
В данном случае представлен код, характерный только под Android.
Обратим внимание на документацию.
Android specific Ripple implementation that uses a
RippleDrawable
under the hood, which allows rendering the ripple animation on the render thread (away from the main UI thread). This allows the ripple to animate smoothly even while the UI thread is under heavy load, such as when navigating between complex screens.
Из документации следует, что Jetpack Compose на Android для Ripple использует старый добрый RippleDrawable
под капотом*. И все отчасти из-за того, что RippleDrawable
умеет обрабатывать Ripple-анимацию не на main thread, а на render thread.
*
Как добраться до RippleDrawable
из PlatformRipple
?
Перейдем к методу addRipple()
.
Видим, что вызывается метод addRipple()
класса RippleHostView
.
Проваливаемся в него.
Внутри последнего используется поле ripple класса UnprojectedRipple
.
Переходим в последний.
Видим, что UnprojectedRipple
является наследником RippleDrawable
.
Вернемся немного назад.
PlatformRipple
наследует Ripple
.
Интересно то, как Ripple
реализует интерфейс Indication
.
(как реализуется метод rememberUpdatedInstance
)
@Stable
internal abstract class Ripple(
...
private val color: State<Color>
) : Indication {
@Composable
final override fun rememberUpdatedInstance(
interactionSource: InteractionSource
): IndicationInstance {
val theme = LocalRippleTheme.current
val color = rememberUpdatedState(
if (color.value.isSpecified) {
color.value
} else {
theme.defaultColor()
}
)
val rippleAlpha = rememberUpdatedState(theme.rippleAlpha())
val instance = rememberUpdatedRippleInstance(
...
color,
rippleAlpha
)
...
}
...
}
Здесь-то мы и сталкиваемся с условно второй публичной частью библиотеки Compose Material Ripple, с RippleTheme
, которая поставляется через LocalRippleTheme
.
По коду можно понять, что RippleTheme
предоставляет rippleAlpha и defaultColor для Ripple. Причем второй будет использован только в том случае, если цвет для Ripple не был явно указан (в rememberRipple()
).
Итак, взглянем на то, как примерно выглядит RippleTheme
.
public interface RippleTheme {
@Composable
public fun defaultColor(): Color
@Composable
public fun rippleAlpha(): RippleAlpha
}
@Immutable
class RippleAlpha(
public val draggedAlpha: Float,
public val focusedAlpha: Float,
public val hoveredAlpha: Float,
public val pressedAlpha: Float
)
Хотелось бы кое-что прояснить.
Когда я читал гайдлайны в Material Design (например, этот, указанный ранее), у меня возникло умозаключение, что Ripple — это частный случай Overlay (State Layer).
Сталкиваясь с реальностью в лице реализации Ripple в Compose, а конкретнее с rippleAlpha()
в RippleTheme
, я вижу несоответствие: rippleAlpha()
возвращает не Float
со значением непрозрачности для Ripple (State Layer для Pressed), а RippleAlpha
, который содержит значения непрозрачности для разных State Layer'ов. Получается, что из реализации следует обратное, что Ripple полностью реализует концепцию State Layer, а не является его частью. Ну и это отчасти подтверждает следующий кусок документации rememberRipple()
:
A Ripple responds to
PressInteraction.Press
by starting a newRippleAnimation
, and responds to otherInteraction
s by showing a fixedStateLayer
with varying alpha values depending on theInteraction
.
В остальном суть RippleTheme
достаточно проста. В моем понимании она нужна для того, чтобы выставлять дефолтный цвет и непрозрачность для всех State Layer'ов (включая Ripple).
Хочется отметить одну интересную деталь в MaterialRippleTheme
(которая выставляется в LocalRippleTheme
внутри MaterialTheme
двумя блоками кода ранее).
В качестве defaultColor()
возвращается LocalContentColor.current
. Последний также используется для различных компонент (типа текст или иконка), если для них не задан конкретный цвет напрямую. Можно сделать так, чтобы цвет для контента и цвет для Ripple менялся в одном месте:
@Composable
fun Content() {
MaterialTheme {
CompositionLocalProvider(
LocalContentColor provides Color.Red
) {
Text(
modifier = Modifier.clickable { },
text = "Hello world!"
)
}
}
}
Есть маленький, но важный нюанс, относящийся к pressedAlpha в RippleAlpha.
On Android, because the press ripple is drawn using the framework's
RippleDrawable
, there are constraints / different behaviours for the actual press alpha used on different API versions. Note that this only affectspressedAlpha
- the other values are guaranteed to be consistent, as they do not rely on framework code. Specifically:API 21-27: The actual ripple is split into two 'layers', with the alpha applied to both layers, so there is no uniform 'alpha'. API 28-32: The ripple is just one layer, but the alpha is clamped to a maximum of 0.5f - it is not possible to have a fully opaque ripple. API 33: There is a bug where the ripple is clamped to a minimum of 0.5, instead of a maximum like before - this should be resolved in future versions.
Что нужно отметить?
Ripple-анимация, вероятно, будет выглядеть по-разному в зависимости от наличия одного или двух "слоев".
Для некоторых API level максимальное возможное значение pressedAlpha равно 0.5.
Поведение с разными API level
Посмотрим, чем отличаются Ripple для разных API level.
Проведем опыт, который заключается в клике на элемент.
Каждый такой элемент имеет подпись "alpha=XXX" со значением alpha под Ripple.
Стоит отметить, что хоть RippleAlpha
и состоит из нескольких alpha для разных Interaction
'ов, нам же интересен только случай с pressedAlpha. Поэтому выставляем только его, а все остальные зануляем.
Слева от элемента расположен начальный цвет фона с подписью "start".
В нашем случае это всегда белый цвет.
Справа — ожидаемый финальный цвет фона с подписью "final".
Этот цвет получается наложением Ripple (Overlay) на начальный цвет.
Все такие элементы находятся в контейнере, цвет фона которого равен ожидаемому финальному цвету при максимально возможном (по документации) значении alpha=0.5.
Это нужно для того, чтобы можно было визуально сравнить получаемые финальные цвета для случаев с alpha>0.5 и с alpha=0.5.
В опыте принимают участие два цвета и пять значений alpha (от 0.0 до 1.0 с шагом 0.25).
Полный код можно найти здесь.
В документации пишут про наличие двух слоев под Ripple для API level < 28.
Однако не раскрывается, что под этим подразумевают.
На API level 21 можно заметить, что при нажатии появляется статичный Overlay, по цвету соответствующий финальному ожидаемому.
Поверх первого Overlay появляется еще один с анимацией "волнового эффекта", характерного для Ripple.
Окончательный цвет при этом отличается от ожидаемого.
Вероятно, в этом и заключается то, что описывали как два слоя.
API level 26 отличается по поведению.
То, что было статичным Overlay в API level 26, теперь анимируется через fade.
Финальный цвет совпадает с ожидаемым.
Но ненадолго.
После завершения клика появляется второй Ovelay c характерной для Ripple анимацией.
Про API level 30 вообще сложно сказать что-то плохое.
Мы видим только один анимируемый Overlay, который по цвету совпадает с ожидаемым результатом.
Ну, и самый проблемный случай — API level 32.
Во-первых, можно заметить, что в конечной стадии Ripple (Overlay) покрывает не весь компонент: углы светлее, чем середина. Вероятно, это связано с тем, что Ripple потерял свои четкие границы: раньше анимируемый круг был полностью одного цвета, а теперь нет.
Во-вторых, результирующий цвет далек от желаемого.
Но так ли это?
При замедлении анимации можно заметить, что цвет в финальной стадии светлее, чем во время анимации. Возможно, это баг.
В свою очередь цвет, который мы видим во время анимации в середине, равен ожидаемому.
Если приглядеться, то можно заметить, что как будто появился эффект шума на анимации.
И он действительно появился. Правда, его лучше видно на больших кликабельных областях.
Мне стало интересно, почему же так все кардинально поменялось.
Возможно, что эти моменты не связаны, однако в Android 12 представили Sparkle Ripple. Подробнее можно почитать в этой статье. Его, правда, выпилили в связи с негативными отзывами пользователей (которые очень легко гуглятся через "reddit sparkle ripple").
Кстати, проблема с цветом уже упоминалась на том же Reddit'e.
Пикаем Ripple из Figma
Вернемся к названию статьи.
"Какого цвета Ripple?"
Этот вопрос появляется тогда, когда вы реализуете кликабельный компонент в соответствии с дизайном (например, из Figma).
Рассмотрим некоторые возможные случаи.
Pressed состояние компонента не определено
Достаточно часто можно встретить ситуацию, когда у компонента попросту отсутствует Pressed состояния.
При имплементации можно в цвет для Ripple выставить цвет контента (onContainer из примера). Тем самым мы получим Ripple как по гайдлайнам, с дефолтными значениями непрозрачности.
Однако было бы неплохо запросить дизайн Pressed состояния. При этом подсвечиваем следующие важные моменты:
Гайдлайны (для Material Design 2 / для Material Design 3).
Ripple — это Overlay (или State Layer).
Overlay — это поверх всего контента.
Цвет для Overlay равен цвету контента.
Непрозрачность для Overlay не может быть больше 50%.
Ripple — штука системная, может визуально отличаться на разных девайсах.
Pressed состояние компонента определено через Overlay
Наиболее благоприятный вариант — когда в Figma есть Pressed состояние компонента и, более того, реализовано оно через слой поверх всего.
Слой может не выглядеть как кружок, а, например, накрывать весь контент. Тогда визуально это будет выглядеть как на следующем примере, однако суть не зависит от формы для Overlay.
Цвет и непрозрачность, которые будут использованы в коде, получаются следующим образом: выделив Overlay, увидим непрозрачность и цвет.
Можно столкнуться со случаями, когда непрозрачность выше 50%. Или контент лежит поверх Overlay. Тогда следует перезапросить дизайн с подсвечиванием пунктов, указанных ранее для случая без Pressed состояния.
Pressed состояние компонента определено через цвет фона
Ну и самый сложный сценарий. Сценарий, при котором Pressed состояние существует, но сделано оно через специально выделенный цвет.
В рамках примеров этот случай визуально похож на предыдущий, однако имеет пару проблем:
Отсутствует Overlay (и, следовательно, концепция Overlay не применяется).
Неясно, как получить связку цвет + непрозрачность для Ripple.
Правильным и лучшим решением будет запросить обновления по дизайну.
А существует ли способ получить пару цвет + непрозрачность для Ripple в данном случае? Вопрос "Какого цвета Ripple?" возник именно отсюда.
Магия
Итак, предположим, что условно у нас есть два цвета: container и containerPressed.
Мы же хотим получить третий. Такой, при наложении которого на container получался бы containerPressed.
В решении данной задачи помог вопрос Finding "equivalent" color with opacity.
Цвет мы можем вычислить следующим образом:
где
x — значение конкретной составляющей RGB цвета (Red, Green или Blue);
A — значение непрозрачности (она же alpha).
Разберем на примерах.
Смотрим в Input:
верхний цвет, container, — #D9D9D9 (или Red=217, Green=217, Blue=217).
нижний цвет, pressedContainer, — #AEAEAE (174, 174, 174).
Далее обращаем внимание на Result блоки.
В них представлено то, какие цвета можно получить при различных alpha:
если alpha=0.20, то цвету для Overlay соответствует #020202 (02, 02, 02);
если alpha=0.35, то цвету для Overlay соответствует #5E5E5E (94, 94, 94);
если alpha=0.50, то цвету для Overlay соответствует #838383 (131, 131, 131).
Цвет мы получили, однако можно заметить, что контент при наложении данного Overlay становится все более серым с увеличением показателя alpha (в данном случае контент представлен двумя текстами).
Здесь и в следующих примерах варианты с alpha>0.5 по описанной ранее причине не рассматриваются (Ripple не применит такой показатель, а остановится на alpha=0.5).
container цвет — #436EDE (67, 110, 222).
pressedContainer цвет — #7D9FF8 (125, 159, 248).
Снова подставляем значения в формулу и смотрим, что получится.
Можно заметить, что многие составляющие цветов выделены красным.
Они лежат вне диапазона [0, 255] и будут приведены к граничным значениям.
Внутри каждого Result блока над "Overlay alpha..." расположен прямоугольник с двумя цветами: ожидаемым финальным слева и фактическим финальным справа.
Можно заметить, что желаемый и фактический цвета отличаются.
Это как раз связано с тем, что при вычислении получились значения вне диапазона.
Тем не менее, в данном конкретном примере цвета визуально близки.
Визуальная близость цветов из прошлого примера сильно зависит от входных значений.
Итак, в новом примере:
container цвет — #436EDE (67, 110, 222).
pressedContainer цвет — #FF0000 (255, 0, 0).
Даже при alpha=0.5 желаемый цвет кардинально отличается от фактического.
Все три этих примера в Figma (и не только) можно найти по этой ссылке.
Визуализацию данных примеров на Compose (а также метод, способный вычислить необходимый цвет) можно найти здесь.
Заключение
Хотелось бы закрепить несколько важных моментов:
Ripple — это Overlay поверх всего контента.
Цвет для Ripple в идеале равен цвету контента.
Непрозрачность для Ripple не может составлять больше 50%.
Ripple — это системно, оно может отличаться для разных API level.
Попросите дизайнеров добавить Ripple с учетом всех нюансов.
Какого цвета Ripple? Он разный...
Комментарии (4)
quaer
21.06.2023 11:44Когда группа разработчиков постоянно наваливает новые гроздья кода, копаться в нём можно вечно...
Renattele
21.06.2023 11:44Не знаю, может быть я странный, но мне зашел Sparkle Ripple, он неплохо сочетается с Material 3(если, конечно, смотреть на реализацию в Keynote)
c5fr7q Автор
21.06.2023 11:44+1Как мне кажется, Sparkle очень сильно отличается от предыдущих реализаций Ripple. И в этом его главная проблема. Я бы увидев подобное подумал, что с девайсом что-то не так / появились какие-то артефакты.
Даже если Sparkle Ripple сочетается с Material Design 3, он также обязан сочетаться как с приложениями на Material Design 2, так и с приложениями без Material Design вовсе (так как Ripple, в отличие от Material Design, штука системная, а не просто библиотека)
Rusrst
Было интересно, спасибо!