Такой вопрос возник у меня однажды. А потом еще раз. И я решил разобраться.
Спойлер: конкретного ответа у меня нет. Зато есть исследование.

Данный вопрос без контекста не имеет смысла. Контекст будет. Но не сразу.
Обо всем по порядку.

Что есть 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

  • Цвет для OverlayonPrimary

В документации представлены дефолтные значения непрозрачности для Overlay, однако никто не мешает использовать свои значения.

Визуализация примера с 20%-й непрозрачностью под Overlay
Визуализация примера с 20%-й непрозрачностью под Overlay

Далее идет картинка с официальной документации с Pressed State и отображаемым Ripple (Overlay). Если посмотреть на правую карточку, то можно заметить, что, Overlay перекрывает нижний график и немного затемняет его (является covering).

Pressed State и отображаемый Overlay
Pressed State и отображаемый Overlay

Кстати, если рассматривать Ripple в рамках Material Design, то существуют аналоги под iOS и Web, однако про них ничего рассказать не могу.

Ripple & Material Design 3

В Material Design 3 кардинально ничего не поменялось, однако есть нюансы.

  • Сократилось общее количество State'ов.

  • Overlay переименовали в State Layer.

А еще документация, вероятно, содержит опечатку, так как отмечает, что контент находится над State Layer, но в следующем же месте это таковым не является.

Контент лежит над State Layer???
Контент лежит над State Layer???
.Визуально заметно, что State Layer затемняет цвет контента (текста), из чего можно сделать вывод, что State Layer находится над контентом
.Визуально заметно, что State Layer затемняет цвет контента (текста), из чего можно сделать вывод, что State Layer находится над контентом

Ripple & XML

Когда проекты еще не использовали Material Design, алгоритм по работе с Ripple в XML примерно заключался в следующем:

  1. В атрибут android:foreground выставлялся ?selectableItemBackground.

  2. Добавлялся атрибут android:clickable, равный true, так как без этого Ripple в каких-то случаях (или даже вообще во всех) попросту не работал.

  3. До кучи добавлялся атрибут 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, справа — без(а еще можно заметить, что текстовый стиль поменялся)
Слева — вариант с MaterialTheme, справа — без
(а еще можно заметить, что текстовый стиль поменялся)

Из этого можно предположить, что 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 from LocalIndication 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?

  1. Для отслеживания Interaction'ов
    Например, чтобы деактивировать другие компоненты если данный компонент нажат

  2. Для выставления отличного от дефолтного (в 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 new RippleAnimation, and responds to other Interactions by showing a fixed StateLayer with varying alpha values depending on the Interaction.

В остальном суть 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!"
            )
        }
    }
}
Preview примера сверху с разными поставляемыми значениями для LocalContentColor
Preview примера сверху с разными поставляемыми значениями для LocalContentColor

Есть маленький, но важный нюанс, относящийся к 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 affects pressedAlpha - 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).

Полный код можно найти здесь.

API level 21
API level 21

В документации пишут про наличие двух слоев под Ripple для API level < 28.
Однако не раскрывается, что под этим подразумевают.

На API level 21 можно заметить, что при нажатии появляется статичный Overlay, по цвету соответствующий финальному ожидаемому.

Поверх первого Overlay появляется еще один с анимацией "волнового эффекта", характерного для Ripple.

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

API level 26
API level 26

API level 26 отличается по поведению.

То, что было статичным Overlay в API level 26, теперь анимируется через fade.

Финальный цвет совпадает с ожидаемым.
Но ненадолго.
После завершения клика появляется второй Ovelay c характерной для Ripple анимацией.

API level 30
API level 30

Про API level 30 вообще сложно сказать что-то плохое.

Мы видим только один анимируемый Overlay, который по цвету совпадает с ожидаемым результатом.

API level 32
API level 32

Ну, и самый проблемный случай — API level 32.

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

Во-вторых, результирующий цвет далек от желаемого.
Но так ли это?

API level 32 (замедленно)
API level 32 (замедленно)

При замедлении анимации можно заметить, что цвет в финальной стадии светлее, чем во время анимации. Возможно, это баг.

В свою очередь цвет, который мы видим во время анимации в середине, равен ожидаемому.

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

Шум при анимации Ripple
Шум при анимации Ripple на API level 32

И он действительно появился. Правда, его лучше видно на больших кликабельных областях.

Мне стало интересно, почему же так все кардинально поменялось.

Возможно, что эти моменты не связаны, однако в Android 12 представили Sparkle Ripple. Подробнее можно почитать в этой статье. Его, правда, выпилили в связи с негативными отзывами пользователей (которые очень легко гуглятся через "reddit sparkle ripple").

Кстати, проблема с цветом уже упоминалась на том же Reddit'e.

Пикаем Ripple из Figma

Вернемся к названию статьи.

"Какого цвета Ripple?"

Этот вопрос появляется тогда, когда вы реализуете кликабельный компонент в соответствии с дизайном (например, из Figma).

Рассмотрим некоторые возможные случаи.

Pressed состояние компонента не определено

Кнопка без Pressed состояния
Кнопка без Pressed состояния

Достаточно часто можно встретить ситуацию, когда у компонента попросту отсутствует Pressed состояния.

При имплементации можно в цвет для Ripple выставить цвет контента (onContainer из примера). Тем самым мы получим Ripple как по гайдлайнам, с дефолтными значениями непрозрачности.

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

  1. Гайдлайны (для Material Design 2 / для Material Design 3).

  2. Ripple — это Overlay (или State Layer).

  3. Overlay — это поверх всего контента.

  4. Цвет для Overlay равен цвету контента.

  5. Непрозрачность для Overlay не может быть больше 50%.

  6. Ripple — штука системная, может визуально отличаться на разных девайсах.

Pressed состояние компонента определено через Overlay

Кнопка с Pressed состоянием через Overlay
Кнопка с Pressed состоянием через Overlay

Наиболее благоприятный вариант — когда в Figma есть Pressed состояние компонента и, более того, реализовано оно через слой поверх всего.

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

Цвет и непрозрачность, которые будут использованы в коде, получаются следующим образом: выделив Overlay, увидим непрозрачность и цвет.

Можно столкнуться со случаями, когда непрозрачность выше 50%. Или контент лежит поверх Overlay. Тогда следует перезапросить дизайн с подсвечиванием пунктов, указанных ранее для случая без Pressed состояния.

Pressed состояние компонента определено через цвет фона

Кнопка с Pressed состоянием через цвет
Кнопка с Pressed состоянием через цвет

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

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

  • Отсутствует Overlay (и, следовательно, концепция Overlay не применяется).

  • Неясно, как получить связку цвет + непрозрачность для Ripple.

Правильным и лучшим решением будет запросить обновления по дизайну.

А существует ли способ получить пару цвет + непрозрачность для Ripple в данном случае? Вопрос "Какого цвета Ripple?" возник именно отсюда.

Магия

Итак, предположим, что условно у нас есть два цвета: container и containerPressed.

Мы же хотим получить третий. Такой, при наложении которого на container получался бы containerPressed.

В решении данной задачи помог вопрос Finding "equivalent" color with opacity.

Цвет мы можем вычислить следующим образом:

x_{overlay}= x_{default}+\frac{x_{pressed}-x_{default}}{A_{overlay}}

где
x — значение конкретной составляющей RGB цвета (Red, Green или Blue);
A — значение непрозрачности (она же alpha).

Разберем на примерах.

Находим цвет для Overlay: из одного серого в другой серый потемнее
Находим цвет для Overlay: из одного серого в другой серый потемнее

Смотрим в 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).

Находим цвет для Overlay: из одного синего в синий посветлее
Находим цвет для Overlay: из одного синего в синий посветлее

container цвет — #436EDE (67, 110, 222).
pressedContainer цвет — #7D9FF8 (125, 159, 248).

Снова подставляем значения в формулу и смотрим, что получится.

Можно заметить, что многие составляющие цветов выделены красным.
Они лежат вне диапазона [0, 255] и будут приведены к граничным значениям.

Внутри каждого Result блока над "Overlay alpha..." расположен прямоугольник с двумя цветами: ожидаемым финальным слева и фактическим финальным справа.

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

Находим цвет для Overlay: из синего в красный
Находим цвет для Overlay: из синего в красный

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

Итак, в новом примере:
container цвет — #436EDE (67, 110, 222).
pressedContainer цвет — #FF0000 (255, 0, 0).

Даже при alpha=0.5 желаемый цвет кардинально отличается от фактического.

Все три этих примера в Figma (и не только) можно найти по этой ссылке.

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

Preview последнего примера
Preview последнего примера

Заключение

Хотелось бы закрепить несколько важных моментов:

  • Ripple — это Overlay поверх всего контента.

  • Цвет для Ripple в идеале равен цвету контента.

  • Непрозрачность для Ripple не может составлять больше 50%.

  • Ripple — это системно, оно может отличаться для разных API level.

  • Попросите дизайнеров добавить Ripple с учетом всех нюансов.

Какого цвета Ripple? Он разный...

Комментарии (4)


  1. Rusrst
    21.06.2023 11:44

    Было интересно, спасибо!


  1. quaer
    21.06.2023 11:44

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


  1. Renattele
    21.06.2023 11:44

    Не знаю, может быть я странный, но мне зашел Sparkle Ripple, он неплохо сочетается с Material 3(если, конечно, смотреть на реализацию в Keynote)


    1. c5fr7q Автор
      21.06.2023 11:44
      +1

      Как мне кажется, Sparkle очень сильно отличается от предыдущих реализаций Ripple. И в этом его главная проблема. Я бы увидев подобное подумал, что с девайсом что-то не так / появились какие-то артефакты.

      Даже если Sparkle Ripple сочетается с Material Design 3, он также обязан сочетаться как с приложениями на Material Design 2, так и с приложениями без Material Design вовсе (так как Ripple, в отличие от Material Design, штука системная, а не просто библиотека)