Не стоит бояться теней
Мы любим своих дизайнеров за то, что они придумывают нам такие классные и красивые кнопки. Но нарисовать кнопку может каждый, а как насчёт тени от кнопки? Я расскажу, как мы решили задачу с тенями для наших контролов и сделали для нашей дизайн-системы не одну, а целых семь теней.
Постановка задачи
Для наглядности я покажу, как выглядит самая сложная красивая тень в нашей дизайн-системе:
Параметры, которые тут фигурируют:
0 px — смещение по оси X;
11 px — смещение по оси Y;
15 px — размытие тени;
#00000040 — цвет тени.
Такая тень примечательна тем, что на самом деле тут не одна, а целых три тени.
В Figma это выглядит так:
Всего в нашей дизайн системе семь теней с различными параметрами смещений, размытия и цветов прозрачности черного
Надо сказать, что и фигуры у нас не простые, а золотыесложные (используем 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
Получаем стандартную тень:
Таким методом мы можем реализовать две наших тени из семи. Остальные, к сожалению, создать силами одного SDK не получится, потому что:
Стандартный механизм крайне ограничен. Мы не можем толком управлять ни цветом тени, ни величиной его размытия; мы можем только попробовать подобрать значения elevation
, чтобы было максимально похоже на то, что нам нужно. Но тени нашей дизайн-системы таким методом не нарисовать.
Небольшое дополнение. Для API > 28 есть методы outlineAmbientShadowColor
и outlineSpotShadowColor
, которые позволяют поменять цвет тени. Например, если выставить для этих параметров значения в Color.RED
, то получим вот такую симпатичную подсветку:
Преимущества:
Недостатки:
9-patch
Из стандартных инструментов также вспомнился 9-patch. Я искренне надеюсь, что никто этим уже не пользуется, но давайте коротенько обсудим, почему этот метод не подходит. Его преимущество в том, что можно нарисовать что угодно, завернуть это в 9-patch и получится какая угодно тень с какими угодно значениями. Проблема в том, что сложно расставлять границы константной области для таких смещенных теней. И для каждой такой картинки нужно будет добавлять дополнительные отступы, чтобы компенсировать «кривость». Например, вот так будет выглядеть 9-patch для одной из наших теней:
Синим отмечена область, в которой мы должны нарисовать нашу 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
И получаем весьма симпатичную тень:
В целом неплохо, но нет смещений тени, нет возможности задать цвет. То есть с точки зрения рисования тени этот вариант менее гибкий, чем elevation
+ Outline
. Ещё один неприятный момент заключается в том, что тень в MaterialShapeDrawable рисуется с помощью Bitmap
, а это кажется избыточным.
Преимущества:
Недостатки:
ScriptIntrinsicBlur
Глядя на тень становится ясно, что это размытие какого-то оттенка серого. Для рисования размытия в Android SDK можно воспользоваться ScriptIntrinsicBlur. Тут, кроме непонятного API, с которым рядовой кнопкокрас может за всю свою жизнь и не столкнуться, есть проблема ограничения максимального значения размытия в 25 пикселей. Это довольно мало, поэтому проявим смекалку и сделаем вот что:
И нужно ещё сказать, что ScriptIntrinsicBlur
работает с bitmap
, который придётся создавать при каждой отрисовке.
Код я тут вставлять не буду, в нём нет ничего интересного, просто создаём Bitmap
, рисуем в нём нашу фигуру, размываем её и сверху рисуем фигуру белым цветом. Если вам всё же интересно посмотреть код, оставлю ссылку.
В результате пропорции немного сбиваются, но в целом очень даже похоже на то, что нам нужно:
Преимущества:
Можно задать все параметры нашей тени: смещение, радиус, цвет.
Недостатки:
В отличие от всех вышеперечисленных подходов этот хотя бы удовлетворяет нашим требованиям. Но недостатки совсем отбивают желание его использовать.
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
)
}
Выглядит очень даже неплохо:
Преимущества:
Можно задать все параметры теней, которые нас интересуют.
Простая реализация.
Недостатки:
Очень досадное ограничение с версией 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
. Рисование градиента представляет собой переход из одного цвета в другой. При создании градиента мы должны передать цвета. между которыми нужно отобразить переходы, и точки этих переходов.
Для описания создадим ещё пару сущностей:
/**
* Описание точек градиента
*
* @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
— внешний радиус радиального градиента.
Мы добавляем к точке линейного градиента значение начала радиального градиента, и получаем соответствующую точку для радиального градиента. Далее рисуем четыре линейных градиента для сторон и четыре радиальных для углов. Затем закрашиваем оставшиеся пустые области внутри фигуры, чтобы не оставалось «пробелов». Весь код можно посмотреть тут. Проверяем результат:
Выглядит хорошо, требованиям нашим удовлетворяет, кажется, что дело в шляпе. На этом шаге я обрадовался и начал добавлять в приложение тени для компонентов. И всё было хорошо, пока не потребовалось выставлять прозрачность для View
. Казалось бы, что могло пойти не так? А вот что:
При выставлении прозрачности отрисовка View
обрезает свой контент по своему размеру.
Такое поведение зашито глубоко в реализации View
, и повлиять на это мы, к сожалению, не можем.
Что делать? Есть несколько вариантов:
Написать кастомную
ViewGroup
, которая будет рисовать тени с помощью нашейCompatShadowRenderer
для своих потомков.Задавать прозрачность не самой
View
, а её контейнеру.При выставлении прозрачности выключать отрисовку тени.
Использовать Jetpack Compose.
Jetpack Compose
Да, в Jetpack Compose проблемы обрезания теней нет. Переводить свои проекты на Compose нам всем придётся рано или поздно, поэтому добавим ещё один аргумент к этому переходу :) Compose не умеет в кастомизацию теней, так что нарисовать в нём наши тени из коробки тоже не получится. Но есть issue, поэтому, возможно, когда-нибудь у нас будет готовый удобный инструмент. А пока мы адаптировали CompatShadowRenderer
под Jetpack Compose. Благо примитивы рисования очень похожи. Заменяем Canvas
на DrawScope
, Paint
на Brush
, и немного меняем подход к смещениям. Готовая реализация доступна по ссылке. Давайте посмотрим на результат:
Верхний вариант — рисование с помощью 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
. Просто потому, что золотое правило «Меньше кода — меньше проблем» всегда актуально. Если для вас это не вариант, то вот вам ссылка на репозиторий, где лежит весь код, который я использовал в статье. Там есть все варианты теней и удобный редактор для их настройки. Для тех, кто привык пользоваться готовыми библиотеками, есть разработка от нашего коллеги по цеху, которая рисует тени градиентом. На этом всё, желаю вам красить только самые красивые кнопки!