Building Navigation from Scratch in Jetpack Compose (With Backstack Handling)
When developing with Activity or Fragment, screen transitions are supported out of the box without any additional libraries. The system automatically manages the back stack, making navigation seamless. However, in Jetpack Compose, there is no built-in API for screen transitions, meaning we either have to use a library or implement navigation manually.
Using a Library (Eungabi)
Recently, a Kotlin Multiplatform-compatible navigation library called Eungabi has been in development. The name, derived from a beautiful Korean word, was chosen to reflect elegance in navigation solutions.
Eungabi supports Jetpack-style navigation, including Predictive Back Gesture and Shared Element Transitions. Additionally, it allows customization of predictive back animations — something that Jetpack’s navigation library currently lacks.
Issue reports and contributions are always welcome, as this library is still in its early stages and requires community support.
👉 Check it out here: Eungabi GitHub
Implementing Navigation Manually
If you choose not to use a library, there are two key aspects to consider:
- Screen Transitions
- Backstack Management
1. Screen Transitions
In Compose, navigation is about rendering the appropriate UI based on some state. Below is an example of a simple screen transition using a currentScreen
state.
@Composable
fun NavigationScreen() {
var currentScreen by remember { mutableStateOf(Screen.A) }
when (currentScreen) {
Screen.A -> Screen("Screen A") { currentScreen = Screen.B }
Screen.B -> Screen("Screen B") { currentScreen = Screen.A }
}
}
@Composable
fun Screen(text: String, onClick: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text)
Button(onClick = onClick) {
Text("Navigate")
}
}
}
enum class Screen { A, B }
To add animations during screen transitions, we can use AnimatedContent
:
@Composable
fun NavigationScreen() {
var currentScreen by remember { mutableStateOf(Screen.A) }
AnimatedContent(
targetState = currentScreen,
label = "Transition"
) { targetScreen ->
when (targetScreen) {
Screen.A -> Screen("Screen A") { currentScreen = Screen.B }
Screen.B -> Screen("Screen B") { currentScreen = Screen.A }
}
}
}
This approach is quite similar to how NavHost
works in Jetpack Navigation.
2. Backstack Management
A major issue with the above implementation is that pressing the back button exits the app instead of returning to the previous screen. To fix this, we need to maintain a back stack using a data structure.
First, define a ScreenEntry
class to hold screen information:
data class ScreenEntry<T>(
val route: T,
val content: @Composable () -> Unit
)
Next, create a BackStackController
class to manage navigation history:
class BackStackController<T>(
private val initialRoute: T,
builder: EntryGraphBuilder<T>.() -> Unit
) {
private var graph: Map<T, ScreenEntry<T>> = EntryGraphBuilder(this)
.apply(builder)
.build()
private val backQueue: ArrayDeque<ScreenEntry<T>> = run {
val initialEntry = findScreenEntry(initialRoute) ?: return@run ArrayDeque()
return@run ArrayDeque(listOf(initialEntry))
}
private val _backStack = MutableStateFlow(backQueue.toList())
val backStack = _backStack.asStateFlow()
val currentScreen = backStack
.map(List<ScreenEntry<T>>::last)
.stateIn(
initialValue = findScreenEntry(initialRoute),
started = SharingStarted.WhileSubscribed(5000),
scope = CoroutineScope(Dispatchers.Main)
)
private fun findScreenEntry(route: T): ScreenEntry<T>? = graph[route]
fun addBackStack(screenRoute: T) {
val entry = findScreenEntry(screenRoute) ?: return
backQueue.addLast(entry)
_backStack.tryEmit(backQueue.toList())
}
fun removeBackStack() {
backQueue.removeLast()
_backStack.tryEmit(backQueue.toList())
}
}
The EntryGraphBuilder
simplifies registering screens:
class EntryGraphBuilder<T>(
private val controller: BackStackController<T>
) {
private val entryMapInternal = hashMapOf<T, ScreenEntry<T>>()
fun register(
route: T,
content: @Composable (controller: BackStackController<T>) -> Unit
) {
val entry = ScreenEntry(
route = route,
content = { content(controller) }
)
entryMapInternal[route] = entry
}
fun build(): Map<T, ScreenEntry<T>> = entryMapInternal
}
Integrating with Navigation UI
Now, let’s integrate BackStackController
into our navigation logic:
@Composable
fun NavigationScreen() {
val controller = remember {
BackStackController(initialRoute = Screen.A) {
register(Screen.A) { controller ->
Screen("Screen A") { controller.addBackStack(Screen.B) }
}
register(Screen.B) { controller ->
Screen("Screen B") { controller.addBackStack(Screen.A) }
}
}
}
val backStack by controller.backStack.collectAsState()
val currentScreen by controller.currentScreen.collectAsState()
AnimatedContent(targetState = currentScreen, label = "Transition") { targetScreen ->
targetScreen?.content()
}
BackHandler(enabled = backStack.size > 1) {
controller.removeBackStack()
}
}
Final Thoughts
Navigation is an essential part of modern client-side development. The concepts covered here — screen transitions and back stack management — are applicable not only in Jetpack Compose but also in declarative frameworks like SwiftUI, Flutter, and React. By mastering these patterns, you can develop UI applications across different platforms with confidence.
🔥 Additional Challenges to Try
- Implement Predictive Back Gesture (Advanced)
- Pass Arguments Between Screens (Intermediate)
- Add Shared Element Transition (Beginner-Intermediate)
Full source code is available [here]. And don’t forget to check out Eungabi!