Вот есть у нас приложение. Серьезное, большое, взрослое.
Обходимся практически без стилей, но без беспорядка; используем
себе виджеты из AppCompat, но уже затянули тему из Material Design
Components (MDC) и подумываем о полноценной миграции.
И вдруг появляется задача на полный redesign. А у нового дизайна
со старым общая разве что бизнес логика. Компоненты новые, шрифты
нестандартные, цвета (за исключением фирменных) другие. В общем
приходит осознание того, что пришло время переезжать на MDC.
Но не все так просто:
-
Redesign предполагается по частям. То есть в приложении будут
как экраны со старым, так и с новым внешним видом
-
Цвета и типографика в новом дизайне отличны от того, что
рекомендует MDC. Хотя принципы именования схожи
-
Presentation слой разбит на отдельные ui модули. Причем
некоторые из них используются другим приложением. Учитывая, что
обходимся без стилей, для стилизации в таких модулях некоторые
свойства спрятаны за атрибуты: цвета, текстовые стили, строки и
многое другое
-
Существует налаженная схема на предмет того, как работать с
вышеупомянутыми ui модулями. В частности с атрибутами. А значит и с
цветами, текстовыми стилями, строками и прочим. А при MDC хотелось
бы использовать стили
Далее делюсь опытом того, как справиться с этими трудностями:
как при переезде на MDC частично стилизовать Android приложение с
независимыми ui модулями, абстрагироваться от дизайн системы и при
этом ничего не сломать. Бонусом - советы и разбор сложностей, с
которыми я столкнулся.
лего равно стили
Про ui модули
Есть ui модули. Они не зависят от проекта. Лежат отдельно от
него.
Внутри каждого из проектов есть корневой модуль. Назовем его
core-presentation. Он зависит от тех ui модулей,
которые используются в данном приложении. Подключаются модули как
обычная gradle зависимость.
Возникает вопрос. А как стилизовать-то? Если коротко, то с
помощью атрибутов. Внутри каждого такого ui модуля определены
используемые атрибуты, которые должны быть реализованы темой
приложения:
<resources><!-- src --><attr name = "someUiModuleBackgroundSrc" format = "reference" /><!-- string --><attr name = "someUiModuleTitleString" format = "reference" /><attr name = "someUiModuleErrorString" format = "reference" /><!-- textAppearance --><attr name = "someUiModuleTextAppearance1" format = "reference" /><attr name = "someUiModuleTextAppearance2" format = "reference" /><attr name = "someUiModuleTextAppearance3" format = "reference" /><attr name = "someUiModuleTextAppearance4" format = "reference" /><attr name = "someUiModuleTextAppearance5" format = "reference" /><attr name = "someUiModuleTextAppearance6" format = "reference" /><attr name = "someUiModuleTextAppearance7" format = "reference" /><attr name = "someUiModuleTextAppearance8" format = "reference" /><!-- color --><attr name = "someUiModuleColor1" format = "reference" /><attr name = "someUiModuleColor2" format = "reference" /></resources>
Используются они примерно так:
<androidx.appcompat.widget.AppCompatTextViewandroid:background = "?someUiModuleBackgroundSrc"android:text = "?someUiModuleErrorString"android:textAppearance = "?someUiModuleTextAppearance5".../>
Ближе к "теме" (стилю)
У меня появился план. Простой, но от того не менее гениальный.
План базировался на нескольких принципах, а я, в свою очередь, его
придерживался.
Собственно, принципы:
-
Так как MDC тема уже затянута, ничто не мешает использовать
виджеты из MDC. Никакого AppCompat'a. И хоть под
капотом framework компоненты переопределяются в аналоги из
MDC, явное использование последних компонент все
же нагляднее:
<TextView.../><!-- Bad --><androidx.appcompat.widget.AppCompatTextView.../><!-- Bad --><com.google.android.material.textview.MaterialTextView.../><!-- Good -->
-
Все компоненты (классы, ресурсы, атрибуты) нового ui в названии
содержат какой-нибудь одинаковый префикс или постфикс (например,
v2)
-
Стиль - это единственный способ изменить
внешний вид View. Иными словами, каждая View
обладает стилем (либо через style
в xml, либо через
дефолтный атрибут стиля посредством defStyleAttr
), и
только этот стиль определяет её внешний вид.
Примеры:
<!-- Good --><com.google.android.material.appbar.MaterialToolbarstyle = "?toolbarStyleV2"/><!-- Bad --><com.google.android.material.appbar.MaterialToolbarandroid:background = "?primaryColorV2"/>
-
Название стиля не должно раскрывать его внешний
вид. При этом оно должно базироваться на названии компонента дизайн
системы. Примеры:
<item name = "filledTextInputStyleV2">@style/V2.Widget.MyFancyApp.TextInputLayout.Filled</item> <!-- Bad --><item name = "searchTextInputStyleV2">@style/V2.Widget.MyFancyApp.TextInputLayout.Search</item> <!-- Good --><item name = "blackOutlinedButtonStyleV2">@style/V2.Widget.MyFancyApp.Button.BlackOutlined</item> <!-- Bad --><item name = "primaryButtonStyleV2">@style/V2.Widget.MyFancyApp.Button.Primary</item> <!-- Good --><item name = "secondaryButtonStyleV2">@style/V2.Widget.MyFancyApp.Button.Secondary</item> <!-- Good --><item name = "textButtonStyleV2">@style/V2.Widget.MyFancyApp.Button.Text</item> <!-- Ok. Based on Figma component name -->
-
Все ресурсы, включая имплементации стилей, лежат внутри
core-presentation
Как итог:
-
Получаем абстрактные стили. Проекты независимы в области палитр,
текстовых стилей и любых других составляющих внешнего вида
-
UI модули не содержат никаких ресурсов
-
Пересечение именований компонентов старого и нового ui исключено
вследствие префикса-постфикса
Вроде не сложно: используй только стили; определяй нужные цвета
в этих стилях. Но так ли это все просто на практике?
Да. Но ровно до тех пор, пока не нарвешься на
TextView
. А как быть здесь? Ровно также. Использовать
стили. Проблема лишь в том, что таких стилей будет до бесконечности
много. Почти под каждый TextView
нужно заводить
отдельный стиль. В защиту такого решения отмечу, что из статьи про MDC можно
косвенно сделать вывод, что тривиальный текст - тоже отдельный
стиль:
While TextAppearance does support android:textColor, MDC tends
to separate concerns by specifying this separately in the main
widget styles
Примеры:
<item name = "v2TextStyleGiftItemPrice">@style/V2.Widget.MyFancyApp.TextView.GiftItemPrice</item><item name = "v2TextStyleGiftItemName">@style/V2.Widget.MyFancyApp.TextView.GiftItemName</item>...<style name = "V2.Widget.MyFancyApp.TextView.GiftItemPrice"> <item name = "android:textAppearance">?v2TextAppearanceCaption1</item> <item name = "android:textColor">?v2ColorOnPrimary</item></style><style name = "V2.Widget.MyFancyApp.TextView.GiftItemName"> <item name = "android:textAppearance">?v2TextAppearanceCaption1</item> <item name = "android:textColor">?v2ColorOnPrimary</item> <item name = "textAllCaps">true</item> <item name = "android:background">?v2ColorPrimary</item></style>...<com.google.android.material.textview.MaterialTextViewstyle = "?v2TextStyleGiftItemPrice".../><com.google.android.material.textview.MaterialTextViewstyle = "?v2TextStyleGiftItemName".../>
Если приглядеться, то можно заметить, что для всех названий
атрибутов стилей в примере используется постфикс
v2 (например, primaryButtonStyleV2
),
а для текстовых стилей - префикс
(v2TextStyleGiftItemName
). Сделано это для того, чтобы
упростить навигацию при автоподстановке IDE.
По итогу, после таких переделок файл с атрибутами в новом ui
модуле выглядит примерно так:
<resources><!-- Общие стили --><attr name = "cardStyleV2" format = "reference" /><attr name = "appBarStyleV2" format = "reference" /><attr name = "toolbarStyleV2" format = "reference" /><attr name = "primaryButtonStyleV2" format = "reference" />...<!-- Стили для TextView --><attr name = "v2TextStyleGiftCategoryTitle" format = "reference" /><attr name = "v2TextStyleGiftItemPrice" format = "reference" /><attr name = "v2TextStyleSearchSuggestion" format = "reference" /><attr name = "v2TextStyleNoResultsTitle" format = "reference" />...<!-- Иконки --><attr name = "ic16CreditV2" format = "reference" /><attr name = "ic24CloseV2" format = "reference" /><attr name = "ic48GiftSentV2" format = "reference" />...<!-- Строки --><attr name = "shopTitleStringV2" format = "reference" /><attr name = "shopSearchHintStringV2" format = "reference" /><attr name = "noResultsStringV2" format = "reference" />...<!-- styleable кастомных View --><declare-styleable name = "ShopPriceSlider"><attr name = "maxPrice" format = "integer" /></declare-styleable></resources>
Почти все зашито в стили. Исключение составляют строки и иконки.
Они имеют отношение к контенту, а не к внешнему виду.
Вообще, строки можно было бы зашить в соответствующие стили для
TextView
, но бывают случаи, когда строка нужна в коде
(и пробросить через стиль ее попросту не получится).
Что касается иконок, то, в целом, под них тоже можно завести
отдельные стили. Все на стилях.
А как быть с android:background
, когда просто нужна
какая-нибудь подложка? Цвет или форма там какая-нибудь. Об этом
чуть позже. Спойлер - через стили.
Рассмотрим несколько стилей:
<style name = "V2.Widget.MyFancyApp.TextView.GiftItemName"> <item name = "android:textAppearance">?v2TextAppearanceCaption1</item> <item name = "android:textColor">?v2ColorOnPrimary</item></style><style name = "V2.Widget.MyFancyApp.Button.Primary" parent = "Widget.MaterialComponents.Button">...</style><style name = "V2.Widget.MyFancyApp.Button.Primary.Price">...<item name = "icon">?ic16CreditV2</item></style>
Можно заметить, что текстовые стили
(android:textAppearance
) и цвета используются через
атрибуты. Также и иконки. И это все в
core-presentation, где, собственно, все это
доступно и напрямую (через @color/
,
@style/
, @drawable/
). Так зачем же?
Ответ: для гибкости. Такой подход дает преимущества в случае
появления новых тем. Примеры:
-
Темная (или любая другая, отличная от оригинальной по палитре)
тема. В новой теме просто меняем значения атрибутов цветов на
нужные
-
"Тематические" темы (Halloween, Christmas, Easter и так далее).
Переопределяем иконки и шрифты под саму тематику. Разобраться с
тем, как и когда использовать такие темы, - дело третье
Подводные камни, сложности, советы
MaterialThemeOverlay
Если вдруг вам потребуется определить android:theme
в дефолтном стиле кастомной View, то ничего у вас не выйдет. Просто
не сработает. Хотя для любого другого, не дефолтного стиля все
отлично работает. Подробнее проблема разобрана в этой статье.
Но отчаиваться не стоит, ведь и для данного проблемного случая
есть решение. Меняем android:theme
на
materialThemeOverlay
, оборачиваем контекст через
MaterialThemeOverlay.wrap(...)
и все работает.
Где-то в xml:
<item name = "achievementLevelBarStyleV2">@style/V2.Widget.MyFancyApp.AchievementLevelBar</item><style name = "V2.Widget.MyFancyApp.AchievementLevelBar" parent = ""><item name = "materialThemeOverlay">@style/V2.ThemeOverlay.MyFancyApp.AchievementLevelBar</item></style>
Сама кастомная View:
class AchievementLevelBar @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyleAttr: Int = R.attr.achievementLevelBarStyleV2) : LinearLayoutCompat(MaterialThemeOverlay.wrap(context, attrs, defStyleAttr, 0), attrs, defStyleAttr) {init {View.inflate(context, R.layout.achievement_level_bar, this)...}...}
И это не работает. А не работает это из-за того, что манипуляции
в init {}
блоке осуществляются с исходным
context
, а не с обернутым. Отсюда вырисовывается очень
простое правило: никогда не использовать исходный
context
при инициализации. Для того, чтобы в данном
примере materialThemeOverlay
заработал, необходимо
context
заменить на getContext()
. Просто
оставлю кусок MaterialButton
здесь:
public MaterialButton(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(wrap(context, attrs, defStyleAttr, DEF_STYLE_RES), attrs, defStyleAttr); // Ensure we are using the correctly themed context rather than the context that was passed in. context = getContext();
(А если так сделать в Kotlin, то Lint будет ругаться на name
shadowing. Грусть)
Light status bar
У нас на проекте для подложки под status bar используется
кастомная StatusBarView
. В идеале, такой штуки быть не
должно (потому что edge-to-edge), но пока что она
присутствует. Довольствуемся тем, что есть.
Так вот, в старом дизайне status bar повсеместно translucent.
Что это значит: есть какой-то полупрозрачный темный overlay (причем
везде разный), а цвет контента - белый или около того. В новом же
дизайне status bar может быть светлым (light): со светлым
background и темным контентом.
Слева
- translucent; справа - light
Собственно задача заключается в том, чтобы уметь поддерживать
light status bar наравне с translucent через кастомную
StatusBarView
. Нюансы:
-
Для поддержки light status bar необходима 23я версия SDK (или
выше). Для всех версий, что ниже, можно отображать дефолтный
translucent status bar (идея взята отсюда)
-
Translucent status bar достигается с помощью выставления флага
FLAG_TRANSLUCENT_STATUS
; overlay без полупрозрачности
(для light) - с помощью
FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
-
Чтобы менять цвет контента, понадобятся следующие методы:
fun setLightStatusBar() {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {var flags = window.decorView.systemUiVisibilityflags = flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BARwindow.decorView.systemUiVisibility = flags}}fun clearLightStatusBar() {if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {var flags = window.decorView.systemUiVisibilityflags = flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()window.decorView.systemUiVisibility = flags}}
class StatusBarView @JvmOverloads constructor(context: Context,attrs: AttributeSet? = null,defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) {init {...systemUiVisibility = SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN}}
-
Чтобы окончательно использовать кастомную
StatusBarView
для light status bar, нужно задать
прозрачный statusBarColor
-
Возвращаясь к стилям, всю эту логику с light / translucent
status bar можно зашить в кастомный атрибут
StatusBarView
Color State List (CSL)
В MDC статье про цвета для
полупрозрачных оттенков какого-либо цвета советуется использовать
CSL. Дело в том, что с 23й версии SDK для CSL доступны атрибуты. И
свойство android:alpha
. А если соединить, то получится
любой цвет с любой прозрачностью.
Выглядит это примерно так:
color/v2_on_background_20.xml
<selector xmlns:android = "http://schemas.android.com/apk/res/android"><item android:alpha = "0.20" android:color = "?v2ColorOnBackground" /></selector>
Используются такие цвета не через атрибут, а напрямую, через
@color/
. Мощь данного подхода в том, что такой CSL
зависит от какого-то цвета. Что внутри
v2ColorOnBackground
не имеет никакого значения. Без
CSL пришлось бы лезть в палитру и добавлять для каждого
v2ColorOnBackground
аналог с 20% прозрачностью:
<color name = "black">#000000</color> <!-- v2ColorOnBackground --><color name = "black_20">#33000000</color> <!-- v2ColorOnBackground 20% opacity -->
Хоть это все и здорово, но есть свои заморочки:
-
Как уже писал ранее, для поддержки необходима 23я версия SDK и
выше. Но вообще, для MDC виджетов все работает нормально и с 21й
версии. Если же так получилось, что нужно дернуть такой CSL через
атрибут (например, в кастомной View для кастомного атрибута), то на
помощь приходит метод MaterialResources.getColorStateList(). Вот
только это является частью Restricted API, но кого это
останавливало
-
CSL не работает в качестве android:background
и
схожих. Но ничто не мешает сделать так:
<style name = "V2.Widget.MyFancyApp.Divider" parent = ""><item name = "android:background">@drawable/v2_rect</item><item name = "android:backgroundTint">@color/v2_on_background_15</item>...</style>
Подложка и android:background
Сразу к делу. Никаких </shape>
через xml. Вот
v2_rect.xml из примера выше - это единственный
допустимый случай. MDC отказался от этого. И всем следует.
А если нужна подложка, то почему бы не посмотреть в сторону
ShapeableImageView
(ну или на крайний случай
MaterialCardView
)? Здесь и способов кастомизации
больше. Как пример:
<com.google.android.material.imageview.ShapeableImageViewstyle = "?shimmerStyleV2" .../><item name = "shimmerStyleV2">@style/V2.Widget.MyFancyApp.Shimmer</item><style name = "V2.Widget.MyFancyApp.Shimmer"><item name = "srcCompat">@drawable/v2_rect</item><item name = "tint">@color/v2_on_background_15</item><item name = "shapeAppearance">@style/V2.ShapeAppearance.MyFancyApp.SmallComponent.Shimmer</item></style>
Стили компонент ViewGroup
Рассмотрим пример:
<com.google.android.material.appbar.AppBarLayoutstyle = "?appBarStyleV2"...><my.magic.path.StatusBarViewstyle = "?statusBarStyleV2".../><com.google.android.material.appbar.MaterialToolbarstyle = "?toolbarStyleV2".../></com.google.android.material.appbar.AppBarLayout>
Представим, что такая конструкция встречается почти на каждом
новом экране. Учтем, что здесь определено три атрибута стиля.
Вдруг появляется нестандартный экран. На нем все три стиля
отличаются. Вопрос: сколько новых атрибутов потребуется? Правильный
ответ - один, для AppBarLayout
(назовем новый атрибут
secondaryAppBarStyleV2
). Для всего остального есть
ThemeOverlay:
<item name = "secondaryAppBarStyleV2">@style/V2.Widget.MyFancyApp.AppBarLayout.Secondary</item><style name = "V2.Widget.MyFancyApp.AppBarLayout.Secondary"><item name = "materialThemeOverlay">@style/V2.ThemeOverlay.MyFancyApp.AppBarLayout.Secondary</item>...</style><style name = "V2.ThemeOverlay.MyFancyApp.AppBarLayout.Secondary" parent = ""><item name = "statusBarStyleV2">@style/V2.Widget.MyFancyApp.StatusBar.Secondary</item><item name = "toolbarStyleV2">@style/V2.Widget.MyFancyApp.Toolbar.Secondary</item></style>
Пример конкретный, но применять такое можно к любой ViewGroup. В
частности, к кастомной View. Если есть уверенность в том, что
какая-то View (и ее стиль) будет использоваться исключительно в
контексте определенной ViewGroup, то можно не имплементировать
атрибут ее стиля на уровне темы приложения, а сделать это на уровне
ThemeOverlay ViewGroup.
MaterialToolbar и Toolbar из AppCompat
Под капотом многие framework виджеты при inflate преобразуются в
соответствующие из MDC. Чтобы ничего случайно не сломать виджетами
из MDC, при затягивании темы (то есть до начала сего рассказа) все
framework виджеты были заменены аналогами из AppCompat. Примерно
так:
<!-- Было --><Toolbar.../><!-- Стало --><androidx.appcompat.widget.Toolbar.../>
И это нормально-таки себе работало. Таким образом получили
следующее: в новых скринах используется
MaterialToolbar
, в старых - Toolbar
из
AppCompat.
Здесь возник один интересный баг. Для стиля
MaterialToolbar
был определен атрибут
navigationIconTint
. Этот атрибут не поддерживается
Toolbar
из AppCompat. Тем не менее, при переходе с
нового скрина на старый, navigationIcon в
Toolbar
каким-то образом красился с помощью
navigationIconTint
. Помог лишь полный переезд на
MaterialToolbar
.
Стили и размеры
Вот есть такая штука в Material Design Guidelines, как Dense
text fields. По сути это TextInputLayout
с высотой в
40dp. Есть даже стили под него
(Widget.MaterialComponents.TextInputLayout.*.Dense
).
Ограничений (в Guidelines) на предмет наличия иконок (в начале или
в конце) нет; более того, даже есть пример с иконкой.
Берем TextInputLayout
, выставляем ему Dense стиль,
добавляем start icon и... это ничем не отличается от обычного, не
Dense стиля. Копаем в сторону того, а как же тогда получить высоту
в 40dp. Надеемся на лучшее, в нужных стилях выкручиваем в 0
вертикальные padding
. Не помогает.
Причина оказалась в
design_text_input_start_icon.xml
, где для start icon
установлены минимальные размеры в 48dp. Тем не менее, если
выставить для TextInputLayout
40dp в
android:layout_height
, все выглядит как нужно.
Не будем забывать про стили. Dense - это про стиль.
Следовательно, android:layout_height
должен в этом
случае лежать внутри стиля. А это плохо тем, что в каждом месте
использования TextInputLayout
с таким стилем придется
выпилить android:layout_height
из разметки (ответ на вопрос, почему
так):
<item name = "searchTextInputStyleV2">@style/V2.Widget.MyFancyApp.TextInputLayout.Search</item><style name = "V2.Widget.MyFancyApp.TextInputLayout.Search" parent = "Widget.MaterialComponents.TextInputLayout.FilledBox.Dense"><item name = "android:layout_height">40dp</item>...</style><!-- Не сработает --><com.google.android.material.textfield.TextInputLayoutstyle = "?searchTextInputStyleV2"android:layout_width = "match_parent"android:layout_height = "wrap_content"/> <!-- Сработает --><com.google.android.material.textfield.TextInputLayoutstyle = "?searchTextInputStyleV2"android:layout_width = "match_parent"/>
Возможно это просто баг и в дальнейшем такого workaround
получится избежать.
Как по мне, получилось неплохое решение. Оно имеет свои
недостатки, но преимущества в виде абстракции от дизайн системы в
ui модулях и возможности частичной стилизации куда весомей.
Используйте средства стилизации по максимуму. Это не сложно.
Спасибо за прочтение.