Create a custom Theme with Jetpack Compose
Doing Android apps is part of our daily job at Lunabee Studio. For almost 15 years the company strived to develop great apps using the best native technologies available.
So when Google released Jetpack compose, a technology that has the potential to revolutionize the Android eco-system we knew we had to try.
Switching to Compose was not an easy journey, we encountered many pitfalls along the way. But now that we just released our first full Compose app in production, looking back, the challenge was worth it.
The goal of this article is to show you a way to implement a custom theme, a step quite important when developing a new app 😉
🏋️ Warm-up
Our UI/UX team just finished the design of our example app : Hikes, an app displaying the best landscapes of the Alps 🏔️ :
Our theme will be composed of three main properties, the color, the typography and the shapes.
🎨 The color scheme of our app
Here is the color palette we are going to implement in our app. 👇
For the sake of simplicity, we wanted to keep it small, but our implementation will also work with a much bigger palette.
Our first job will be to “convert” it into Kotlin. And as you can see in the code bellow, it’s quite straight forward:
object HikesPalette {
val White = Color(0XFFFFFFFF)
val Green80 = Color(0xFF83F9BF)
val Green40 = Color(0xFF006C47)
val Green20 = Color(0xFF003823)
val Gray99 = Color(0xFFFBFDF8)
val Gray90 = Color(0xFFE1E3DF)
val Gray10 = Color(0xFF191C1A)
}
Now that the code of our palette has been written, we can implement the color scheme.
Nowadays a particular attention is set on the dark mode of an app. Thus, most of the apps we use have two schemes : one for the light mode and one for the dark one.
More info on the topic:
Here are the two color schemes we are going to implement 👇
Let’s first create a “contract” (or an interface 😉) that the two color schemes must follow.
interface HikesColorsScheme {
val main: Color
val mainContent: Color
val background: Color
val text: Color
}
Now that it has been done, we can easily create the two color schemes using the palette above:
// Light theme
object HikesLightColorsScheme : HikesColorsScheme {
override val main: Color = HikesPalette.Green40
override val mainContent: Color = HikesPalette.White
override val background: Color = HikesPalette.Gray99
override val text: Color = HikesPalette.Gray10
}
// Dark theme
object HikesDarkColorsScheme : HikesColorsScheme {
override val main: Color = HikesPalette.Green80
override val mainContent: Color = HikesPalette.Green20
override val background: Color = HikesPalette.Gray10
override val text: Color = HikesPalette.Gray90
}
🖊️ The Typography
Now that the color has been dealt with, we are going to take a look at the typography of our app. Here are the six different types that we are going to use. To represent that in a “code” perspective, we will define an object having six variables.
object HikeTypography {
val header = TextStyle(
fontFamily = Montserrat,
fontSize = 42.sp,
fontWeight = FontWeight.W900
)
val title = TextStyle(
fontFamily = Montserrat,
fontSize = 22.sp,
fontWeight = FontWeight.W700
)
val action = TextStyle(
fontFamily = Montserrat,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
val labelBold = TextStyle(
fontFamily = Montserrat,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
val bodyBold = TextStyle(
fontFamily = Montserrat,
fontSize = 14.sp,
fontWeight = FontWeight.W500
)
val body = TextStyle(
fontFamily = Montserrat,
fontSize = 14.sp,
fontWeight = FontWeight.W400
)
}
🟩 The Shapes
Concerning the shapes, we are going to do the same as before. We have three different shapes in our app, so let’s write that in a Kotlin way.
object HikeShape {
val smallRoundedCornerShape: RoundedCornerShape
@Composable
get() = RoundedCornerShape(4.dp)
val mediumRoundedCornerShape: RoundedCornerShape
@Composable
get() = RoundedCornerShape(8.dp)
val largeRoundedCornerShape: RoundedCornerShape
@Composable
get() = RoundedCornerShape(12.dp)
}
🎁 Provide the theme’s attributes
All our theme’s attributes have been defined. But one question remains: How will theses attributes be passed to our composables ?
object HikesTheme {
val colors: HikesColorsScheme = ... ?
val typography: HikeTypography = ...?
val shapes: HikesShape = ...?
}
Indeed, color, shape and typo are properties we need to access a lot of times in each of our composables (to define the background of a button, the border of a textfield etc…). Passing down the attributes to use to each composable seems a bit painful 🙄. Hopefully, there is a tool that will help us achieve our goal 👉 CompositionLocal.
CompositionLocal will help us pass data down to the composition implicitly. In other words, we will be able to do HikesTheme.colors.main
in every components of our composition tree without bothering to know if the app needs to use the light or dark theme.
Let’s start by defining a “Provider” for each properties 👇
val LocalHikesColor: ProvidableCompositionLocal<HikesColorsScheme> =
staticCompositionLocalOf { error("no provided") }
val LocalHikesTypography: ProvidableCompositionLocal<HikeTypography> =
staticCompositionLocalOf { error("no provided") }
val LocalHikesShape: ProvidableCompositionLocal<HikesShape> =
staticCompositionLocalOf { error("no provided") }
We can now fill the missing code in our HikesTheme:
object HikesTheme {
val colors: HikesColorsScheme
@Composable
get() = LocalHikesColor.current
val typography: HikeTypography
@Composable
get() = LocalHikesTypography.current
val shapes: HikesShape
@Composable
get() = LocalHikesShape.current
}
The next thing to do is to use CompositionLocalProvider in order to provide the attributes to the rest of the app.
val localHikesColors: HikesColorsScheme = if (darkTheme) {
HikesDarkColorsScheme
} else {
HikesLightColorsScheme
}
CompositionLocalProvider(
LocalHikesColor provides localHikesColors,
LocalHikesTypography provides HikeTypography,
LocalHikesShape provides HikesShape
) {
{ .... The content of Our App ....}
}
}
We are now ready to create the screens of our beautiful app! Well, nearly ready 😁….
A lot of composables provided by Google use material theme to style themselves. For instance, a button will use by default the MaterialTheme.colorScheme.primary
color for its background.
But with our current implementation we did not specify it, and thus all the buttons will have a default color (which is close to purple 😅), unless we explicitly tell them each time which color to use, which can be very painfull, and not very optimal.
To solve that, we will create a mapping from our color scheme to the MaterialTheme’s color scheme.
🧪 Enrich Material Theme
fun mapMaterialColorScheme(
darkTheme: Boolean,
hikesColors: HikesColorsScheme
) = if (darkTheme) {
darkColorScheme(
primary = hikesColors.main,
onPrimary = hikesColors.mainContent,
background = hikesColors.background,
onBackground = hikesColors.text
)
} else {
lightColorScheme(
primary = hikesColors.main,
onPrimary = hikesColors.mainContent,
background = hikesColors.background,
onBackground = hikesColors.text
)
}
The above function will map our color with some of the Material theme color. We did not map all the color but if you want to do it it’s possible 😉.
If you want to see all available colors with Material 3, check this👇
We are now ready to build our theme! 🎉
Let’s build our theme
We just need to fill the “…” in the snipet above with: 👇
MaterialTheme(
colorScheme = mapMaterialColorScheme(darkTheme, localHikesColors)
) {
content()
}
And thus we have :
@Composable
fun HikesTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val localHikesColors: HikesColorsScheme = if (darkTheme) {
HikesDarkColorsScheme
} else {
HikesLightColorsScheme
}
CompositionLocalProvider(
LocalHikesColor provides localHikesColors,
LocalHikesTypography provides HikeTypography,
LocalHikesShape provides HikesShape
) {
MaterialTheme(
colorScheme = mapMaterialColorScheme(darkTheme, localHikesColors)
) {
content()
}
}
}
We can now complete the code of the MainActivity :
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
HikesTheme {
Surface() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Header",
style = HikesTheme.typography.header
)
Text(
text = "Title",
style = HikesTheme.typography.title
)
Text(
text = "Action",
style = HikesTheme.typography.action
)
Text(
text = "Body bold",
style = HikesTheme.typography.bodyBold
)
Text(
text = "Body Regular",
style = HikesTheme.typography.body
)
Box(
modifier = Modifier
.size(50.dp)
.clip(HikesTheme.shapes.smallRoundedCornerShape)
.background(HikesTheme.colors.main)
)
Box(
modifier = Modifier
.size(50.dp)
.clip(HikesTheme.shapes.mediumRoundedCornerShape)
.background(HikesTheme.colors.main)
)
Box(
modifier = Modifier
.size(50.dp)
.clip(HikesTheme.shapes.largeRoundedCornerShape)
.background(HikesTheme.colors.main)
)
}
}
}
}
}
}
And that’s it, well done!👏. We’ve just created a truly custom theme for an app using Jetpack Compose. It’s is a rather simple process but we need to do it well if we want great-looking apps 😉