Вот есть у нас приложение. Серьезное, большое, взрослое. Обходимся практически без стилей, но без беспорядка; используем себе виджеты из 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 и темным контентом.

Слева - translucent; справа - light
Слева - 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.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 модулях и возможности частичной стилизации куда весомей.

Используйте средства стилизации по максимуму. Это не сложно. Спасибо за прочтение.