14.
Style & Theme
Written by Subhrajyoti Sen
A polished user interface makes a good first impression. It can even be one of the reasons users like using your app. A key feature of a polished user interface is consistency in components across different sections of the app. These components include color schemes, shapes, typography and more. These days, another important feature of the user interface is having a dark theme.
Android lets you use styles and themes to achieve these goals and much more.
In this chapter, you’ll learn about:
- Styles, themes and their differences.
- The order of different modes of styling.
- Using theme overlays to tweak specific attributes.
- Adding styling support to custom views.
- Adding dark theme support to your app.
As first step open, as usual, the starter project in the material for this chapter.
Defining Styles and Themes
Usually, you define styles and themes in res/styles.xml, which contains a collection of attributes and their values. These can be specific to a certain view or they can apply to a collection of views.
Structure of a Style
A typical style looks like this:
<style name="LargeText">
<item name="android:textSize">@dimen/large_text</item>
</style>
This style has the following properties:
- name: The name that points to this style.
-
item: Each item in a style is a pair consisting of a view attribute and its value. In this case, the style sets
textSizeto@dimen/large_textwhich in turn resolves to 18sp. You can have one or more<item>s inside a<style>.
Now, what if you want a style to define a large red text? You could write something like this:
<style name="LargeRedText">
<item name="android:textSize">@dimen/large_text</item>
<item name="android:textColor">@android:color/red</item>
</style>
Even though the style above is technically correct, you’ll notice that you’re repeating textSize to create a variant of the style. While this might be feasible for a few styles, it can quickly get out of hand. A better approach is to inherit from the style and create a variant.
You can inherit from a style in two ways:
-
Using the parent attribute:
<style name="LargeRedText" parent="LargeText"> <item name="android:textColor">@android:color/red</item> </style> -
Using a prefixed name with a dot separator:
<style name="LargeText.Red"> <item name="android:textColor">@android:color/red</item> </style>Note that you can’t use this method to extend from styles defined by the Android platform.
Structure of a Theme
The structure of a theme is identical to that of a style:
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
In this code, you define a theme named AppTheme, which inherits from Theme.MaterialComponents.DayNight.NoActionBar.
Although styles and themes have identical structures, they function differently. Their key differences are:
- The item name in a style has to be a view attribute. With themes, the name is a reference to a pre-defined identifier. If you think of an
itemas a pair where the item name is the key, you’d define a style asMap<View Attribute, Value>whereas a theme would beMap<Theme Attribute, Value>. - You can only apply a style to a specific view or a view group, whereas a theme can be applied to any view hierarchy: a
ViewGroup,Activityor even the entire app. When you apply a theme to any view hierarchy, you automatically apply it to all its child views. - You apply a style using the
styleattribute, whereas you apply a theme using theandroid:themeattribute.
Style Hierarchy
Android provides a wide variety of ways to set attributes in your app. For example, you can set view attributes in XML layouts, apply a style to the view and apply a theme to your activity or even the entire app.
Given the wide variety of approaches available, it’s helpful to know the order of precedence Android follows when applying styles and themes, especially if you repeated the same attributes in multiple places.
The order of precedence, in descending order, is:
- Styles applied using spans to a
TextViewor any view that extends fromTextView. - Attributes applied programmatically.
- Attributes applied in XML.
- Styles applied to a view.
- The default style of the view.
- A theme applied to a view hierarchy, activity or the entire app.
- A
textAppearanceapplied to aTextView.
For example, if you set the textColor of a TextView to blue in the XML layout and also apply a style that sets the textColor to green, the text will render in blue when you inflate it. That’s because attributes applied directly to a view have a higher precedence than styles.
Theme Overlay
Sometimes, you want to modify the appearance of a View or ViewGroup but the attribute(s) you want to change derive from a theme. Take the example of MaterialButton.
Open fragment_details.xml and add the following code in the inner <ConstraintLayout>, right below ProgressButton:
<com.google.android.material.button.MaterialButton
android:id="@+id/red_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#DA2222"
android:layout_marginTop="@dimen/default_margin"
android:text="Red Button"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="@dimen/default_margin"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/adopt_button" />
This code adds MaterialButton with a red background. Build and run, then open the details page for any pet. You’ll notice that the button has a blue background instead of a red one, as shown below:
That’s because MaterialButton’s background derives from the theme attribute, colorPrimary. To change the button’s background, you need to modify the theme attribute only when it applies to that specific MaterialButton. That’s where theme overlays are useful.
As the name suggests, theme overlays overlay the existing theme rather than replacing it. By applying a theme to the activity and a theme overlay to a specific view in the activity, the attributes defined in the overlay override only the corresponding attributes in the activity’s theme and apply them to the view.
To see how this works, open styles.xml and add the following style:
<style name="ThemeOverlay.PetSave.RedButton" parent="">
<item name="colorPrimary">#DA2222</item>
</style>
This code creates a style named ThemeOverlay.PetSave.RedButton. It’s a good convention to name theme overlays with the ThemeOverlay prefix, followed by the name of your app. This differentiates them from the theme overlays provided by libraries.
The theme overlay above doesn’t extend from any other style. This prevents it from accidentally overriding values from the theme of the parent view group, activity or app. The overlay also sets colorPrimary to the required red color’s HEX code.
Finally, you need to apply this overlay to the view. Open fragment_details.xml and replace background with theme, as shown below:
<com.google.android.material.button.MaterialButton
android:id="@+id/red_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:theme="@style/ThemeOverlay.PetSave.RedButton"
android:layout_marginTop="@dimen/default_margin"
android:text="Red Button"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginBottom="@dimen/default_margin"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/adopt_button" />
Build and run. You’ll now see that the button has a red background.
TextAppearance
textAppearance lets you define text-specific styling for a TextView and decouple it from the rest of the styling. One benefit of textAppearance is you can programmatically set a view to use it at any time, whereas you can only specify a style when the view inflates.
Open fragment_details.xml. You’ll notice that you’ve repeated the same set of attributes for special_needs_label, sprayed_neutered_label, declawed_label and so on. Since these are text-specific attributes, you can extract them to textAppearance.
Open styles.xml and add the following style:
<style name="PetLabelTextAppearance" parent="TextAppearance.MaterialComponents.Headline3" >
<item name="android:textSize">@dimen/large_text</item>
<item name="android:textStyle">bold</item>
</style>
In the previous code, you set textSize and textStyle attributes to the values you specified in the layout XML. The style extends from TextAppearance.MaterialComponents.Headline3 since it’s the default text appearance you apply to a TextView.
Next, open fragment_details and remove the android:textSize and android:textStyle attributes. Then, add the textAppearance attribute to the TextView with the ID special_needs_label as shown below:
<TextView
android:id="@+id/special_needs_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/default_margin"
android:layout_marginTop="@dimen/default_margin"
android:text="@string/special_needs"
android:textAppearance="@style/PetLabelTextAppearance"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/divider" />
Check the layout preview inside Android Studio. You’ll see that the text looks exactly as it did before, which confirms you set textAppearance correctly.
Finally, replace the attributes of the other label headers with textAppearance. Now, whenever you want to change the appearance of the header labels, you only need to modify textAppearance in styles.xml and the change will reflect across all the required TextViews. This is the main benefit of using styling over hard coding attributes in views.
Note: Since textAppearance is lower in the style hierarchy, if you specify an attribute in textAppearance and also directly in the view, the direct attribute values will display.
Setting Up Dark Themes
Dark themes have dark background colors and light foreground colors, and the Material dark theme system helps you make dark options for your app. Some of the benefits of providing one are:
- Reduced eye-strain
- Lower battery consumption on OLED screens
- Better appearance in low-light environments
Depending on which Android version a device uses, the user can switch to a dark theme in different ways:
- Android Q and above: Navigate to Settings ▸ Display ▸ Dark Theme or implement the Dark Theme tile in the notification tray.
- Android P: Use Settings ▸ System ▸ Developer options ▸ Night mode.
- Any other older version: Use the option provided in the app to switch themes. Note that not all apps provide this option.
When adding dark theme support, the first step is to use the DayNight variant of any AppCompat or MaterialComponents theme.
Open styles.xml and verify that the app’s theme is a DayNight variant. Here is the project’s theme:
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
</style>
In this code, you see that the parent theme is a DayNight variant of MaterialComponents, so the app can support dark themes.
Understanding Material Color Attributes
Before creating a dark theme, you need to understand the color system in Material Design components.
Color attributes consist mainly of primary, secondary, surface, error and background colors. There are also corresponding on colors that apply to elements drawn on top of other elements. Some of the most important ones are:
- colorPrimary: The primary color that represents your brand. This is one of the dominant colors in your app. The toolbar often uses this colors.
- colorPrimaryVariant: A lighter or darker variant of the primary color.
- colorOnPrimary: The color of elements that display on top of your primary color. You’ll see an example of this in the next section.
- colorOnSecondary: The color of elements displayed on top of your secondary colors.
- colorError: The color displayed for errors. For example, when the user makes an input error in an input field.
- colorOnError: The color of elements that display on top of your error color.
- colorSurface: The color used for surfaces like bottom sheets, cards, bottom navigation and more.
- colorOnSurface: Use this color for elements that display on top of your surface color, like the text on a card.
Adding a Theme Toggle
To let the user switch between themes, you’ll add a toggle, which will have three options:
- Light
- Dark
- System default
It’s good practice to follow the system default theme to mail consistency with the user’s other apps.
The corresponding constants defined in AppCompatDelegate are:
-
Light:
MODE_NIGHT_NO -
Dark:
MODE_NIGHT_YES -
System default:
MODE_NIGHT_FOLLOW_SYSTEM
In this section, you’ll add an overflow menu to the toolbar that contains options to switch between different modes.
Start by creating a file named theme_options.xml inside res/menu and adding the following code:
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/light_theme"
android:title="Light theme"
app:showAsAction="never" />
<item
android:id="@+id/dark_theme"
android:title="Dark Theme"
app:showAsAction="never" />
<item
android:id="@+id/follow_system"
android:title="Follow System"
app:showAsAction="never" />
</menu>
This code adds three menu items corresponding to the three modes.
Next, you need to display those options as an overflow menu in the toolbar. To do this, add the following method to MainActivity.kt in the common package:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// ...
override fun onCreateOptionsMenu(menu: Menu): Boolean {
val inflater = menuInflater
inflater.inflate(R.menu.theme_options, menu) // HERE
return true
}
}
This code inflates the items from theme_options.xml and displays them as menu options. When the user selects any one of the menu options, the app switches to the corresponding theme.
Now, add the following method to the same file:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// ...
override fun onOptionsItemSelected(item: MenuItem): Boolean {
val themeMode = when (item.itemId) {
R.id.light_theme -> {
AppCompatDelegate.MODE_NIGHT_NO
}
R.id.dark_theme -> {
AppCompatDelegate.MODE_NIGHT_YES
}
else -> {
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
}
AppCompatDelegate.setDefaultNightMode(themeMode)
return true
}
}
The code above chooses the mode from AppCompatDelegate based on the menu item the user selects. AppCompatDelegate.setDefaultNightMode(themeMode) sets the night mode using user’s selected mode.
Build and run, then click the overflow menu and select the theme of your choice. You’ll notice that the app’s theme changes whenever you select a new theme.
In the next section, you’ll use this toggle to find dark theme issues in the current app.
Resolving Dark Theme Inconsistencies
Use the toggle to switch to the dark theme and explore the app. Observe the same screens in both light and dark themes. Some of the inconsistencies you’ll notice are:
- The toolbar color remains the same in both themes.
- The background of the search container is white in the dark theme.
- The floating action button’s call icon in the details screen is white in both themes.
You’ll fix them one at a time.
Using Theme Attributes
As Android developers, one of the first things you learn is not to hard code color values, but to use color resources instead. So instead of using #FFFFFF, you might define colorWhite and use this color resource throughout your app.
This approach has a limitation when it comes to theming. For example, when you want to apply a dark theme, it doesn’t make sense to change the value of colorWhite to a dark color. You’ll have to create a new alias for the color and switch between the white and a dark color depending on the theme.
You’ll have many such instances across your codebase and this approach will get increasingly complex, both in terms of naming colors and in remembering the names.
That’s where theme attributes come in. Instead of thinking of colors based on the widget they apply to, think of them in terms of theme attributes.
To make things clearer, take the example of the floating action button in fragment_details.xml:
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/call"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/default_margin"
android:contentDescription="@string/contact"
android:src="@drawable/ic_call_24dp"
android:visibility="gone"
app:backgroundTint="@color/colorPrimary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:tint="@android:color/white"
tools:visibility="visible" />
In the code above, you set tint to @android:color/white. The icon will always render white, regardless of which theme you apply.
Now, go to the details screen and change the theme from light to dark. You’ll notice the dialer icon color always remains white.
Instead, think of the icon color in terms of the theme attributes. The background color of the floating action button is the primary color of the theme. What would you call a color that you need to display on top of the primary color? In the previous section, you learned about colorOnPrimary, which is exactly what you’ll use here.
Set the tint attribute to colorOnPrimary:
<com.google.android.material.floatingactionbutton.FloatingActionButton
//...
app:tint="?attr/colorOnPrimary"
tools:visibility="visible" />
In the code above, ?attr/colorOnPrimary is a reference to the theme attribute colorOnPrimary. ?attr/ refers to theme attributes where @color refers to colors in your res directory.
Build and run, then go to the details screen and change themes. Now, the dialer icon is white in light theme and black in dark theme. And with that, you’ve successfully used theme attribute to support theming in a widget.
Fixing Other Hard-coded Colors
Similarly, open fragment_search.xml and look at AppBarLayout. You’ll notice that background is a static color:
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/collapsible_search_params_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
This is why the background is white in both light and dark themes. You need to replace this with a theme attribute. Replace background’s value with colorPrimarySurface:
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/collapsible_search_params_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimarySurface"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
colorPrimarySurface switches between colorPrimary in light themes and colorSurface in dark themes.
Build and run. Switch to dark theme and go to the search page. You’ll see that the search container is no longer white in the dark theme.
Using Night Colors
You might have noticed that you haven’t specified any separate color values for the dark theme, yet switching to dark theme displays different colors in many places. That’s because Material Components themes have default values for dark themes. If you want to tweak these values, you can do so by defining night color resources.
Create a new resource directory named values-night inside the res directory like in Figures 14.7 and 14.8:
This directory will contain the resources you want to override for dark theme. When you apply a dark theme, Android will check values-night before it uses a resource. If it finds a defined resource, it will use that value; otherwise, it will pick the resource value from the values directory.
To define colors for your dark theme, create a file named colors.xml inside values-night:
You now have two colors.xml files that differ for the night qualifier:
Now, add the following colors to colors.xml (night):
<color name="colorPrimary">#BA86FC</color>
<color name="colorPrimaryDark">#000000</color>
<color name="colorBackground">#000000</color>
The code above adds two dark colors to the app’s primary colors. It also defines a background color, but you haven’t defined colorBackground for the light theme yet. To resolve this, add the following to colors.xml:
<color name="colorBackground">#FFFFFF</color>
Now that you’ve defined colorBackground for both themes, you need to point the default background color of the theme to this color resource. Open styles.xml and add the following item to AppTheme:
<item name="android:colorBackground">@color/colorBackground</item>
Build and run. Visit the different screens and switch between light and dark themes. You’ll notice that the background is black in dark theme and white in light theme. You’ll also notice that the primary color of the app changes to a dark purple in the night theme.
Great! You’ve successfully added dark theme support to your app and fixed the inconsistencies using the styling and theming concepts you’ve learned in this chapter.
Styling Custom Views
Most of the views Android provides have good styling support out of the box. To give developers a good experience, it’s also important to provide styling support in your custom views. In this section, you’ll make ProgressButton styleable.
Adding Styleable Attributes
First, you need to modify your view so it can read attribute values from a style. To do this, you need to remove any hard-coded colors from the view.
Open the ProgressButton.kt file in the common.presentation package and remove the color assignment from textPaint, backgroundPaint and progressPaint so they match the code below:
private val textPaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
textSize = context.dpToPx(16f)
}
private val backgroundPaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
}
private val progressPaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = context.dpToPx(2f)
}
Next, define attributes for these colors so you can use them in a style. Open attrs.xml and add the following code to ProgressButton:
<attr name="progressButton_backgroundColor" format="color" />
<attr name="progressButton_textColor" format="color" />
<attr name="progressButton_progressColor" format="color" />
In the code above, you define three attributes: background color, text color and progress color. They all have the format color since you’ll assign color resources to them.
To be consistent with MaterialTextView, the default background color of ProgressButton will also be colorPrimary, as defined in the theme. You can overwrite this by setting the backgroundColor attribute in the view XML. Since colorPrimary is a theme attribute and not a color resource, you need to use the theme to extract its value.
Open ProgressButton and add the following code to init, right before the line containing typedArray.recycle():
val typedValue = TypedValue()
context.theme.resolveAttribute(android.R.attr.colorPrimary, typedValue, true)
val defaultBackgroundColor = typedValue.data
In the code above, you resolve the value of the colorPrimary attribute using the theme from the context. Using the correct context is vital. If you try using an Activity context here, it will lead to inconsistencies since the Activity and the view can have different themes.
Now that you have the default background color, set the default text and progress color to white by adding the following to init:
val defaultTextColor = Color.WHITE
val defaultProgressColor = Color.WHITE
With the default values set, you now need to read the attribute values from the XML, as well as styles. Add the following code to init:
val backgroundColor = typedArray.getColor(R.styleable.ProgressButton_progressButton_backgroundColor, defaultBackgroundColor)
backgroundPaint.color = backgroundColor
val textColor = typedArray.getColor(R.styleable.ProgressButton_progressButton_textColor, defaultTextColor)
textPaint.color = textColor
val progressColor = typedArray.getColor(R.styleable.ProgressButton_progressButton_progressColor, defaultProgressColor)
progressPaint.color = progressColor
The code above is similar to the one you wrote to read the button text in the previous chapter. Your custom view can now read values passed using the XML attributes or a style.
Default Styles
In the last chapter, you learned about the View constructor, which looked like this:
class ProgressButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr)
In addition to the three arguments above, there’s another argument that’s important to styling. Open ProgressButton.kt and replace the constructor with the following with a new defStyleRes parameter.
class ProgressButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0
) : View(context, attrs, defStyleAttr, defStyleRes)
In this section, you’ll work with:
- defStyleAttr: The attribute in the theme that specifies which style this view uses.
- defStyleRes: The style the view uses. This usually ships with the library or SDK.
Remember the precedence order of the styling hierarchy? context.obtainStyledAttributes is the method that resolves the precedence and provides the final values to use. To do this, it needs access to defStyleAttr and defStyleRes.
Change the first line in init to:
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ProgressButton, defStyleAttr, defStyleRes)
In addition to passing attrs and R.styleable.ProgressButton, you also pass defStyleAttr and defStyleRes as arguments. This helps check the values and resolve the precedence.
To create a theme attribute that can specify the default style, open attrs.xml and add the following code outside ProgressButton:
<attr name="progressButtonStyle" format="reference"/>
The code above creates an attribute named progressButtonStyle that has a type reference since it refers to a style and not a definite value.
Defining Your Custom View’s Style
The next step is to define the style for your custom view. Open styles.xml and add the following code:
<style name="ProgressButtonStyle">
<item name="progressButton_backgroundColor">?attr/colorPrimary</item>
<item name="progressButton_textColor">?attr/colorOnPrimary</item>
<item name="progressButton_progressColor">?attr/colorOnPrimary</item>
</style>
This code creates a style and sets values for ProgressButton’s attributes by using theme attributes. To set the style above as the default style for ProgressButton in your theme, add the following to the style named AppTheme at the top of the file:
<item name="progressButtonStyle">@style/ProgressButtonStyle</item>
AppTheme’s style now looks like this:
<style name="AppTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="android:colorBackground">@color/colorBackground</item>
<item name="progressButtonStyle">@style/ProgressButtonStyle</item>
</style>
Setting defStyleAttr and defStyleRes
Your final step is to set the values of defStyleAttr and defStyleRes. Open ProgressButton.xml and change the default values of the constructor arguments as follows:
class ProgressButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.progressButtonStyle,
defStyleRes: Int = R.style.ProgressButtonStyle
) : View(context, attrs, defStyleAttr, defStyleRes)
Build and run. Go to the details page and you’ll see that the Adopt button has the correct default style. Toggle the theme and you’ll see that the text color changes according to the theme.
Congratulations! You’ve successfully created a custom view with great support for styles and themes.
Key Points
- Use styles and themes for consistent UI elements throughout the app.
- Styles apply to a specific view but themes apply to a view hierarchy.
- Different styling modes have a different order of precedence.
- Make your custom views styleable and provide a default style.
- Use
textAppearanceto group character level styling attributes. - Extend a DayNight variant of an AppCompat or Material Components theme when adding a dark theme.
- Use theme attributes as often as possible.
This chapter is the last about UI. In the next chapter you’ll start learning everything you need about a very important topic: security.