This is a functional Recipe app built in Kotlin and jetpack compose.
It is implemented to act as guide for for implementing the new Type Safe Compose Navigation api.
The app is currently in development as the api is in beta, any updates from the Jetpack team will be updated in due time.
PRs on improvements and bug fixes are welcome.
The architecture has been simplified to focus on the navigation implementation of the project.
Android Studio JellyFish or newer
[versions]
navigationCompose = "2.8.0-beta02"
kotlin = "1.9.23"
[libraries]
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
[plugins]
kotlinX-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
in your project build gradle file add kotlinX Serialization plugin
alias(libs.plugins.kotlinX.serialization) apply false
in your app build.gradle.kts file
alias(libs.plugins.kotlinX.serialization)
depedencies{
implementation(libs.androidx.navigation.compose)
//other deps
}
Declare your routes as Serializable Types
sealed class AppDestinations{
@Serializable
data object RecipeList : AppDestinations()
@Serializable data class RecipeDetail(val id : Int) : AppDestinations()
}
Declare your navigation graph using the serializable routes as destination type.
To navigate to a new screen,invoke the navcontroller.navigate() and pass the serializable type as the parameter
if you are navigating with arguments, on the destination screen you can invoke backstackentry.toRoute() to recreate the object from the route.
NavHost(
navController = navController,
startDestination = RecipeDestinations.Recipes,
modifier = modifier
) {
composable<RecipeDestinations.Recipes> {
AllRecipesScreen(RecipeRepository.getAllRecipes()) { recipeId ->
navController.navigate(RecipeDestinations.RecipeDetails(recipeId)) {
launchSingleTop = true
}
}
}
composable<RecipeDestinations.RecipeDetails> { backStackEntry ->
val recipeDetails: RecipeDestinations.RecipeDetails = backStackEntry.toRoute()
RecipeDetailPage(recipeDetails.recipeId) {
navController.navigateUp()
}
}
//more destinations
}
//In The viewmodel
class RecipeDetailViewModel(savedStateHandle : SavedStateHandle){
val recipeDetails: RecipeDestinations.RecipeDetails = savedStateHandle.toRoute()
....
}
The SavedStateHandle also has the extension toRoute() to retrieve the arguments directly into the viewmodel
//In The viewmodel
class RecipeDetailViewModel(savedStateHandle : SavedStateHandle,repository : RecipeRepository){
val recipeDetails: RecipeDestinations.RecipeDetails = savedStateHandle.toRoute()
....
fun getRecipeDetails(){
repository.getRecipeDetails(recipeDetails.id)
...
}
}
Define an enum class that contains declrations and configuration of the bottom bar , the only change we do from previous implementation is have the route as an object instead of a String
enum class TopLevelDestinations(
val label: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector,
val route: RecipeDestinations
) {
Recipes(
label = "Recipes",
selectedIcon = Icons.Filled.Restaurant,
unselectedIcon = Icons.Outlined.Restaurant,
route = RecipeDestinations.Recipes
),
Favorites(
label = "Favorites",
selectedIcon = Icons.Filled.Favorite,
unselectedIcon = Icons.Outlined.Favorite,
route = RecipeDestinations.Favorites
),
}
In your BottomNavigation composable, get the current NavBackStackEntry using the currentBackStackEntryAsState() function. This entry gives you access to the current NavDestination. The selected state of each BottomNavigationItem can then be determined by checking if the current destination or any of its parent destinations have the same route class as the item's route using the NavDestination.hasRoute() function and the item's route::class KClass instance.
@Composable
fun RecipeBottomBar(navController: NavController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination: NavDestination? = navBackStackEntry?.destination
val showBottomNav = TopLevelDestinations.entries.map { it.route::class }.any { route ->
currentDestination?.hierarchy?.any {
it.hasRoute(route)
} == true
}
AnimatedVisibility(visible = showBottomNav) {
BottomAppBar {
TopLevelDestinations.entries.map { bottomNavigationItem ->
val isSelected =
currentDestination?.hierarchy?.any { it.hasRoute(bottomNavigationItem.route::class) } == true
if (currentDestination != null) {
NavigationBarItem(selected = isSelected,
onClick = {
navController.navigate(bottomNavigationItem.route)
},
icon = {
Icon(
imageVector = if (isSelected) bottomNavigationItem.selectedIcon else bottomNavigationItem.unselectedIcon,
contentDescription = bottomNavigationItem.label
)
}, alwaysShowLabel = isSelected,
label = {
Text(bottomNavigationItem.label)
})
}
}
}
}
}
Even though it is not recomended there might be a need to pass a complex data type as a navigation argument, There are built-in NavTypes for primitive types, such as int, long, boolean, float, and strings, parcelable, and serializable classes (including Enums), as well as arrays of each supported type.
For any other custom types you need to create a custom NavType and pass it to your graph declaration. This is to define how the custom type can be encoded and decoded during navigation. We have custom type of an enum class for different types of recipes
enum class RecipeType {
Breakfast,
Lunch,
Supper,
Snack
}
Fortunately enums are supported as a navtype and all we need is pass the type map as an argument of the composable
composable<RecipeDestinations.CategoryRecipes>(
typeMap = mapOf(typeOf<RecipeType>() to NavType.EnumType(RecipeType::class.java))
) { backStackEntry ->
val category: RecipeDestinations.CategoryRecipes =
backStackEntry.toRoute<RecipeDestinations.CategoryRecipes>()
CategoryRecipesScreen(
category.type,
//pass other arguments
}
}
we will however create our custom Navtype for the RecipeType enum class to demonstrate how it should be done for any complex type. we'll need to add the parcelize plugin to our app level gradle file it will now look like this
plugins{
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.kotlinX.serialization)
id("kotlin-parcelize")
}
Next we will annoate the the Recipetype class with @Parcelize and Serializable then make it extend Parcelable
@Serializable
@Parcelize
enum class RecipeType : Parcelable {
Breakfast,
Lunch,
Supper,
Snack
}
We create the Navtype for the recipe class following This Guide
val CategoryNavigationType = object : NavType<RecipeType>(isNullableAllowed = false) {
override fun get(bundle: Bundle, key: String): RecipeType? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
bundle.getParcelable(key, RecipeType::class.java)
} else {
@Suppress("DEPRECATION")
bundle.getParcelable(key)
}
}
override fun parseValue(value: String): RecipeType {
return Json.decodeFromString<RecipeType>(value)
}
override fun serializeAsValue(value: RecipeType): String {
return Json.encodeToString(value)
}
override fun put(bundle: Bundle, key: String, value: RecipeType) {
bundle.putParcelable(key, value)
}
}
Finally we will pass it when creating our graph
composable<RecipeDestinations.CategoryRecipes>(
typeMap = mapOf(typeOf<RecipeType>() to CategoryNavigationType)
) { backStackEntry ->
val category: RecipeDestinations.CategoryRecipes =
backStackEntry.toRoute<RecipeDestinations.CategoryRecipes>()
CategoryRecipesScreen(
category.type,
...
}
The api is currently in beta and not much is expected to change in the future if you encounter an issue file it Happy Coding , Leave a Star ⭐,remember to keep your types safe and your code right.