Мы любим своих дизайнеров за то, что они придумывают нам такие классные и красивые кнопки. Но нарисовать кнопку может каждый, а как насчёт тени от кнопки? Я расскажу, как мы решили задачу с тенями для наших контролов и сделали для нашей дизайн-системы не одну, а целых семь теней.
Постановка задачи
Для наглядности я покажу, как выглядит самая сложная красивая тень в нашей дизайн-системе:
Параметры, которые тут фигурируют:
0px — смещение по оси X;
11px — смещение по оси Y;
15px — размытие тени;
#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<Shadow>
) : 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
, а это кажется избыточным.
Преимущества:
Готовая реализация тени прямо в
Drawable
.
Недостатки:
Нет возможности задать смещение и цвет тени.
Используется
Bitmap
.
ScriptIntrinsicBlur
Глядя на тень становится ясно, что это размытие какого-то оттенка серого. Для рисования размытия в Android SDK можно воспользоваться ScriptIntrinsicBlur. Тут, кроме непонятного API, с которым рядовой кнопкокрас может за всю свою жизнь и не столкнуться, есть проблема ограничения максимального значения размытия в 25 пикселей. Это довольно мало, поэтому проявим смекалку и сделаем вот что:
Уменьшаем изображение на значение
radius/25
.Размываем.
Увеличиваем до прежнего размера.
И нужно ещё сказать, что ScriptIntrinsicBlur
работает с bitmap
, который придётся создавать при каждой отрисовке.
Код я тут вставлять не буду, в нём нет ничего интересного, просто создаём Bitmap
, рисуем в нём нашу фигуру, размываем её и сверху рисуем фигуру белым цветом. Если вам всё же интересно посмотреть код, оставлю ссылку.
В результате пропорции немного сбиваются, но в целом очень даже похоже на то, что нам нужно:
Преимущества:
Можно задать все параметры нашей тени: смещение, радиус, цвет.
Недостатки:
Для создания экземпляра
ScriptIntrinsicBlur
нужен контекст.Используется
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 < 9 нужно отключать аппаратное ускорение для
View
.
Очень досадное ограничение с версией 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<GradientPointAndColorMultiplier>
) : 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
. Просто потому, что золотое правило «Меньше кода — меньше проблем» всегда актуально. Если для вас это не вариант, то вот вам ссылка на репозиторий, где лежит весь код, который я использовал в статье. Там есть все варианты теней и удобный редактор для их настройки. Для тех, кто привык пользоваться готовыми библиотеками, есть разработка от нашего коллеги по цеху, которая рисует тени градиентом. На этом всё, желаю вам красить только самые красивые кнопки!
Комментарии (21)
amarao
22.02.2022 14:37+2Мне кажется, что вы мухлюете. Тень - это не кружочки с овальчиками. Тень - это область, в которую не попадает свет или попадает только отражённый/рассеянный свет.
Делаете тень? Используйте ray tracing. Поставьте парочку источников света, отрендерите кнопку, отрендерите фон, посчитайте тень. Не забудьте подповерхностное рассеяние и отражения от других кнопок.
Все эти игрища с кружочками - мухлёж и обман.
У большинства пользователей уже давно есть NVIDIA RTX3060, так что использование рейтрейсинга для отрисовки тени для кнопки более чем оправдано.
Vest
22.02.2022 14:42+2Всё жду, когда телефон сзади в штанах дырку прожжёт. В своё время такую гипотетическую ситуацию описывали на Баше. Теперь я вижу способы, как это можно реализовать.
Хорошая статья.
DEADMC
22.02.2022 15:55+3Спасибо, поставил на свой андроидофон RTX3070 и тени действительно стали гораздо лучше! Правда пришлось ставить еще систему охлаждения и теперь 3кг аккумулятор немного карман оттягивает, может еще с этой проблемой решение предложите?
nbinik Автор
22.02.2022 18:52-1У большинства пользователей уже давно есть NVIDIA RTX3060
Ни разу в телефоне на Android такого не видел )
В остальном в статье описаны варианты, которые можно реализовать имеющимся инструментами. Не уверен что `ray tracing` входит в число доступных нам инструментов ) Да и задача не нарисовать `честную тень` а нарисовать тень, которая есть на макетах, или максимально близкую к ней
amarao
23.02.2022 10:35+1А зачем рисовать тень, максимально близкую к тени на макетах?
Зачем рисовать тень?
TheGodfather
22.02.2022 14:39+10Кажется, этот пост — прекрасная иллюстрация к текущему состоянию индустрии. Несколько человек тратят дни жизни, чтобы сделать какую-то упоротую никому не нужную фигню, но при это рисуют графички всякие, мол, так и надо. Пользователь блин даже не заметит, есть у вас там тени или нет, используется ли «тень номер семь» или «тень номер семь тысяч четыреста пятьдесят два». Бизнесу стоило бы потратить все это безумное количество «теневых» денег на что-нибудь более полезное, имхо.
Получаем стандартную тень:
Ну и славно? Все, закончили на этом, зачем время тратить-то дальше? Вы бы хоть сравнение привели, чем оно отличается от последующих теней.Sadler
22.02.2022 15:00-2Тратить на тени сотни часов, наверное, не стоит, но определённая важная роль у них имеется, потому думать о них всё-таки иногда следует. Тени -- это один из элементов опыта из реального мира, такой же, как кнопки и переключатели. Это то, что позволяет пользователю с первого взгляда выделять важные элементы дизайна, не слишком (в идеале) замусоривая экран. Плюс, тени иногда позволяют обеспечить необходимый уровень контраста между текстом и динамически изменяющейся подложкой.
nbinik Автор
22.02.2022 19:02более полезное
Это ведь абстрактное понятие, если бизнес считает что нужна дизайн система а дизайнеры считают что в рамках этой дизайн системы нужны одинаковые везде тени, то это стоит затраченных усилий.
тратят дни жизни
Коллеги сэкономят больше затраченного мной времени, когда не будут `на глаз` подбирать тень, а просто указывать один из вариантов уже готовых теней.
Ну и славно? Все, закончили на этом, зачем время тратить-то дальше?
Потому-что нужно не стандартную а именно такую как на макете ) Но в этом вопросе я с вами согласен, если нет необходимости в конкретных тенях, то лучше остановиться на стандартном механизме. В нашем случае нам такой вариант не подошел.
IvanPetrof
22.02.2022 15:11+5Тени возвращаются
amarao
22.02.2022 15:24+2Во-во, мне тоже эта нелепость вспомнилась. Вместе с "плавным курсором" нортоновских утилит последних версий (когда курсор можно было двигать в пределах знакоместа, а достигалось это перепрограммированием знакогенератора).
SShtole
22.02.2022 18:13[offtop]
В детстве сильно недоумевал, что значит “The fat lady sang”. Много лет спустя узнал, что это оперная поговорка: «Пока толстая дама не споёт, это ещё не конец» (используется для троллинга крупного начальства на совещаниях при подведении итогов). Толстым дамам поручали арию валькирии Брунхильды из «Кольца Нибелунгов» по причине подходящего голоса и соответствия образу. Пели они её 20 минут подряд в самом конце. Соответственно, «Толстая дама спела» == «КонецЪ!».
[/offtop]
MentalBlood
22.02.2022 15:52+3Мы любим своих дизайнеров за то, что они придумывают нам такие классные и красивые кнопки
Удобство и отзывчивость интерфейса все чаще приносятся в жертву "классности" и "красивости"
laatoo
22.02.2022 18:52+1чем ближе предмет к поверхности, тем ярче и меньше тень, и наоборот.
2 параметра, которые зависят только от дальности предмета от базовой поверхности (z-level).
z-level - множитель, длина и ширина тени - параметры.
откуда столько сложности и зачем столько человекочасов на тратить на переизобретение расчета теней, будучи приложением для заказа такси - решительно непонятно.
Psychosynthesis
23.02.2022 01:29+4Если дизайнер "на серьёзных щщах" рисует под кнопкой три тени, и искренне считает, что она смотрится лучше чем точно такая же кнопка с одной тенью... его, скорее всего, надо отправить на переобучение. В простом случае...
В реальности, скорее всего, такой человек ещё будет тратить время на то чтобы сравнить разницу между его чудесными макетами в Figma и вёрсткой в нескольких браузерах с помощью чего-то типа расширения PixelPerfect. И, придя в ужас от того что в некоторых браузерах тень рисуется чуть-чуть по другому, этот гений-дизайнер будет тратить ещё и время фронтендера на вещи, которые по факту вообще ничего кроме дополнительного CSS-кода не несут. В таких случаях я даже не знаю что делать - моё личное мнение, что это уже какая-то психологическая проблема, но это, конечно, только мой взгляд на вопрос.
К сожалению, у меня был опыт работы именно с таким дизайнером, и второй абзац моего комментария это вовсе не выдумка. Это всё очень грустно, конечно.
Если вы - дизайнер и вдруг читаете этот комментарий, у меня к вам личная просьба - пожалуйста, попытайтесь сделать кнопку вообще без теней, может оно даже лучше будет, а?
Pan_brigadir
24.02.2022 11:13Отличная статья. Из таких мелочей складывается впечатление о приложении.
Что по производительности? Делали ли вы замеры FPS с этими тенями и без них? Я предполагаю, что стык Compose и View для того чтобы нарисовать тени обходится не "бесплатно".
Eddy71
На картинке с Джеком "покАрсим" вместо "покрасим" :)
nbinik Автор
И правда, поправили ) Спасибо :)