Как сделать цветные тени в Android с градиентом и анимацией

На презентации новых макбуков обратил внимание на картинку процессора:

be91babf3d5a1c95cf755c54ae5c3d50.png

Переливающиеся цветные тени на темном фоне, выглядит классно.

Вот дошли руки, решил попробовать нарисовать на андроиде так же. Вот что получилось:

4c2e0ba8fc25d5f9bc0d5c4d29f1ce1f.gif

Сразу оговорюсь, что стандартным способом это сделать нельзя, до api 28 есть поддержка только черных elevation, после api 28 добавили поддержку цветных теней, но градиент сделать не получится. Поэтому мы будет рисовать drawable, устанавливать его в виде background и применять padding на целевой вьюхе, чтобы контент был внутри тени.

Напишем функцию создания Drawable с тенью:

/**
 * Создание drawable с градиентом-тенью
 */
private fun createShadowDrawable(
    @ColorInt colors: IntArray,
    cornerRadius: Float,
    elevation: Float,
    centerX: Float,
    centerY: Float
): ShapeDrawable {

    val shadowDrawable = ShapeDrawable()

    // Устанавливаем черную тень по умолчанию
    shadowDrawable.paint.setShadowLayer(
        elevation, // размер тени
        0f, // смещение тени по оси Х
        0f, // по У
        Color.BLACK // цвет тени
    )

    /**
     * Применяем покраску градиентом
     *
     * @param centerX - Центр SweepGradient по оси Х. Берем центр вьюхи
     * @param centerY - Центр по оси У
     * @param colors - Цвета градиента. Последний цвет должен быть равен первому,
     * иначе между ними не будет плавного перехода
     * @param position - позиции смещения градиента одного цвета относительно другого от 0 до 1.
     * В нашем случае null т.к. нам нужен равномерный градиент
     */
    shadowDrawable.paint.shader = SweepGradient(
        centerX,
        centerY,
        colors,
        null
    )

    // Делаем закугление углов
    val outerRadius = FloatArray(8) { cornerRadius }
    shadowDrawable.shape = RoundRectShape(outerRadius, null, null)

    return shadowDrawable
}

Поскольку у этого drawable фон представлен в виде радуги тех цветов, что мы передали в параметрах, нам нужен нормальный одноцветный фон. Для этого создаем вторую drawable:

/**
 * Создание цветного drawable с закругленными углами
 * Это будет основной цвет нашего контейнера
 */
private fun createColorDrawable(
    @ColorInt backgroundColor: Int,
    cornerRadius: Float
) = GradientDrawable().apply {
        setColor(backgroundColor)
        setCornerRadius(cornerRadius)
    }

Функция установки бэкграунда на вьюху-контейнер. У нас будет LayerDrawable с двумя слоями. 1 — тень, 2 — просто цвет с закругленными углами.

/**
 * Устанавливаем бэкграунд с тенью на вьюху, учитывая padding
 */
private fun View.setColorShadowBackground(
    shadowDrawable: ShapeDrawable,
    colorDrawable: Drawable,
    padding: Int
) {
    val drawable = LayerDrawable(arrayOf(shadowDrawable, colorDrawable))
    drawable.setLayerInset(0, padding, padding, padding, padding)
    drawable.setLayerInset(1, padding, padding, padding, padding)
    setPadding(padding, padding, padding, padding)
    background = drawable
}

Применяем на вьюхе:

// ждем когда вьюха отрисуется чтобы узнать ее размеры
targetView.doOnNextLayout {
    val colors = intArrayOf(
        Color.WHITE,
        Color.RED,
        Color.WHITE
    )
    val cornerRadius = 16f.dp
    val padding = 30.dp
    val centerX = it.width.toFloat() / 2 - padding
    val centerY = it.height.toFloat() / 2 - padding

    val shadowDrawable = createShadowDrawable(
        colors = colors,
        cornerRadius = cornerRadius,
        elevation = padding / 2f,
        centerX = centerX,
        centerY = centerY
    )
    val colorDrawable = createColorDrawable(
        backgroundColor = Color.DKGRAY,
        cornerRadius = cornerRadius
    )

    it.setColorShadowBackground(
        shadowDrawable = shadowDrawable,
        colorDrawable = colorDrawable,
        padding = 30.dp
    )
}

Теперь проанимируем изменение с одного набора цветов на другие. Зациклим.

/**
 * Анимация drawable-градиента
 */
private fun animateShadow(
    shapeDrawable: ShapeDrawable,
    @ColorInt startColors: IntArray,
    @ColorInt endColors: IntArray,
    duration: Long,
    centerX: Float,
    centerY: Float
) {
    /**
     * Меняем значение с 0f до 1f для применения плавного изменения
     * цвета с помощью [ColorUtils.blendARGB]
     */
    ValueAnimator.ofFloat(0f, 1f).apply {
        // Задержка перерисовки тени. Грубо говоря, фпс анимации
        val invalidateDelay = 100
        var deltaTime = System.currentTimeMillis()

        // Новый массив со смешанными цветами
        val mixedColors = IntArray(startColors.size)

        addUpdateListener { animation ->
            if (System.currentTimeMillis() - deltaTime > invalidateDelay) {
                val animatedFraction = animation.animatedValue as Float
                deltaTime = System.currentTimeMillis()

                // Смешиваем цвета
                for (i in 0..mixedColors.lastIndex) {
                    mixedColors[i] = ColorUtils.blendARGB(startColors[i], endColors[i], animatedFraction)
                }

                // Устанавливаем новую тень
                shapeDrawable.paint.shader = SweepGradient(
                    centerX,
                    centerY,
                    mixedColors,
                    null
                )
                shapeDrawable.invalidateSelf()
            }
        }
        repeatMode = ValueAnimator.REVERSE
        repeatCount = Animation.INFINITE
        setDuration(duration)
        start()
    }
}

Применим:

// Второй массив с цветами. Размер массивов должен быть одинаковый.
val endColors = intArrayOf(
	Color.RED,
	Color.WHITE,
	Color.RED
)
animateShadow(
	shapeDrawable = shadowDrawable,
  startColors = colors,
  endColors = endColors,
  duration = 2000,
  centerX = centerX,
  centerY = centerY
)

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

© Habrahabr.ru