Вот есть у нас приложение. Серьезное, большое, взрослое. Обходимся практически без стилей, но без беспорядка; используем себе виджеты из 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 модулях и возможности частичной стилизации куда весомей.
Используйте средства стилизации по максимуму. Это не сложно. Спасибо за прочтение.
ссылка на оригинал статьи https://habr.com/ru/post/552486/
Добавить комментарий