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:

  1. 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.

  2. 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.

  3. 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.

  4. 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:

  1. Colors: The core color palette of your app, including primary, secondary, background, and text colors. These are defined as simple Color objects.

  2. Typography: The fonts, sizes, and styles for your text elements. These are defined as TextStyle and Typography objects.

  3. 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 colors
  • RadialGradient: A circular gradient that radiates outward from a center point
  • SweepGradient: 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:

  1. Compose themes are defined in Kotlin code, not XML, using simple functions and objects for colors, typography, and shapes.

  2. Apply your theme by wrapping composables in a custom theme composable that sets the MaterialTheme values.

  3. Access theme values in any composable using the MaterialTheme object, like MaterialTheme.colors.primary.

  4. Use advanced techniques like animations, gradients, and custom layouts to create unique and dynamic themes.

  5. 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:

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!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *