company_banner

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

  • Tutorial


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

В какой-то момент любое крупное приложение разрастается так, что сложно везде поддерживать однотипный дизайн и динамично реагировать на любые изменения и тенденции в  дизайне и 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.setTextColor(
        if (darkMode) R.color.blue else R.color.black
    )
} else if (view is TabLayout) {
  view.doAnything()
}

Достоинства


Не требует пересоздания Activity (это важно! Нет морганий при смене темы).
Я внедрил этот подход в одном известном всем продукте (см. скриншоты). Работает довольно быстро при простой однотипной вёрстке(в данном случае она была простая).



Стандартный механизм стилей в Android


Стиль — локальная стилизация экрана или view, затрагивающая только отдельный экран или view. Часто такую стилизацию называют «ThemeOverlay», или «легковесная» тема, которая позволяет переопределить атрибуты основной темы).

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

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

Примеры


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

<style name="DesignSystemTheme" parent="Theme.AppCompat.Light">
    <!-- colors -->
    <item name="cm_primary_background">#123456</item>
    <item name="cm_secondary_background">#654321</item>
    <!-- View's (Component's) styles -->
    <item name="cm_header1_style">@style/Header1.Light</item>
    <item name="cm_header2_style">@style/Header2.Light</item>
</style>

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

<style name="Header1" parent="BaseTextWidget">
    <item name="android:textSize">28sp</item>
    <item name="lineHeight">34sp</item>
    <item name="fontFamily">@font/roboto_bold</item>
</style>

<style name="Header1.Light">
    <item name="android:textColor">#123456</item>
</style>

<style name="Header1.Dark">
    <item name="android:textColor">#fedcba</item>
</style>

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

  • Явное: 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 и переключаем его в соответствии с темой, в которой он определен.

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


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

<style name="ThemeA">
         <item name="primary_background">@color/red</item>
         <item name="best_textview_style">@style/MyBestText.A</item>
</style>
<style name="ThemeB">
          <item name="primary_background">@color/brown</item>
          <item name="best_textview_style">@style/MyBestText.B</item>
</style>

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

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

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

<style name="MyBestText" parent="android:Widget.TextView">
    <item name="android:textSize">28sp</item>
    <item name="lineHeight">34sp</item>
    <item name="fontFamily">@font/roboto_bold</item>
</style>

<style name="MyBestText.A">
    <item name="android:textColor">@color/white</item>
</style>

<style name="MyBestText.B">
    <item name="android:textColor">@color/black</item>
</style>

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

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

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

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

<SomethingView
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="?attr/primary_background">

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

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

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

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


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


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

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

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

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


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

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


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


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


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

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


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

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


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


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

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

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







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


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

Итоги


  1. У нас есть надежный механизм динамической смены тем и подстройки стилей (как в отладочной панели).

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


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

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

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поддерживаете dark mode или дополнительную стилизацию в приложении?

  • 52,6%Да20
  • 18,4%Нет7
  • 39,5%Планируем делать15
Ситимобил
Компания

Комментарии 10

    0

    Не очень понял в чём преимущество создания своих кастомных компонент над уже стандартными. В этом примере вы меняется дефолтный стиль.
    Но разве мы не можем в темах и сейчас поменять стиль стандартных компонент без необходимости наследоваться? Всякие buttonStyle, materialButtonStyle, toolbarStyle и прочие аттрибуты..

      0
      Доброе утро! Ответил вам в отдельном треде ниже((
      0
      Посыл дизайн системы в том, чтобы определить там самые базовые элементы и минимизировать количество кода по применению этих компонентов в своих экранах. Нет ничего предрасудительного в том чтобы изменить дефолтный стиль и не заставлять всех писать в .xml style=«somestyle». При этом в случаях расхождения с дизайн системой никто не запрещает переопределить в верстке свой стиль элементу. Преимущество наследования view проявляется когда есть необходимость определять свое поведение, отличное от базового (а в подавляющем большинстве элементов это требуется). Согласен с вами, что простые текстовые view (как в примере) остро не нуждаются в наследовании (можно подпихнуть style из темы и переключать), но как мне кажется удобно при чтении верстки сразу видеть что за компонент перед вами (H1/H2/H3 Component, ListItemComponent, ButtonComponent and etc.,) без чтения нюансов из style тэга. При создании экранов дизайнеры маркируют элементы (у нас в фигме) как компонент дизайн системы и при верстке можно сразу вставлять компонент дизайн системы и донастраивать его при необходимости.
        0

        А что входит в компонент по-умолчанию? Может привести несколько примеров реальных из проекта?
        Например, ваш H1/H2/H3 Component. В нём определяются только размеры текста?
        А например цвет куда? Или цвет это уже внешняя стилизация компонента?

          0
          По умолчанию в компоненте его базовый конструктор с отсылкой на стиль. Как пример текстовый компонент заголовков H1 (названия вдохновлены web-ом):
          class H1Component @JvmOverloads constructor(
              context: Context,
              attrs: AttributeSet? = null,
              defStyleAttr: Int = R.attr.cm_header1_style
          ) : AppCompatTextView(context, attrs, defStyleAttr)


          А в стиле компонента («ThemeOverlay») определение его атрибутов:
          <style name="Header1" parent="BaseTextWidget">
                  <item name="android:textSize">28sp</item>
                  <item name="lineHeight">34sp</item>
                  <item name="fontFamily">@font/roboto_bold</item>
              </style>
          
              <style name="Header1.Light">
                  <item name="android:textColor">@color/component_black</item>
              </style>
          
              <style name="Header1.Dark">
                  <item name="android:textColor">@color/component_white_night</item>
              </style>


          При такой реализации разработчикам не нужно думать о том какие стили прописать и какие шрифты, все за них уже определено.
          Остается лишь добавить компонент в верстку и не прописывать ничего лишнего:
           <com.citymobil.designsystem.text.H1Component
                   android:id="@+id/title"
                   android:layout_width="match_parent"
                   android:layout_height="wrap_content"
                   android:text="I will be back"/>

        0
        Я не очень понял зачем упарываться рантайм перекраской. Да, «моргания» действительно нет, но смена темы это сравнительно редкое явление, которым можно пренебречь, а вот обмазывать каждую вью для перекраса рантаймом это очень дорого в разработке.
          0
          Согласен что упарываться не стоит, как раз таки далее я рассмотрел в статье иной подход который нам подходит больше (Ситимобилу).
            0
            Но на одном из проектов в читалке (LitRes) требовалось сделать смену темы без моргания и были строго определены 3 темы, потому был применен первый подход. Обмазать каждую view не было очень дорого поскольку был однотипный UI (можно было большую часть элементов красить однотипно, была написана схема перекраски: на входе бралась parent view экрана, на выходе был полностью перекрашенный экран).
            0
            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()
            }

            После is не нужен явный каст же
              0
              каст убрал, спасибо за замечание

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

            Самое читаемое