Стилизация Android-приложений и дизайн-система: как это сделать и подружить одно с другим

e6032e06e74d92ec1256fcb7eb8e201e.png

Привет читателям!   

В какой-то момент любое крупное приложение разрастается так, что сложно везде поддерживать однотипный дизайн и динамично реагировать на любые изменения и тенденции в дизайне и UX-требованиях.  

Поэтому решили внедрить в наше приложение дизайн-систему и добавить поддержку нескольких тем оформления. 

Изучив различные способы, выработали свой подход к решению такой задачи. Хотелось сделать так, чтобы дизайн-систему и поддержку стилей можно было повторно использовать в других своих проектах. В соответствии с этой идеей разрабатывались компоненты и темы.


Дизайн-система и её компоненты предназначены для унификации дизайна и стилевого   единства во всем приложении.

Компонентами дизайн-системы в нашем случае будем называть custom view с возможностью адаптации к нескольким стилям приложения. Компоненты могут применяться в любом месте приложения (кнопки, элементы списка, заголовки и т.д.).


Заказчиками компонентов дизайн-системы являются дизайнеры. С ними на первом этапе согласовываем надобность элемента (оценка переиспользуемости) и его функциональность. 

После согласования должно быть понятно, какие опции нужно вынести в атрибуты custom view (цвет текста, текст, иконочку, цвет тинта иконочки и т.д.), а какие скрыть от изменений извне (это позволяет уберечь элемент от неправильного использования разработчиками).

Далее дизайнеры отрисовывают компонент в своих средах и отдают на разработку. 

При реализации компонента нужно добавить поддержку тем (светлая или темная тема и т.д.) О том, как компонент поддерживает несколько тем, я расскажу ниже.

Лучшие методики

  • Создать модуль с компонентами дизайн-системы. Из положительных моментов: отдельный модуль может быть использован в других приложениях, а модульность позволяет быстрее ориентироваться.
  • Создать тестовое приложение с компонентами дизайн-системы. Это ускоряет разработку и отладку.


Мне известно два способа поддержки стилей в Android:

  • Программный (программная перекраска).
  • Стандартные механизмы стилей в Android.


   Мы перекрашиваем всю иерархию view в runtime. Рекурсивно проходимся по ней и по определенным правилам перехода из одной темы в другую перекрашиваем компоненты. Те из них, которые не должны перекрашиваться, маркируются с помощью android:tag или android:contentDescription. Эти компоненты не учитываются при разборе иерархии экрана.

    Перекрашивать можно как перед отображением экрана (например, в onStart() у Activity), так и при работе с ним.      

Недостатки


  • Требует дополнительных ресурсов, снижает производительность. Стилизация применяется после инициализации всех компонентов.
  • Нужно быть внимательным к правилам перехода из одной темы в другую. Требуется учесть огромное множество правил перекраски, можно что-то забыть. Получается длинная простыня из switch — case (Java) или when (Kotlin). И в довесок требуется учесть элементы, которые не нужно красить при помощи вышеупомянутых тегов.
  • Нельзя частично перекрасить в соответствии с темами. В любом правиле есть исключения, и не всегда всё в приложении делается по дизайн-системе. Непонятно, как действовать если требуется частичная перекраска некоторых элементов.


Применение стиля сводится к описанию изменений в конкретных элементах:

if (view is TextView) {
    (view as TextView).setTextColor(
        if (darkMode) R.color.blue else R.color.black
    )
} else if (view is TabLayout) {
  (view as TabLayout).doAnything()
}

Достоинства


Не требует пересоздания Activity (это важно! Нет морганий при смене темы).
Я внедрил этот подход в одном известном всем продукте (см. скриншоты). Работает довольно быстро при простой однотипной вёрстке (в данном случае она была простая).
Стиль — локальная стилизация экрана или view, затрагивающая только отдельный экран или view. Часто такую стилизацию называют «ThemeOverlay», или «легковесная» тема, которая позволяет переопределить атрибуты основной темы).

  Тема — глобальная стилизация экранов приложения, затрагивающая подмену стилей, цветов и т.д. у всего, что мы видим на экранах приложения.

  Темой можно считать множество стилизаций, которые можно переключать.

Примеры


В теме могут содержаться как стили конкретных view элементов, так и конкретные цвета.



Здесь объявлен стиль для конкретной view:







Стили поддерживают явное и неявное наследование:

  • Явное: Header1 унаследован от BaseTextWidget.
  • Неявное: Header1.Light унаследован от Header1.


Если к текстовому элементу мы применим стиль Header1, то подтянется только Header1. А атрибуты Header1.Light или Header1.Dark не применятся.

Если к текстовому элементу мы применим стиль Header1.Light/Dark, то подтянутся стили Header1.Light/Dark и Header1 (достоинство неявного наследования)

Множественного наследования темы не поддерживают. Вероятно, из-за конфликтов одноименных атрибутов.

Стили каждого компонента дизайн-системы мы решили размещать в файлах attrs_component_name.xml (см. attrs_header1, attrs_button и т.д.)

Стандартный конструктор view


Стандартный конструктор view предоставляет обширные средства для настройки элемента. Внешний вид элементов можно изменить через .xml-атрибуты или через определение стиля по умолчанию в стандартном конcтрукторе view.

Рассмотрим стандартный конструктор view на примере H1Component (задаёт крупный текст в шапке экранов):

class H1Component @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,               
    defStyleAttr: Int = R.attr.cm_header1_style
) : AppCompatTextView(context, attrs, defStyleAttr)


Здесь attrs — атрибуты из определения .xml (в том числе кастомные атрибуты view). Они парсятся и применяются стандартным образом (см. ниже на примере FabComponent).

class FabButtonComponent @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppCompatImageButton(context, attrs, defStyleAttr) {

    init {
        val a = context.obtainStyledAttributes(
            attrs, 
            R.styleable.FabButtonComponent
        )
        val icon = a.getDrawable(R.styleable.FabButtonComponent_cm_icon)
        a.recycle()
        // apply attrs here 
    }
}

defStyleAttr — стиль view по умолчанию.

context — контекст view, при помощи которого она создана.

ВАЖНО: чтобы view успешно переключала тему, необходимо чтобы она была создана при помощи контекста, унаследованного от android.view.ContextThemeWrapper (то есть контекст activity подходит, а applicationContext — не подходит (применится тема, которая подтянется из стиля, указанного в Manifest экрана)

ВАЖНО: при такой реализации главный приоритет у атрибутов, объявленных в .xml. У стилей, описанных в теме, приоритет ниже.

Интеграция стиля в компоненты дизайн системы и его связь с темой


Для поддержки темы компонентами дизайн-системы мы определяем в компонентах defStyleAttr и переключаем его в соответствии с темой, в которой он определен.

Реализация темы в приложении 


Создаем две темы:




Компоненты дизайн системы системы будут тянуть этот стиль в таком ключе:

class MyBestText @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,               
    defStyleAttr: Int = R.attr.best_textview_style
) : TextView(context, attrs, defStyleAttr)


Тут определены стили каждой темы для этого элемента:







Применяем тему через стандартный механизм Android.

При создании Activity указываем нужную тему. Тогда MyBestText подтянет нужный стиль и окрасит свой текст в белый или черный в зависимости от темы (см. выше описание темы MyBestText).

private void setAppTheme(@NonNull Boolean isDarkModeEnabled) {
    if (isDarkModeEnabled) {
        setTheme(R.style.DesignSystemDark);
    } else {
        setTheme(R.style.DesignSystemLight);
    }


Цвета из темы мы будем разрешать прямо из .xml и подтягивать из темы.



ВАЖНО: начиная с Android 5.0 допускается отовсюду динамически разрешать android:background=»?attr/primary_background» (селекторы, shape, vector drawables и т.д.) В Android 4.4 есть ограничение на селекторы, при попытке динамически разрешить итоговый цвет из селекторов система упадёт.  

  При всех достоинствах такой реализации компоненты дизайн-системы не могут в preview Android Studio полноценно работать со стилизованными темами (к элементам не будут применяться стили). 

Пока тема официально не использована нашими экранами, а только подключается программно (то есть стили наших activity не подгружают явным образом тему из Manifest), мы не можем комфортно работать с элементами, поддерживающими темы в preview (их даже не будет в списке).

Тестирование компонентов дизайн-системы


Для тестирования и анализа степени покрытия приложения дизайнеры предложили разработать отладочную панель с настройками стилей компонентов, цветов и т.д.

5adfd8c9f52b6363cb84322983275be1.png


Темы в Android являются неизменяемыми, но их всегда можно перезаписать полностью или частично через Activity.setTheme (@StyleRes final int resid). Так можно в нужный момент получить любую комбинацию стилей и собрать свою собственную тему. Но все стили должны быть объявлены в .xml заранее.

Программно изменять атрибут темы без отсылок к объявленным стилям, к сожалению, нельзя. По крайней мере, я не нашёл способа.

Если знаете, как подсунуть свой цвет в атрибут темы (не объявленный в ресурсах как style), то напишите мне. Тогда мы сможем прямо из коробки манипулировать цветами с бэка на уровне стилизации всего приложения!

Делаем рабочее preview компонентов дизайн-системы в Android Studio


Темы экранов приложения должны наследоваться от темы дизайн-системы.

Preview компонентов в .xml


При некорректно установленной теме экрана компоненты дизайн-системы тоже не будут отображаться корректно (не применятся стили и цвета):

8d439c1d61d06d9c8665385bced8b5d5.png


При установке темы, унаследованной от темы дизайн-системы, мы получим вот что:

fe15762d4d3b79a140e76931ad4783ac.png


Видно, как разрешились все атрибуты темы и правильно подтянулись стили компонента.

Проверка поведения компонентов в другой теме в Preview без пересборки приложения


Чтобы проверить отображение в другой теме достаточно переключить тему в Preview light/dark.

Если конкретные реализации темы завязаны на ресурсы values/values-night, то можно переключать из preview в dark mode. И всё будет работать из коробки без выставления setTheme в Activity.

1b2ef77772318bff01492b4019a86042.png


Переключение тем в приложении


Переключение тем в приложении может быть завязано на системное переключение dark-mode. В таком случае темы должны быть определены в директориях values и values-night.

Если планируется три и более тем, то потребуется вручную разрешать, какую из тем поставить через activity.setTheme().

Результаты стилизации смотрим ниже:

А как же третья тема под AB-тестом?


Как ранее говорилось, в таком случае придется вручную выставлять setTheme для применения нужной темы.

Итоги


  1. У нас есть надежный механизм динамической смены тем и подстройки стилей (как в отладочной панели).
  2. Мы можем создавать новые компоненты дизайн-системы, поддерживающие стилизацию, и внедрять их повсеместно.


Теперь мы можем как угодно стилизовать всё наше приложение и настроить дизайн-систему. Всё упирается лишь в нашу фантазию.

Ссылка на тестовый проект в Git с пошаговым руководством по интеграции тем в свой проект: https://github.com/Dragues/SampleThemeApplication/

© Habrahabr.ru