Вот есть у нас приложение. Серьезное, большое, взрослое. Обходимся практически без стилей, но без беспорядка; используем себе виджеты из 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.AppCompatTextView
android: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.MaterialToolbar style = "?toolbarStyleV2" /> <!-- Bad --> <com.google.android.material.appbar.MaterialToolbar android: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.MaterialTextView
style = "?v2TextStyleGiftItemPrice"
...
/>
<com.google.android.material.textview.MaterialTextView
style = "?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 и темным контентом.
Собственно задача заключается в том, чтобы уметь поддерживать 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.systemUiVisibility
flags = flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
window.decorView.systemUiVisibility = flags
}
}
fun clearLightStatusBar() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
var flags = window.decorView.systemUiVisibility
flags = flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
window.decorView.systemUiVisibility = flags
}
}
Без
FLAG_TRANSLUCENT_STATUS
кастомнаяStatusBarView
не залазит под status bar. Исправляется это примерно так:
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.ShapeableImageView
style = "?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.AppBarLayout
style = "?appBarStyleV2"
...
>
<my.magic.path.StatusBarView
style = "?statusBarStyleV2"
...
/>
<com.google.android.material.appbar.MaterialToolbar
style = "?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.TextInputLayout
style = "?searchTextInputStyleV2"
android:layout_width = "match_parent"
android:layout_height = "wrap_content"
/>
<!-- Сработает -->
<com.google.android.material.textfield.TextInputLayout
style = "?searchTextInputStyleV2"
android:layout_width = "match_parent"
/>
Возможно это просто баг и в дальнейшем такого workaround получится избежать.
Как по мне, получилось неплохое решение. Оно имеет свои недостатки, но преимущества в виде абстракции от дизайн системы в ui модулях и возможности частичной стилизации куда весомей.
Используйте средства стилизации по максимуму. Это не сложно. Спасибо за прочтение.