Creating Reusable TopBar in Jetpack Compose and Slot Pattern
I was looking for a way to write a composable function that takes content as a parameter and didn't see many examples of that. I just realized that I'm rewriting the same lines of code for each screen and I started to search for a solution. I didn't see examples online or find a solution so went into the source code of the layouts and start reading. I came up with a solution (we'll implement it soon).
One usage can be to reuse the TopBar in Compose app without having to rewrite TopBar for each screen. The same way can be done for different scopes Column, Row, etc. After refactoring, there was a huge difference and less code.
Note: In this article, we will not focus to write pretty UI for simplicity.
Requirements
Basic kotlin knowledge
Familiarity with Jetpack Compose
- Compose navigation
Our simple application will contain only 2 screens (one topbar and only text as content). Let's start defining the TopBar.
TopBar.kt
package com.hasancbngl.reusable_topbar
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CardDefaults.cardColors
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TopBar(
title: String,
onBackButtonClick: () -> Unit,
onHomeButtonClick: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(.1f),
shape = RoundedCornerShape(6.dp),
elevation = CardDefaults.cardElevation(5.dp),
colors = cardColors(Color.White)
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "",
modifier = Modifier
.clickable { onBackButtonClick() }
)
Text(text = title, fontSize = 24.sp)
Icon(
imageVector = Icons.Default.Home,
contentDescription = "",
modifier = Modifier
.clickable { onHomeButtonClick() }
)
}
}
}
Now, let's use our TopBar in MainScreen and ItemsScreen.
MainScreen.kt
package com.hasancbngl.reusable_topbar.mainscreen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavHostController
import com.hasancbngl.reusable_topbar.TopBar
@Composable
fun MainScreen(navController: NavHostController) {
Column(modifier = Modifier.fillMaxSize()) {
TopBar("Main Screen",
onBackButtonClick = {
navController.navigateUp()
},
onHomeButtonClick = {
navController.navigate("main_screen") {
popUpTo("main_screen")
}
}
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Green)
.clickable {
navController.navigate("items_screen")
}
) {
Text(
text = "Main Screen text", modifier = Modifier.align(
Alignment.Center
)
)
}
}
}
ItemsScreen.kt
package com.hasancbngl.reusable_topbar.mainscreen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.NavHostController
import com.hasancbngl.reusable_topbar.TopBar
@Composable
fun ItemsScreen(navController: NavHostController) {
Column(modifier = Modifier.fillMaxSize()) {
TopBar("Items Screen",
onBackButtonClick = {
navController.navigateUp()
},
onHomeButtonClick = {
navController.navigate("main_screen") {
popUpTo("main_screen")
}
}
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Yellow)
) {
Text(
text = "Items Screen text", modifier = Modifier.align(
Alignment.Center
)
)
}
}
}
MainActivity.kt
package com.hasancbngl.reusable_topbar
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.hasancbngl.reusable_topbar.mainscreen.ItemsScreen
import com.hasancbngl.reusable_topbar.mainscreen.MainScreen
import com.hasancbngl.reusable_topbar.ui.theme.ReusableTopbarDemoAppTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ReusableTopbarDemoAppTheme {
// A surface container using the 'background' color from the theme
val navController = rememberNavController()
SetupNavGraph(navController)
}
}
}
@Composable
fun SetupNavGraph(
navController: NavHostController
) {
NavHost(navController = navController, startDestination = "main_screen") {
composable(route = "main_screen") {
MainScreen(navController = navController)
}
composable(route = "items_screen") {
ItemsScreen(navController = navController)
}
}
}
}
That starting code can be find on the main branch of the project Github
Our basic app with 2 screens is ready and now comes the fun part. We could pass the navController to TopBar and refactor it for onClickListeners but we will have it refactored eventually and use the TopBar only once.
Next, time to take a break from our app and inspect the Column layout codebase in Android Studio.
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
) {
As we see above, the Column layout (as an example) same as all the layouts and functions that take parameters. We are very familiar with the modifiers, right? And the content parameter is where we fill in our views for instance in our screen composables we have TopBar and only text as content.
Before we write our reusable function which takes also content as a parameter, let's check what our screens has in common.
- Both uses Column as the parent layout
- TopBar
Create new Composable function and add the parameters. We need title and also content parameters.
content: @Composable ColumnScope.() -> Unit
This content paramater is basically a Composable and Lambda function with return type Unit. The ColumnScope is here not necessary to use but we will understand it better soon. It is basically the parent layout Scope which we will write instead of ColumnScope. Since I use Column as the parent layout, I will keep it the same.
ScreenWrapper.kt
@Composable
fun ScreenWrapper(
title: String,
navController: NavHostController,
content: @Composable ColumnScope.() -> Unit
) {
Column(modifier = Modifier.fillMaxSize()) {
TopBar(title,
onBackButtonClick = {
navController.navigateUp()
},
onHomeButtonClick = {
navController.navigate("main_screen") {
popUpTo("main_screen")
}
}
)
content(this)
}
}
We see almost the same code which we have in our screens. The only difference is there is content(this) here and takes this as an argument. We made it. We wrote a reusable function that takes content as a parameter but what is going on with this??
The this keyword is basically the scope we have and we will provide to be used in other contents. Time to jump in our screens and refactor. Go to MainScreen first and delete the Column and TopBar and Use ScreenWrapper as the parent layout. Then, do the same for ItemsScreen
Also, to see the importance of this keyword and the scope we provided ColumnScope. before Composable annotation, let's change the size of the Box'es we have as 250.dp.
MainScreen.kt
package com.hasancbngl.reusable_topbar.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.hasancbngl.reusable_topbar.screens.components.ScreenWrapper
@Composable
fun MainScreen(navController: NavHostController) {
ScreenWrapper(title = "Main Screen", navController = navController) {
Box(
modifier = Modifier
.size(250.dp)
.align(Alignment.CenterHorizontally)
.background(Color.Green)
.clickable {
navController.navigate("items_screen")
}
) {
Text(
text = "Main Screen text", modifier = Modifier.align(
Alignment.Center
)
)
}
}
}
Size of the Box changed and we aligned it horizantally with .align(Alignment.CenterHorizontally)
if you remove the ColumnScope. from the ScreenWrapper function and (this) you will see that .align() is not usable.
ItemsScreen.kt
package com.hasancbngl.reusable_topbar.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.hasancbngl.reusable_topbar.screens.components.ScreenWrapper
@Composable
fun ItemsScreen(navController: NavHostController) {
ScreenWrapper(title = "Items Screen", navController = navController) {
Box(
modifier = Modifier
.size(250.dp)
.align(Alignment.CenterHorizontally)
.background(Color.Yellow)
) {
Text(
text = "Items Screen text", modifier = Modifier.align(
Alignment.Center
)
)
}
}
}
Waw. We made it, it just works the same and it's like magic. Let's take it one step further, assume we have a also text:String parameter we want to use in both screens, Can we provide a custom value and use it?
The answer is yes. Change the content in ScreenWrapper as
content: @Composable ColumnScope.(
text: String,
) -> Unit
Wait, what's going on? We see an error in content(this) section, required string and found columnScope. Add the string you want as second parameter. It became
content(this, "This Text From ScreenWrapper")
Now we can use this text in any screen we want. Example, MainScreen.kt
@Composable
fun MainScreen(navController: NavHostController) {
ScreenWrapper(title = "Main Screen", navController = navController) { text ->
Box(
modifier = Modifier
.size(250.dp)
.align(Alignment.CenterHorizontally)
.background(Color.Green)
.clickable {
navController.navigate("items_screen")
}
) {
Text(
text = "Main Screen text", modifier = Modifier.align(
Alignment.Center
)
)
Text(text = text)
}
}
}
If you use it in ItemsScreen there is the same result. It's amazing, isn't it? Before saying last words, what we did is called Slot Pattern it is widely used within codebase of Compose. If you are interested to find out more: Slot Api Compose
The final code, can be found on my Github on finish branch. I hope you enjoyed this article. Let me know if this helped you or if you made something similar. Thanks for reading!