Using Trio for Navigation in Android Jetpack Compose
The article shows how Trio stores navigation state in a ViewModel‑backed stack, uses type‑safe routers for screen and activity transitions, supports async save‑and‑exit, result‑returning navigation, and interoperates with legacy fragments, enabling modular, testable navigation in Jetpack Compose.
This article continues the series on the Trio framework, Airbnb’s Android Jetpack Compose‑based page architecture. It focuses on how navigation is implemented within Trio, showing how storing navigation state in a ViewModel simplifies testing and modularity.
State Model
data class ParentState(
@PersistState val trioStack: List
) : MavericksStateThe ParentState holds a list of Trio objects, representing the navigation stack. The @PersistState annotation (provided by Mavericks) ensures the stack survives process death.
ViewModel Operations
class ParentViewModel : TrioViewModel {
fun pushScreen(trio: Trio) = setState {
copy(trioStack = trioStack + trio)
}
fun pop() = setState {
copy(trioStack = trioStack.dropLast(1))
}
}The ViewModel manipulates the stack directly, allowing push, pop, reorder, or clear operations without touching the UI layer.
Composable Rendering
@Composable
override fun TrioRenderScope.Content(state: ParentState) {
ShowTrio(state.trioStack.last())
}The UI simply renders the topmost Trio from the stack.
Saving and Exiting
class CounterViewModel : TrioViewModel {
fun saveAndExit() = viewModelScope.launch {
val success = performSaveRequest()
setState {
copy(
trioStack = trioStack.dropLast(1),
success = success
)
}
}
}This example shows how an asynchronous operation can update both business data and navigation state atomically.
Activity Flow Integration
class ViewModelInitializer
(
val initialState: S,
internal val activityFlow: Flow
,
...
) suspend fun awaitActivity(): ComponentActivity {
return initializer.activityFlow.filterNotNull().first()
}ViewModels can obtain the current Activity via awaitActivity() , enabling navigation that depends on activity context (e.g., launching a new Activity after a network request).
Router System
// In feat.decimal.nav
@Plugin(pluginPoint = RoutersPluginPoint::class)
class DecimalRouters : RouterDeclarations() {
@Parcelize
data class DecimalArgs(val count: Double) : Parcelable
object DecimalScreen : TrioRouter
}Routers are small classes that declare a TrioRouter for each feature module. They are discovered via Dagger/KSP and provide type‑safe navigation.
Using a Router from a ViewModel
class CounterViewModel : TrioViewModel {
fun showDecimal(count: Double) {
val trio = DecimalRouters.DecimalScreen.createTrio(DecimalArgs(count))
props.pushScreen(trio)
}
}Alternatively, a router can start a new Activity:
class CounterViewModel : TrioViewModel {
fun showDecimal(count: Double) = viewModelScope.launch {
val activity = awaitActivity()
val intent = DecimalRouters.DecimalScreen.newIntent(activity, DecimalArgs(count))
activity.startActivity(intent)
}
}Activity Host for a Trio
class TrioActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val trio = intent.parseTrio()
setContent { ShowTrio(trio) }
}
}When a Trio is launched in its own Activity, the Activity extracts the Parcelable Trio from the Intent and displays it.
Result‑Returning Navigation
class DecimalRouters : RouterDeclarations {
data class DecimalResult(val count: Double)
object DecimalScreen : TrioRouter
}
class CounterViewModel : TrioViewModel {
val decimalLauncher = DecimalScreen.createResultLauncher { result ->
setState { copy(count = result.count) }
}
fun showDecimal(count: Double) {
decimalLauncher.startActivityForResult(DecimalArgs(count))
}
}This pattern lets a child screen return data to its parent while keeping all navigation logic inside the ViewModel.
Fragment Interoperability
class LegacyFragment : MavericksFragment {
fun showTrioScreen() {
showFragment(
CounterRouters.CounterScreen.newInteropFragment(SharedCounterViewModelPropsAdapter::class)
)
}
}
class SharedCounterViewModelPropsAdapter : LegacyViewModelPropsAdapter
{
override suspend fun createPropsStateFlow(
legacyViewModelProvider: LegacyViewModelProvider,
navController: NavController
,
scope: CoroutineScope
): StateFlow
{
val sharedCounterViewModel: SharedCounterViewModel = legacyViewModelProvider.getActivityViewModel()
val fragmentClickViewModel: SharedCounterViewModel = legacyViewModelProvider.requireExistingViewModel {
SharedCounterViewModelKeys.fragmentOnlyCounterKey
}
return combine(sharedCounterViewModel.stateFlow, fragmentClickViewModel.stateFlow) { shared, fragment ->
SharedCounterScreenProps(
navController = navController,
sharedClickCount = shared.count,
fragmentClickCount = fragment.count,
increaseSharedCount = { sharedCounterViewModel.increaseCounter() }
)
}.stateIn(scope)
}
}Routers also create “interop” fragments that embed a Trio alongside existing Fragments, allowing shared ViewModel communication.
Conclusion
The article demonstrates how Trio stores navigation in ViewModel state, uses a type‑safe router system, and supports modular, interoperable navigation across Activities and Fragments. The next part of the series will explore how Props enable dynamic communication between pages.
Airbnb Technology Team
Official account of the Airbnb Technology Team, sharing Airbnb's tech innovations and real-world implementations, building a world where home is everywhere through technology.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.