Не стоит бояться теней

9a9d9da931bdc946323b2feded7137ef.jpg

Мы любим своих дизайнеров за то, что они придумывают нам такие классные и красивые кнопки. Но нарисовать кнопку может каждый, а как насчёт тени от кнопки? Я расскажу, как мы решили задачу с тенями для наших контролов и сделали для нашей дизайн-системы не одну, а целых семь теней.

Постановка задачи

64c93dc1d2950038232006c1c3850bfe.jpeg

Для наглядности я покажу, как выглядит самая сложная красивая тень в нашей дизайн-системе:

801635945a9235f36aeb9f29722fe6ad.png

Параметры, которые тут фигурируют:

  • 0 px — смещение по оси X;

  • 11 px — смещение по оси Y;

  • 15 px — размытие тени;

  • #00000040 — цвет тени.

Такая тень примечательна тем, что на самом деле тут не одна, а целых три тени.

61cfeafa397a8555c4e16e311c4794ff.jpeg

В Figma это выглядит так:

991f9845c222250cf6fd830a93c5d357.pngВсего в нашей дизайн системе семь теней с различными параметрами смещений, размытия и цветов прозрачности черного

b27ccea6c9deb07a53b9891c812384b6.png

Надо сказать, что и фигуры у нас не простые, а золотыесложные (используем Sketch Smooth Corner и Squircle), мы рисуем их с помощью Path. Записывать это в дополнительные требования не будем, потому что для тени достаточно подобрать похожую фигуру, нет нужды рисовать контур тени, точно повторяющий форму кнопки. Например, для Sketch Smooth Corner можно использовать просто прямоугольник со скруглёнными углами (радиус закругления у нас задаётся явно), а для Squircle — тот же прямоугольник со скруглёнными углами, но с радиусом, равным половине наименьшей стороны. Также я вообще не рекомендую использовать Path для Outline, потому что для этого он должен удовлетворять определенным условиям. Эти условия могут отличаться в зависимости от версии и вендора. Подытожим наши требования:

  • Возможность задавать параметры теней.

  • Возможность рисовать несколько теней на кнопку.

  • API ≥ 21.

Давайте немного формализуем наши требования в коде:

/**
	 * Композитная тень дизайн системы
	 *
	 * @param name - название тени
	 * @param layers - список теней
	 */
	@Parcelize
	data class CustomShadowParams(
	    val name: String,
	    val layers: List
	) : Parcelable 
	
	/**
	 * Параметры тени
	 *
	 * @Param dX - смещение по оси X
	 * @Param dY - смещение по оси Y
	 * @Param radius - радиус размытия тени
	 * @Param color - цвет тени
	 * @Param colorAlpha - прозрачность цвета тени
	 */
	@Parcelize
	data class Shadow(
	    @Px val dX: Float,
	    @Px val dY: Float,
	    @Px val radius: Float,
	    @ColorInt val color: Int,
	    @FloatRange(from = 0.0, to = 1.0) val colorAlpha: Float
	) : Parcelable 

Цвет тени и её прозрачность разделены, потому что так удобнее реализовывать экраны, на которых мы динамически будем менять прозрачность. Для примера тут и далее будем использовать CustomShadowParams.shadow2():

fun shadow2(): CustomShadowParams {
    return CustomShadowParams(
        name = "Shadow 2",
        listOf(
            Shadow(
                dX = 0.toPx,
                dY = 2.toPx,
                radius = 9.toPx,
                color = Color.BLACK,
                colorAlpha = 0.14f
            )
        )
    )
}

Значения для теней подобраны эмпирически. Весь код, который тут представлен, доступен в репозитории: https://github.com/ussernamenikita/AndroidShadows.

Обзор доступных инструментов

Каждый уважающий себя костылеписец сначала ознакомится с уже готовыми решениями. И первым делом мы идём к нашему соратнику Android SDK:

Outline + elevation

Чтобы нарисовать тень исключительно средствами SDK, мы можем воспользоваться elevation и Outline. Первый управляет величиной размытия и цветом тени, а второй — формой тени и её смещением. Выглядеть это будет примерно так:

val view = View(context)
val layer = CustomShadowParams.shadow2().layers[0]
view.outlineProvider = object : ViewOutlineProvider() {
    override fun getOutline(view: View, outline: Outline) {
        outline.setRoundRect(
            layer.dX.toInt(),
            layer.dY.toInt(),
            layer.dX.toInt() + view.width,
            layer.dY.toInt() + view.height,
            buttonCornerRadius
        )
    }
}
view.elevation = layer.radius

Получаем стандартную тень:

42648b7b8506cba2d1e59eca8462db0d.png

Таким методом мы можем реализовать две наших тени из семи. Остальные, к сожалению, создать силами одного SDK не получится, потому что:

Стандартный механизм крайне ограничен. Мы не можем толком управлять ни цветом тени, ни величиной его размытия; мы можем только попробовать подобрать значения elevation, чтобы было максимально похоже на то, что нам нужно. Но тени нашей дизайн-системы таким методом не нарисовать.

Небольшое дополнение. Для API > 28 есть методы outlineAmbientShadowColor и outlineSpotShadowColor, которые позволяют поменять цвет тени. Например, если выставить для этих параметров значения в Color.RED, то получим вот такую симпатичную подсветку:

da05c85849ccf076c3c02bb9c2061695.png

Преимущества:

Недостатки:

9-patch

b8ac3ad2fa3e2847b8f4b9a45fd24c6e.jpeg

Из стандартных инструментов также вспомнился 9-patch. Я искренне надеюсь, что никто этим уже не пользуется, но давайте коротенько обсудим, почему этот метод не подходит. Его преимущество в том, что можно нарисовать что угодно, завернуть это в 9-patch и получится какая угодно тень с какими угодно значениями. Проблема в том, что сложно расставлять границы константной области для таких смещенных теней. И для каждой такой картинки нужно будет добавлять дополнительные отступы, чтобы компенсировать «кривость». Например, вот так будет выглядеть 9-patch для одной из наших теней:

8a12c9bd859b399366c6cc737ccfe734.png

Синим отмечена область, в которой мы должны нарисовать нашу View. А всё, что находится между этой областью и краями, придётся компенсировать отступами самой View. Такой подход крайне неудобен, и я рекомендую никогда не пользоваться 9-patch.

MaterialShapeDrawable

Блуждая в поисках решений, я наткнулся на MaterialShapeDrawable. Инструмент сам по себе интересный и позволяет рисовать нетривиальные фигуры. По теме статьи он интересен тем, что сам умеет рисовать тень. Немного кода, чтобы это всё запустить:

val shadowDrawable = MaterialShapeDrawable(
    ShapeAppearanceModel
        .Builder()
        .setAllCornerSizes(buttonCornerRadius)
        .build()
)

val view = View(context)
val layer = CustomShadowParams.shadow2().layers[0]
with(shadowDrawable) {
    fillColor = ColorStateList.valueOf(buttonColor)
    setShadowColor(layer.colorWithAlpha)
    elevation = layer.radius
    shadowCompatibilityMode = MaterialShapeDrawable.SHADOW_COMPAT_MODE_ALWAYS
}
view.background = shadowDrawable

И получаем весьма симпатичную тень:

7e2ae83b70dd6e9463e489fc7420f647.png

В целом неплохо, но нет смещений тени, нет возможности задать цвет. То есть с точки зрения рисования тени этот вариант менее гибкий, чем elevation + Outline. Ещё один неприятный момент заключается в том, что тень в MaterialShapeDrawable рисуется с помощью Bitmap, а это кажется избыточным.

Преимущества:

Недостатки:

ScriptIntrinsicBlur

Глядя на тень становится ясно, что это размытие какого-то оттенка серого. Для рисования размытия в Android SDK можно воспользоваться ScriptIntrinsicBlur. Тут, кроме непонятного API, с которым рядовой кнопкокрас может за всю свою жизнь и не столкнуться, есть проблема ограничения максимального значения размытия в 25 пикселей. Это довольно мало, поэтому проявим смекалку и сделаем вот что:

И нужно ещё сказать, что ScriptIntrinsicBlur работает с bitmap, который придётся создавать при каждой отрисовке.

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

В результате пропорции немного сбиваются, но в целом очень даже похоже на то, что нам нужно:

c1b19643bf8479bd128a4bd026dafd59.png

Преимущества:

  • Можно задать все параметры нашей тени: смещение, радиус, цвет.

Недостатки:

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

SetShadowLayer

Посчитав, что подход со ScriptIntrinsicBlur нам не подходит, я продолжил поиски. И наткнулся на интересный метод setShadowLayer у класса Paint. В документации сказано, что метод работает только для текста. Но, как часто это бывает, нам «недоговаривают». Правда в том, что это не работает для всего, кроме текста, если включено аппаратное ускорение. И если выключить его для конкретной View, то тень будет рисоваться для всего, что рисуется с заданным Paint. Такое ограничение работает до девятой версии Android, а в более поздних версиях можно использовать метод без каких-либо ограничений. Код отрисовки такой тени можно добавить как в кастомный Drawable, так и в саму View, ведь в обоих случаях рисовать будем через canvas, а значит и код будет одинаковым:

private val shadowPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    shadowParams.layers.forEach {
        shadowPaint.setShadowLayer(it.radius, it.dX, it.dY, it.colorWithAlpha)
        canvas.drawRoundRect(
            0f,
            0f,
            width.toFloat(),
            height.toFloat(),
            roundRadius,
            roundRadius,
            shadowPaint
        )
    }
    canvas.drawRoundRect(
        0f,
        0f,
        width.toFloat(),
        height.toFloat(),
        roundRadius,
        roundRadius,
        backgroundPaint
    )
}

Выглядит очень даже неплохо:

9ca161fe79fce2b646279db8df400996.png

Преимущества:

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

  • Простая реализация.

Недостатки:

Очень досадное ограничение с версией Android, которое не позволяет использовать этот метод в нашем проекте. Дойдя до этого пункта я задумался, а как сама ОС рисует тень? Решил проследить, куда уходит Outline, который генерирует View. Оказалось, что он передается в RenderNode, а затем в нативный код:

public boolean setOutline(@Nullable Outline outline) {
    if (outline == null) {
        return nSetOutlineNone(mNativeRenderNode);
    }

    switch (outline.mMode) {
        case Outline.MODE_EMPTY:
            return nSetOutlineEmpty(mNativeRenderNode);
        case Outline.MODE_ROUND_RECT:
            return nSetOutlineRoundRect(mNativeRenderNode,
                outline.mRect.left, outline.mRect.top,
                outline.mRect.right, outline.mRect.bottom,
                outline.mRadius, outline.mAlpha);
        case Outline.MODE_PATH:
            return nSetOutlinePath(mNativeRenderNode, outline.mPath.mNativePath,
                    outline.mAlpha);
    }

    throw new IllegalArgumentException("Unrecognized outline?");
}

В нативный код лезть я пока не готов, поэтому решил посмотреть исходники SDK на https://cs.android.com/. В мастер-ветке в реализации ViewGroup я наткнулся на вспомогательный класс с интересным названием RectShadowPainter. Он с помощью градиента рисует тень для потомков контейнера. К слову, такой же подход используется в MaterialShapeDrawable.

CompatShadowRenderer

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

99ae8635506744996e2d0c6b173ca3fc.png

Для описания создадим ещё пару сущностей:

/**
 * Описание точек градиента
 *
 * @param point - удаление от начала градиента.
 * 0.0 - начало градинета. 1.0 - конец градиента
 *
 * @param colorMultiplier - изменение цвета в точке.
 * 0.0 - цвет тени, 1.0 - прозрачный цвет
 */
@Parcelize
data class GradientPointAndColorMultiplier(
    @FloatRange(from = 0.0, to = 1.0) val point: Float,
    @FloatRange(from = 0.0, to = 1.0) val colorMultiplier: Float
) : Parcelable

**
 * Параметры градиента
 *
 * @param colorsAndPoints - список точек градиента 
 * со значением цветов в этой точке
 */
@Parcelize
class GradientParams(
    val colorsAndPoints: List
) : Parcelable

Градиент мы опишем списком из пар сущностей. Одна такая сущность содержит точку — значение от 0 до 1, которое представляет собой удаление от начала градиента. И для каждой такой точки у нас должно быть значение цвета в этой точке. Так как цвет тени у нас хранится отдельно, тут удобней хранить значения прозрачности в интервале от 0 до 1. Например, для нашей тени Shadow2 эти значения будут такими:

GradientParams(
    listOf(
        GradientPointAndColorMultiplier(0f, 0.6f),
        GradientPointAndColorMultiplier(0.75f, 0.10f),
        GradientPointAndColorMultiplier(1f, 0f)
    )
)

Отрисовка тени для прямоугольника со скруглёнными углами происходит с помощью линейного градиента для сторон и радиального для углов. Мы задаём параметры именно для линейного градиента, а затем на его основе вычисляем параметры для радиального:

private fun createCornerParams(): GradientParams {
    val innerShadowSize = innerShadowSize
    val points = sideGradientParams.getPoints().map { point ->
        val pointsInPixelsInLinearGradient = (shadowSize + innerShadowSize) * point
        val radialGradientStartPoint = outlineCornerRadius - innerShadowSize
        (pointsInPixelsInLinearGradient + radialGradientStartPoint) / outerArcRadius
    }
    val newColorsAndPoints = sideGradientParams
        .colorsAndPoints
        .mapIndexed { index, gradientPointAndValue -> gradientPointAndValue.copy(point = points[index]) }
    return GradientParams(newColorsAndPoints)
}

Параметры:

  • shadowSize — радиус размытия;

  • innerShadowSize — смещение тени внутрь нашей фигуры, чтобы размытие увеличивалось в обе стороны: и от фигуры, и «под» ней;

  • outlineCornerRadius — радиус закругления для нашей фигуры;

  • outerArcRadius — внешний радиус радиального градиента.

Мы добавляем к точке линейного градиента значение начала радиального градиента, и получаем соответствующую точку для радиального градиента. Далее рисуем четыре линейных градиента для сторон и четыре радиальных для углов. Затем закрашиваем оставшиеся пустые области внутри фигуры, чтобы не оставалось «пробелов». Весь код можно посмотреть тут. Проверяем результат:

da927927068da853fd54c1730cbe0a5b.png

Выглядит хорошо, требованиям нашим удовлетворяет, кажется, что дело в шляпе. На этом шаге я обрадовался и начал добавлять в приложение тени для компонентов. И всё было хорошо, пока не потребовалось выставлять прозрачность для View. Казалось бы, что могло пойти не так? А вот что:

7b50d6323724b139711c67364a22c5e1.png

При выставлении прозрачности отрисовка View обрезает свой контент по своему размеру.

71da50a7563a8dc67c5a5b53def19a70.jpeg

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

Что делать? Есть несколько вариантов:

  • Написать кастомную ViewGroup, которая будет рисовать тени с помощью нашей CompatShadowRenderer для своих потомков.

  • Задавать прозрачность не самой View, а её контейнеру.

  • При выставлении прозрачности выключать отрисовку тени.

  • Использовать Jetpack Compose.

Jetpack Compose

Да, в Jetpack Compose проблемы обрезания теней нет. Переводить свои проекты на Compose нам всем придётся рано или поздно, поэтому добавим ещё один аргумент к этому переходу :) Compose не умеет в кастомизацию теней, так что нарисовать в нём наши тени из коробки тоже не получится. Но есть issue, поэтому, возможно, когда-нибудь у нас будет готовый удобный инструмент. А пока мы адаптировали CompatShadowRenderer под Jetpack Compose. Благо примитивы рисования очень похожи. Заменяем Canvas на DrawScope, Paint на Brush, и немного меняем подход к смещениям. Готовая реализация доступна по ссылке. Давайте посмотрим на результат:

fff880596c96e129c07b7e5d3e3f5632.png35021a4ba8124fa9d0e0edf92004354b.png

Верхний вариант — рисование с помощью Android SDK, а нижний — с помощью Jetpack Compose. Разницы практически нет, считаю это победой :)

Для удобства создаём Modifier:

fun Modifier.roundRectShadow(
    customShadowParams: CustomShadowParams,
    cornerRadius: Dp
) = this.then(ShadowDrawer(customShadowParams, cornerRadius))

private class ShadowDrawer(
    private val customShadowParams: CustomShadowParams,
    private val cornerRadius: Dp
) : DrawModifier {

    private val composeCompatShadowsRenderer = ComposeCompatShadowsRenderer()

    override fun ContentDrawScope.draw() {
        customShadowParams.layers.forEach {
            composeCompatShadowsRenderer.paintCompatShadow(
                canvas = this,
                outlineCornerRadius = cornerRadius.toPx(),
                shadow = it
            )
        }
        drawContent()
    }
}

И используем его в нужном месте:

Box(
    modifier = Modifier
        .width(Dp(buttonWidthDp))
        .height(Dp(buttonHeightDp))
        .roundRectShadow(
            customShadowParams = shadowParams,
            cornerRadius = Dp(buttonCornerRadiusDp)
        )
        .background(
            color = buttonColor,
            shape =RoundedCornerShape(
                topStart = Dp(buttonCornerRadiusDp),
                topEnd = Dp(buttonCornerRadiusDp),
                bottomEnd = Dp(buttonCornerRadiusDp),
                bottomStart = Dp(buttonCornerRadiusDp),
            )
        )
)

Что в итоге?

Имеем тысячу и один способ нарисовать тень :) В нашем проекте мы будем использовать рисование градиентом, потому что оно удовлетворяет нашим требованиям, мы можем настроить рисование тени как нам угодно, этот метод работает и в Android SDK, и в Jetpack Compose.

Что использовать вам? Используйте стандартный механизм, то есть elevation. Просто потому, что золотое правило «Меньше кода — меньше проблем» всегда актуально. Если для вас это не вариант, то вот вам ссылка на репозиторий, где лежит весь код, который я использовал в статье. Там есть все варианты теней и удобный редактор для их настройки. Для тех, кто привык пользоваться готовыми библиотеками, есть разработка от нашего коллеги по цеху, которая рисует тени градиентом. На этом всё, желаю вам красить только самые красивые кнопки!

© Habrahabr.ru