Compose Theming: Best Practices in Jetpack Compose for Beautiful UIs

Jetpack Compose is revolutionizing Android UI development by providing a declarative and reactive way to build user interfaces. One of the core aspects of any well-designed app is theming – the process of defining and applying a consistent visual style throughout the application. Properly implementing theming ensures a polished, user-friendly experience. In this comprehensive guide, we’ll explore the best practices for Compose theming in Jetpack Compose.

Why Theming Matters in Jetpack Compose

Theming is more than just setting colors and fonts; it’s about creating a cohesive brand identity and ensuring consistency across all UI elements. Proper theming in Jetpack Compose helps in:

  • Brand Consistency: Ensures a consistent look and feel that aligns with the brand.
  • Accessibility: Allows easy customization for users with different needs (e.g., high contrast mode).
  • Maintainability: Simplifies updates and changes to the app’s visual style.
  • User Experience: Provides a predictable and pleasing visual experience for the user.

Key Components of Compose Theming

Compose theming primarily revolves around the following components:

  • Colors: Defines the color palette used in the app.
  • Typography: Specifies the fonts, styles, and sizes used for text.
  • Shapes: Sets the shapes (e.g., rounded corners) used for components.

Step-by-Step Guide to Compose Theming

Step 1: Setting up the Theme Composable

Jetpack Compose provides a MaterialTheme composable that wraps the entire UI, providing access to themed values. Customizing this composable allows you to define your app’s unique style.


import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable

@Composable
fun MyAppTheme(
    content: @Composable () -> Unit
) {
    MaterialTheme(
        content = content
    )
}

Now, let’s look at how to customize each aspect of the theme.

Step 2: Defining Custom Colors

Create a Colors object that holds your app’s color palette. Define primary, secondary, background, and other essential colors.


import androidx.compose.ui.graphics.Color
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme

val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8BC)

val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)


private val DarkColorScheme = darkColorScheme(
    primary = Purple80,
    secondary = PurpleGrey80,
    tertiary = Pink80
)

private val LightColorScheme = lightColorScheme(
    primary = Purple40,
    secondary = PurpleGrey40,
    tertiary = Pink40

    /* Other default colors to override
    background = Color(0xFFFFFBFE),
    surface = Color(0xFFFFFBFE),
    onPrimary = Color.White,
    onSecondary = Color.White,
    onTertiary = Color.White,
    onBackground = Color(0xFF1C1B1F),
    onSurface = Color(0xFF1C1B1F),
    */
)

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }

        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

By providing custom color schemes, you ensure a visually consistent experience whether the user prefers a light or dark theme.

Step 3: Defining Typography

Typography defines the text styles used throughout your application. Use the Typography class to specify fonts, font weights, and sizes.


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
import androidx.compose.material3.Typography

// Set of Material typography styles to start with
val Typography = Typography(
    bodyLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.5.sp
    )
    /* Other default text styles to override
    titleLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 22.sp,
        lineHeight = 28.sp,
        letterSpacing = 0.sp
    ),
    labelSmall = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Medium,
        fontSize = 11.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    )
    */
)

Step 4: Defining Shapes

Shapes control the appearance of components, such as the roundness of buttons or the corners of cards. Customize the Shapes object to create a unique visual style.


import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp

val Shapes = Shapes(
    small = RoundedCornerShape(4.dp),
    medium = RoundedCornerShape(8.dp),
    large = RoundedCornerShape(0.dp)
)

Step 5: Applying the Theme

Wrap your composable content with your custom MyAppTheme to apply the defined styles.


import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun ThemedContent() {
    MyAppTheme {
        Surface {
            Text("Hello, Themed World!")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    ThemedContent()
}

Best Practices for Compose Theming

To ensure effective and maintainable theming in your Jetpack Compose applications, follow these best practices:

1. Use Semantic Colors

Instead of directly using color values in your composables, reference semantic color names (e.g., MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.secondary). This provides abstraction and allows you to change color schemes easily without modifying every composable.


import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable

@Composable
fun MyButton(text: String) {
    Button(
        onClick = { /* Handle click */ },
        colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colorScheme.primary)
    ) {
        Text(text = text, color = MaterialTheme.colorScheme.onPrimary)
    }
}

2. Implement Dark and Light Themes

Support both light and dark themes to cater to user preferences and improve accessibility. Use isSystemInDarkTheme() to detect the system’s theme setting and provide corresponding color schemes.


import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColors
    } else {
        LightColors
    }
    MaterialTheme(
        colors = colors,
        content = content
    )
}

3. Organize Theme Components

Keep your theme definitions (colors, typography, shapes) in separate files or packages to maintain a clean and organized codebase. This makes it easier to find and modify theme-related code.

4. Provide Custom Theme Attributes

You can provide custom theme attributes using CompositionLocalProvider to offer additional theming options that go beyond colors, typography, and shapes. For example, you can define a custom spacing scale:


import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

data class Spacing(
    val small: Dp = 4.dp,
    val medium: Dp = 8.dp,
    val large: Dp = 16.dp
)

val LocalSpacing = staticCompositionLocalOf { Spacing() }

Then, provide this in your MyAppTheme:


import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable

@Composable
fun MyAppTheme(content: @Composable () -> Unit) {
    val spacing = Spacing()
    MaterialTheme {
        CompositionLocalProvider(LocalSpacing provides spacing) {
            content()
        }
    }
}

Finally, use it in your composables:


import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.padding

@Composable
fun MyComponent() {
    val spacing = LocalSpacing.current
    Text("My Component", modifier = Modifier.padding(spacing.medium))
}

5. Test Your Theme

Create UI tests that verify that your theme is applied correctly across different components and states. This helps ensure that your app’s visual appearance remains consistent even as you make changes.

Example: A Complete Theming Implementation

Here’s an example that puts all the above best practices together.


// Color.kt
import androidx.compose.ui.graphics.Color

val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)

val DarkColors = darkColorScheme(
    primary = Purple200,
    secondary = Teal200
)

val LightColors = lightColorScheme(
    primary = Purple500,
    secondary = Teal200
)

// Typography.kt
import androidx.compose.material.Typography
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

val Typography = Typography(
    body1 = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp
    )
)

// Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors = if (darkTheme) {
        DarkColors
    } else {
        LightColors
    }

    MaterialTheme(
        colors = colors,
        typography = Typography,
        content = content
    )
}

// MainActivity.kt
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun MainContent() {
    Surface(color = MaterialTheme.colors.background) {
        Text(text = "Hello, Themed World!", color = MaterialTheme.colors.primary)
    }
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    MyAppTheme {
        MainContent()
    }
}

Conclusion

Compose theming in Jetpack Compose is essential for creating a visually appealing and consistent user experience. By defining custom colors, typography, and shapes, and adhering to best practices, you can build a theming system that is maintainable, scalable, and accessible. Following the principles and code examples outlined in this guide will empower you to create visually stunning and well-themed Android applications with Jetpack Compose.