Mastering App Styling and Theming With Jetpack Compose: An Expert Guide
Since its stable 1.0 release in July 2021, Jetpack Compose has rapidly gained popularity as Android‘s modern UI toolkit. According to Google, over 100,000 Android apps now use Compose, including many of its own first-party apps like Gmail, Play Store, and Maps. A big reason for its success is the declarative, Kotlin-first approach to building UIs, which is not only more intuitive but also results in significantly less code – up to a 30% reduction according to the Compose team.
One area where Compose really shines compared to the legacy View system is app theming and styling. With Compose, you define your UI theme entirely in Kotlin code, using a simple set of composable functions and objects. This unlocks the full power and flexibility of the language, enabling dynamic themes, seamless style reuse, and easy customization.
In this comprehensive guide, we‘ll dive deep into Compose‘s theming system and learn best practices for creating stunning, responsive app themes. Whether you‘re new to Compose or a seasoned pro, by the end you‘ll have the skills to build a complete custom design system for your app. Let‘s get started!
The Challenges of XML-based Theming
To appreciate how Compose improves styling, it helps to understand the challenges of the traditional XML-based approach in the View system. While it was certainly workable, it had some major drawbacks:
-
XML is verbose and hard to read. Styling attributes are scattered across multiple files and resource folders, making it difficult to get a holistic view of your theme. Typing out long resource names like
android:textAppearance
is cumbersome and error-prone. -
Limited code reuse. XML resources can only be referenced, not abstracted or composed together. If you wanted to create a reusable "brand button" style for example, you‘d have to duplicate the same attributes everywhere it‘s used. This quickly becomes a maintenance nightmare.
-
Can‘t express dynamic styling. XML is static – you can‘t easily change styles based on state like a dark mode toggle or a user‘s preferences. The best you can do is define multiple resource files and manually switch between them.
-
Coupling of theme and component. With XML, a component‘s structure (layout) and style (theme) are tightly coupled in the same file. This makes it harder to develop and test components in isolation from the theme.
Compare that to Compose, where your entire theme is defined in a few compact Kotlin objects that are easy to read, reuse, and customize. You can dynamically change the theme at any point in your composable hierarchy, and even pass theme data as parameters to composables. The end result is a much more powerful and maintainable system for app styling.
Anatomy of a Compose Theme
So what exactly makes up a Compose theme? At the core, it consists of three main parts:
-
Colors: The core color palette of your app, including primary, secondary, background, and text colors. These are defined as simple
Color
objects. -
Typography: The fonts, sizes, and styles for your text elements. These are defined as
TextStyle
andTypography
objects. -
Shapes: The corner radii and edge treatments for your UI components like buttons, cards, and dialogs. These are defined as
Shape
objects.
Here‘s a complete example of a basic Compose theme:
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Colors
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
// Color Palettes
private val DarkColors = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200
)
private val LightColors = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200
)
// Typography
val Typography = Typography(
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
h1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 24.sp
),
button = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.W500,
fontSize = 14.sp
)
)
// Shapes
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(8.dp),
large = RoundedCornerShape(16.dp)
)
This is already much more concise and readable than the equivalent XML would be. But the real power comes in how you apply the theme to your composables.
Applying a Theme
To actually use your theme, you create a custom @Composable
function that takes a content
lambda as a parameter. Inside this function, you call the MaterialTheme()
composable, passing your theme objects as parameters:
@Composable
fun MyTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}
You can then wrap any composable you want to be styled with your theme, and it will inherit those theme properties:
@Composable
fun MyThemedApp() {
MyTheme {
// Composables in here will use MyTheme
Surface(color = MaterialTheme.colors.background) {
Text("Hello world", style = MaterialTheme.typography.body1)
}
}
}
The MaterialTheme
object acts as the current "context" of your theme within the composable tree. Any composable can access the current theme through it, like we did with MaterialTheme.colors.background
and MaterialTheme.typography.body1
.
This pattern of passing a themed content
block is a core principle of Compose UI. By organizing your theme and composables in this way, you achieve a clear separation of concerns – your theme configuration lives independently from your screen layouts. This makes your UI much more modular and reusable.
Advanced Theming Techniques
The basic approach we‘ve covered so far will get you pretty far, but Compose theming can do a lot more! Let‘s look at some advanced techniques you can use to level up your themes.
Animating Theme Changes
Compose makes it easy to animate changes to your theme, like toggling between a light and dark color scheme. The key is Compose‘s animateColorAsState()
function, which lets you animate a Color
between two values.
Here‘s an example of how you can animate your theme‘s primary color when a button is clicked:
@Composable
fun AnimatedThemeButton() {
var isDark by remember { mutableStateOf(false) }
val backgroundColor by animateColorAsState(if (isDark) DarkColors.primary else LightColors.primary)
MyTheme(darkTheme = isDark) {
Surface(color = backgroundColor) {
Button(onClick = { isDark = !isDark }) {
Text(text = "Toggle Theme")
}
}
}
}
In this example, we define a isDark
state variable that keeps track of the current theme mode. We pass that variable to our MyTheme
function to toggle the color palette.
For the button itself, we create an animated
version of the background color that automatically animates whenever isDark
changes. The by
keyword is a property delegate that sets up a listener on isDark
and updates the backgroundColor
whenever it changes.
The end result is a smooth, animated transition between light and dark modes whenever the user taps the button. This is just one example – you can use this same technique to animate any part of your theme, like text colors, sizes, or even shapes and elevations.
Gradients and Brushes
In addition to solid colors, Compose also supports gradient fills for any Modifier.background()
or Modifier.border()
. You can define these gradients using the various Brush
classes:
LinearGradient
: A simple linear gradient between two or more colorsRadialGradient
: A circular gradient that radiates outward from a center pointSweepGradient
: An angular gradient that sweeps around a center point
Here‘s an example of creating a button with a linear gradient background:
@Composable
fun GradientButton(
text: String,
onClick: () -> Unit,
gradient: Brush
) {
Button(
onClick = onClick,
modifier = Modifier.background(gradient)
) {
Text(text = text)
}
}
// Usage
GradientButton(
text = "Submit",
onClick = { /* ... */ },
gradient = LinearGradient(
colors = listOf(Color.Blue, Color.Cyan),
start = Offset(0f, 0f),
end = Offset(100f, 0f)
)
)
You can use gradients for any background or fill, including shapes, cards, and other surfaces. They‘re a great way to add visual interest and branding to your UI.
Reusing Styles
One of the biggest advantages of Compose theming is the ability to easily create and reuse custom styles across your app. Anytime you find yourself repeating the same set of modifiers or attributes, you can extract that to a reusable Modifier
extension function:
fun Modifier.myTextStyle() = this.then(
Modifier
.padding(16.dp)
.background(MyTheme.colors.primary)
.clip(RoundedCornerShape(8.dp))
)
// Usage
Text(
text = "Hello reusable styles",
modifier = Modifier.myTextStyle()
)
You can also create your own custom layout components that encapsulate a common set of styling attributes:
@Composable
fun StyledRow(
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit
) {
Row(
modifier = modifier
.padding(16.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MyTheme.colors.secondary),
horizontalArrangement = Arrangement.SpaceBetween,
content = content
)
}
// Usage
StyledRow {
Text("Left")
Text("Right")
}
By centralizing your styles and theme in these shared components, you ensure a consistent look and feel across your app while minimizing duplicated code.
Material Theming
So far we‘ve been using Compose‘s MaterialTheme
as a container for our custom theme attributes. But the full Material Design system is much more expansive, with detailed guidelines for color, typography, shape, elevation, and more.
Compose makes it easy to implement a Material theme by providing composable functions for all the core Material components like Button
, TextField
, Card
, TopAppBar
, etc. Each of these has a colors
, shape
, and typography
parameter that lets you customize their style to match your theme:
@Composable
fun MyMaterialButton(onClick: () -> Unit) {
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary,
contentColor = MaterialTheme.colors.onPrimary
),
shape = MaterialTheme.shapes.medium,
elevation = ButtonDefaults.elevation(
defaultElevation = 8.dp,
pressedElevation = 2.dp
)
) {
Text(text = "Material Button")
}
}
By styling your app with these Material components, you ensure a consistent and professional look that follows industry best practices. You can of course customize them to fit your own unique brand and style as well.
To learn more about implementing Material Design with Compose, check out Google‘s excellent Material Theming guide: https://material.io/blog/migrating-material-3-compose
Wrap-up
Congratulations, you now have a deep understanding of how to style and theme an app using Jetpack Compose! To recap, the key points we covered:
-
Compose themes are defined in Kotlin code, not XML, using simple functions and objects for colors, typography, and shapes.
-
Apply your theme by wrapping composables in a custom theme composable that sets the
MaterialTheme
values. -
Access theme values in any composable using the
MaterialTheme
object, likeMaterialTheme.colors.primary
. -
Use advanced techniques like animations, gradients, and custom layouts to create unique and dynamic themes.
-
Ensure a consistent look by centralizing your theme and using Material components and guidelines.
Theming is such an important part of any app experience, and Compose provides an incredibly powerful and flexible system for implementing it. I hope this guide has inspired you to dive deep and create your own stunning themes!
For a real-world example of a complete Compose theme, check out the open-source Compose Material Catalog sample app:
https://github.com/android/compose-samples/tree/main/Owl
It demonstrates a realistic app theme with support for dynamic color, branded components, light/dark toggle, and even time-based themes. Give it a look and see what creative ideas you can come up with!
As always, the official Compose documentation is the best resource for learning more. Check out the Theming and Material sections for an even deeper dive:
- Theming: https://developer.android.com/jetpack/compose/themes
- Material: https://developer.android.com/jetpack/compose/designsystems/material
If you have any other questions or want to share your own Compose theming tips, feel free to reach out! You can find me on Twitter at @ComposeExpert or email me at [email protected].
Happy theming!