
Restores lifecycle-agnostic navigation and hierarchical scope management for Compose UIs: visibility-aware lifecycles, type-safe navigation, DI-agnostic dependency scoping, and ref-counted coroutine scopes.
Get back the lifecycle-agnostic behavior that Compose was designed for.
A Kotlin Multiplatform navigation and scope management library for Compose, targeting Android, Desktop (JVM), iOS, and WebAssembly.
AndroidX lifecycle components were designed for a stateless world—Views that get destroyed and recreated, Activities that die on rotation, Fragments with complex lifecycle callbacks. The workarounds are familiar:
rememberSaveable, SavedStateHandle, process death restorationCompose doesn't need these restrictions. It's a stateful UI framework where components naturally hold state, survive recomposition, and manage their own lifecycles. GetBack embraces this by providing:
VisibilityScopedView is the cross-platform entry point. It creates a coroutine scope tied to view visibility and wires up a ViewProvider:
// Any platform (Android Activity, Desktop Window, iOS ComposeUIViewController, WasmJs ComposeViewport)
VisibilityScopedView(
scopeFactory = { CoroutineScope(SupervisorJob() + Dispatchers.Main) },
onViewAppear = { scope ->
// Return a View — scope cancels when hidden, restarts when visible
myViewProvider.onViewAppear(scope)
}
).content()Use NavigationStackHost for push/pop navigation and ViewSwitcherHost for tab switching:
// Push/pop navigation
val navigationStack = ModalNavigationStack<Screen>(rootScope = scope)
NavigationStackHost(stack = navigationStack) { entry ->
entry.viewProvider.onViewAppear(entry.scope)
}
// Tab switching with state retention
val tabSwitcher = RetainingScopeViewSwitcher<TabRoute>(scope, defaultKey = TabRoute.Home)
ViewSwitcherHost(switcher = tabSwitcher) { route, routeScope ->
createTabProvider(route, routeScope)
}This architecture provides an alternative to AndroidX Navigation that aligns more closely with how Compose applications think about state and scoping.
This architecture introduces four distinct scope layers, each managed by different concerns. The model is decoupled from AndroidX lifecycle concepts—scopes are based on navigation hierarchy and visibility rather than Activity/Fragment lifecycle states.
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. RENDER SCOPE (UI Framework) │
│ Managed by: Compose (or any UI framework) │
│ Lifecycle: Composition existence │
│ Survives: recomposition │
│ Cancels: composable leaves the UI tree │
│ Use for: UI state, remember{}, animations, recomposition │
├─────────────────────────────────────────────────────────────────────────┤
│ 2. VIEW SCOPE (Visibility) │
│ Managed by: onViewAppear / View │
│ Lifecycle: View visibility in the hierarchy │
│ Survives: recomposition │
│ Cancels: view hidden (pushed over) or removed │
│ Use for: visibility-dependent work, analytics, animations │
├─────────────────────────────────────────────────────────────────────────┤
│ 3. VIEW PROVIDER SCOPE (Navigation Entry) │
│ Managed by: NavigationStack, ViewSwitcher │
│ Lifecycle: Entry existence in navigation/switcher hierarchy │
│ Survives: view hidden (pushed over), configuration changes │
│ Cancels: entry popped from stack or removed from switcher │
│ Use for: ViewModels, screen-level state, navigation-scoped work │
├─────────────────────────────────────────────────────────────────────────┤
│ 4. MANAGED COROUTINE SCOPE (Dependency Scopes) │
│ Managed by: ManagedCoroutineScope (ref-counted) │
│ Lifecycle: Reference-counted across consumers │
│ Survives: individual ViewProvider lifecycles │
│ Cancels: when ref count reaches zero AND all children complete │
│ Use for: shared repositories, background sync, cross-view state │
└─────────────────────────────────────────────────────────────────────────┘
The UI framework's native composition lifecycle. In Compose, this is the remember{} and recomposition model. This layer is inherent to your UI framework choice—we don't manage it, we build on top of it.
@Composable
fun HomeContent() {
// Compose manages this state - survives recomposition,
// but destroyed when composable leaves the tree
val scrollState = rememberLazyListState()
}This layer is inherent to the UI framework—we build on top of it rather than managing it.
The CoroutineScope passed to onViewAppear is tied to visibility. This scope cancels when the view is hidden (e.g., another screen is pushed on top):
class HomeViewProvider : ViewProvider {
override fun onViewAppear(scope: CoroutineScope): View {
scope.launch {
// Only runs while this view is visible
// Cancels when another screen is pushed on top
analyticsTracker.trackScreenView("Home")
}
return View { HomeContent() }
}
}Use this scope for work that should only happen while the user can see the screen.
A ViewProvider exists as long as its navigation entry exists. It survives being hidden—when another screen is pushed on top, the ViewProvider remains alive:
class HomeViewProvider(
scope: ManagedCoroutineScope, // Survives when hidden
private val viewModel: HomeViewModel, // Lives at ViewProvider scope
) : Screen {
// NavigationStack owned by this ViewProvider
private val navigationStack = ModalNavigationStack<Screen>(rootScope = scope)
override fun onViewAppear(scope: CoroutineScope): View {
// scope cancels when hidden, but viewModel and navigationStack persist
return View { HomeContent(viewModel, navigationStack) }
}
}The ViewProvider scope is managed by navigation components (NavigationStack, ViewSwitcher). When an entry is popped or removed, its ViewProvider scope cancels.
ManagedCoroutineScope provides ref-counted coroutine scopes that can be shared across multiple ViewProviders. When you create a child scope, the parent tracks it:
// Parent scope (e.g., tab scope)
val tabScope: ManagedCoroutineScope = ...
// Child scopes created from parent (one per ViewProvider)
val homeViewScope = tabScope.create("HomeView")
val detailsViewScope = tabScope.create("DetailsView")
// When tabScope cancels:
// 1. It waits for homeViewScope and detailsViewScope to complete
// 2. Child scopes complete their in-flight work
// 3. Only then does tabScope fully cancelThis layer has no AndroidX dependencies. Dependencies at this layer are available for garbage collection when all their child scopes complete and release their references.
Tab Scope (ManagedCoroutineScope) ← Layer 4: Managed Coroutine
│
├── HomeViewProvider ← Layer 3: ViewProvider
│ └── HomeView ← Layer 2: View (visibility)
│ └── HomeContent (Composable) ← Layer 1: Render
│
└── DetailsViewProvider ← Layer 3: ViewProvider
└── DetailsView ← Layer 2: View (visibility)
└── DetailsContent (Composable) ← Layer 1: Render
When navigating from Home → Details:
When popping back to Home:
AndroidX Navigation conflates these concepts:
Activity CREATED → STARTED → RESUMED → PAUSED → STOPPED → DESTROYED
↓
NavBackStackEntry follows Activity lifecycle
(no visibility-based scope - covered screens still RESUMED)
↓
ViewModel.onCleared() when popped (immediate, no ref-counting)
This Architecture separates them:
Render Scope: UI framework composition
View Scope: Visibility-based, cancels when hidden
ViewProvider Scope: Navigation entry existence, survives being hidden
Coroutine Scope: Ref-counted dependency scopes
For migration purposes or when AndroidX lifecycle integration is needed, the lifecycle module provides LifecycleViewScopeProvider. This wraps navigation destinations with ViewModelStoreOwner support, enabling use of AndroidX ViewModels, SavedStateHandle, and lifecycle-aware components within the navigation stack:
val viewScopeProviderFactory = ViewScopeProvider.Factory { name, onViewAppear, scope ->
LifecycleViewScopeProvider(
name = name,
onViewAppear = onViewAppear,
savedState = null, // or restored state
scope = scope
)
}This is optional—the core architecture works without AndroidX lifecycle dependencies.
| Aspect | This Architecture | AndroidX Navigation |
|---|---|---|
| Scope trigger | Visibility in composition | Android lifecycle events |
| Navigation arguments | Type-safe factories with any data | Bundle/SavedStateHandle with type casting |
| Route definition | Kotlin interfaces/objects | String routes or KClass serialization |
| Back stack persistence | Not automatic (intentional) | SavedStateHandle survives process death |
| Nested navigation | Natural via scope hierarchy | Requires nested NavHosts with coordination |
Navigation factories receive strongly-typed dependencies—pass full objects, not just IDs:
// Type-safe at compile time - no string routes or Bundle serialization
navigationStack.push(detailsFactory) { entry ->
DetailsComponent.Dependency(
navigationScope = entry,
feedItem = item, // Full object, not just an ID
mediaType = category.mediaType
)
}With AndroidX Navigation, route mismatches or argument type errors are runtime failures.
When a parent scope cancels, all children automatically cancel:
// When Home tab is destroyed, its navigation stack and all pushed screens cancel
ModalNavigationStack(rootScope = homeTabScope)No need for manual cleanup or remembering to cancel coroutines.
Operations respond to actual visibility rather than lifecycle states:
override fun onViewAppear(scope: CoroutineScope): View {
scope.launch {
// Only runs while this specific view is visible
// Cancels when another view is pushed on top
analyticsTracker.trackScreenView("Details")
}
return View { DetailsContent() }
}In AndroidX Navigation, a screen under a modal is still in RESUMED state.
Deep links can construct full navigation stacks with proper dependencies at each level:
// Deep link handler can build the entire stack with real dependencies
suspend fun handleDeepLink(uri: Uri) {
mainViewSwitcher.onSelect(MainViewRoute.LoggedIn)
homeNavigationStack.push(detailsFactory) { entry ->
DetailsComponent.Dependency(
navigationScope = entry,
feedItem = fetchItem(uri.itemId),
mediaType = MediaType.APPS
)
}
}Each screen receives its full dependencies, not just primitive IDs that require re-fetching.
RetainingScopeViewSwitcher preserves complete tab state (scroll position, nested navigation stacks, form data) without complex configuration:
val tabSwitcher = RetainingScopeViewSwitcher<TabRoute>(scope, defaultKey = TabRoute.Home)What's missing: AndroidX Navigation can save and restore the entire navigation back stack across process death via SavedStateHandle.
Why this may not matter:
When this matters:
Developers familiar with AndroidX Navigation's convention-based approach need to understand:
Core library modules target all Compose Multiplatform platforms:
| Platform | Status |
|---|---|
| Android | Supported (minSdk 24) |
| Desktop (JVM) | Supported |
| iOS (arm64, x64, simulator) | Supported |
| WasmJs (browser) | Supported |
Android-specific modules (getbackcompose-lifecycle, getbackcompose-activity) provide optional AndroidX integration for Activity-based apps.
Three complete sample apps demonstrate different dependency injection approaches with identical feature sets (onboarding, tabbed home, detail screens with nested navigation, favorites, profile):
| App | DI Approach | Platforms |
|---|---|---|
examples/simple-app |
Manual constructor injection | Android, Desktop, iOS, WasmJs |
examples/metro-app |
Metro (KMP DI) | Android, Desktop, iOS, WasmJs |
examples/dagger-app |
Dagger 2 | Android |
Run an example:
./gradlew :examples:simple-app:sample:desktop:run
./gradlew :examples:metro-app:sample:desktop:runSee docs/ROADMAP.md for planned features including:
Get back the lifecycle-agnostic behavior that Compose was designed for.
A Kotlin Multiplatform navigation and scope management library for Compose, targeting Android, Desktop (JVM), iOS, and WebAssembly.
AndroidX lifecycle components were designed for a stateless world—Views that get destroyed and recreated, Activities that die on rotation, Fragments with complex lifecycle callbacks. The workarounds are familiar:
rememberSaveable, SavedStateHandle, process death restorationCompose doesn't need these restrictions. It's a stateful UI framework where components naturally hold state, survive recomposition, and manage their own lifecycles. GetBack embraces this by providing:
VisibilityScopedView is the cross-platform entry point. It creates a coroutine scope tied to view visibility and wires up a ViewProvider:
// Any platform (Android Activity, Desktop Window, iOS ComposeUIViewController, WasmJs ComposeViewport)
VisibilityScopedView(
scopeFactory = { CoroutineScope(SupervisorJob() + Dispatchers.Main) },
onViewAppear = { scope ->
// Return a View — scope cancels when hidden, restarts when visible
myViewProvider.onViewAppear(scope)
}
).content()Use NavigationStackHost for push/pop navigation and ViewSwitcherHost for tab switching:
// Push/pop navigation
val navigationStack = ModalNavigationStack<Screen>(rootScope = scope)
NavigationStackHost(stack = navigationStack) { entry ->
entry.viewProvider.onViewAppear(entry.scope)
}
// Tab switching with state retention
val tabSwitcher = RetainingScopeViewSwitcher<TabRoute>(scope, defaultKey = TabRoute.Home)
ViewSwitcherHost(switcher = tabSwitcher) { route, routeScope ->
createTabProvider(route, routeScope)
}This architecture provides an alternative to AndroidX Navigation that aligns more closely with how Compose applications think about state and scoping.
This architecture introduces four distinct scope layers, each managed by different concerns. The model is decoupled from AndroidX lifecycle concepts—scopes are based on navigation hierarchy and visibility rather than Activity/Fragment lifecycle states.
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. RENDER SCOPE (UI Framework) │
│ Managed by: Compose (or any UI framework) │
│ Lifecycle: Composition existence │
│ Survives: recomposition │
│ Cancels: composable leaves the UI tree │
│ Use for: UI state, remember{}, animations, recomposition │
├─────────────────────────────────────────────────────────────────────────┤
│ 2. VIEW SCOPE (Visibility) │
│ Managed by: onViewAppear / View │
│ Lifecycle: View visibility in the hierarchy │
│ Survives: recomposition │
│ Cancels: view hidden (pushed over) or removed │
│ Use for: visibility-dependent work, analytics, animations │
├─────────────────────────────────────────────────────────────────────────┤
│ 3. VIEW PROVIDER SCOPE (Navigation Entry) │
│ Managed by: NavigationStack, ViewSwitcher │
│ Lifecycle: Entry existence in navigation/switcher hierarchy │
│ Survives: view hidden (pushed over), configuration changes │
│ Cancels: entry popped from stack or removed from switcher │
│ Use for: ViewModels, screen-level state, navigation-scoped work │
├─────────────────────────────────────────────────────────────────────────┤
│ 4. MANAGED COROUTINE SCOPE (Dependency Scopes) │
│ Managed by: ManagedCoroutineScope (ref-counted) │
│ Lifecycle: Reference-counted across consumers │
│ Survives: individual ViewProvider lifecycles │
│ Cancels: when ref count reaches zero AND all children complete │
│ Use for: shared repositories, background sync, cross-view state │
└─────────────────────────────────────────────────────────────────────────┘
The UI framework's native composition lifecycle. In Compose, this is the remember{} and recomposition model. This layer is inherent to your UI framework choice—we don't manage it, we build on top of it.
@Composable
fun HomeContent() {
// Compose manages this state - survives recomposition,
// but destroyed when composable leaves the tree
val scrollState = rememberLazyListState()
}This layer is inherent to the UI framework—we build on top of it rather than managing it.
The CoroutineScope passed to onViewAppear is tied to visibility. This scope cancels when the view is hidden (e.g., another screen is pushed on top):
class HomeViewProvider : ViewProvider {
override fun onViewAppear(scope: CoroutineScope): View {
scope.launch {
// Only runs while this view is visible
// Cancels when another screen is pushed on top
analyticsTracker.trackScreenView("Home")
}
return View { HomeContent() }
}
}Use this scope for work that should only happen while the user can see the screen.
A ViewProvider exists as long as its navigation entry exists. It survives being hidden—when another screen is pushed on top, the ViewProvider remains alive:
class HomeViewProvider(
scope: ManagedCoroutineScope, // Survives when hidden
private val viewModel: HomeViewModel, // Lives at ViewProvider scope
) : Screen {
// NavigationStack owned by this ViewProvider
private val navigationStack = ModalNavigationStack<Screen>(rootScope = scope)
override fun onViewAppear(scope: CoroutineScope): View {
// scope cancels when hidden, but viewModel and navigationStack persist
return View { HomeContent(viewModel, navigationStack) }
}
}The ViewProvider scope is managed by navigation components (NavigationStack, ViewSwitcher). When an entry is popped or removed, its ViewProvider scope cancels.
ManagedCoroutineScope provides ref-counted coroutine scopes that can be shared across multiple ViewProviders. When you create a child scope, the parent tracks it:
// Parent scope (e.g., tab scope)
val tabScope: ManagedCoroutineScope = ...
// Child scopes created from parent (one per ViewProvider)
val homeViewScope = tabScope.create("HomeView")
val detailsViewScope = tabScope.create("DetailsView")
// When tabScope cancels:
// 1. It waits for homeViewScope and detailsViewScope to complete
// 2. Child scopes complete their in-flight work
// 3. Only then does tabScope fully cancelThis layer has no AndroidX dependencies. Dependencies at this layer are available for garbage collection when all their child scopes complete and release their references.
Tab Scope (ManagedCoroutineScope) ← Layer 4: Managed Coroutine
│
├── HomeViewProvider ← Layer 3: ViewProvider
│ └── HomeView ← Layer 2: View (visibility)
│ └── HomeContent (Composable) ← Layer 1: Render
│
└── DetailsViewProvider ← Layer 3: ViewProvider
└── DetailsView ← Layer 2: View (visibility)
└── DetailsContent (Composable) ← Layer 1: Render
When navigating from Home → Details:
When popping back to Home:
AndroidX Navigation conflates these concepts:
Activity CREATED → STARTED → RESUMED → PAUSED → STOPPED → DESTROYED
↓
NavBackStackEntry follows Activity lifecycle
(no visibility-based scope - covered screens still RESUMED)
↓
ViewModel.onCleared() when popped (immediate, no ref-counting)
This Architecture separates them:
Render Scope: UI framework composition
View Scope: Visibility-based, cancels when hidden
ViewProvider Scope: Navigation entry existence, survives being hidden
Coroutine Scope: Ref-counted dependency scopes
For migration purposes or when AndroidX lifecycle integration is needed, the lifecycle module provides LifecycleViewScopeProvider. This wraps navigation destinations with ViewModelStoreOwner support, enabling use of AndroidX ViewModels, SavedStateHandle, and lifecycle-aware components within the navigation stack:
val viewScopeProviderFactory = ViewScopeProvider.Factory { name, onViewAppear, scope ->
LifecycleViewScopeProvider(
name = name,
onViewAppear = onViewAppear,
savedState = null, // or restored state
scope = scope
)
}This is optional—the core architecture works without AndroidX lifecycle dependencies.
| Aspect | This Architecture | AndroidX Navigation |
|---|---|---|
| Scope trigger | Visibility in composition | Android lifecycle events |
| Navigation arguments | Type-safe factories with any data | Bundle/SavedStateHandle with type casting |
| Route definition | Kotlin interfaces/objects | String routes or KClass serialization |
| Back stack persistence | Not automatic (intentional) | SavedStateHandle survives process death |
| Nested navigation | Natural via scope hierarchy | Requires nested NavHosts with coordination |
Navigation factories receive strongly-typed dependencies—pass full objects, not just IDs:
// Type-safe at compile time - no string routes or Bundle serialization
navigationStack.push(detailsFactory) { entry ->
DetailsComponent.Dependency(
navigationScope = entry,
feedItem = item, // Full object, not just an ID
mediaType = category.mediaType
)
}With AndroidX Navigation, route mismatches or argument type errors are runtime failures.
When a parent scope cancels, all children automatically cancel:
// When Home tab is destroyed, its navigation stack and all pushed screens cancel
ModalNavigationStack(rootScope = homeTabScope)No need for manual cleanup or remembering to cancel coroutines.
Operations respond to actual visibility rather than lifecycle states:
override fun onViewAppear(scope: CoroutineScope): View {
scope.launch {
// Only runs while this specific view is visible
// Cancels when another view is pushed on top
analyticsTracker.trackScreenView("Details")
}
return View { DetailsContent() }
}In AndroidX Navigation, a screen under a modal is still in RESUMED state.
Deep links can construct full navigation stacks with proper dependencies at each level:
// Deep link handler can build the entire stack with real dependencies
suspend fun handleDeepLink(uri: Uri) {
mainViewSwitcher.onSelect(MainViewRoute.LoggedIn)
homeNavigationStack.push(detailsFactory) { entry ->
DetailsComponent.Dependency(
navigationScope = entry,
feedItem = fetchItem(uri.itemId),
mediaType = MediaType.APPS
)
}
}Each screen receives its full dependencies, not just primitive IDs that require re-fetching.
RetainingScopeViewSwitcher preserves complete tab state (scroll position, nested navigation stacks, form data) without complex configuration:
val tabSwitcher = RetainingScopeViewSwitcher<TabRoute>(scope, defaultKey = TabRoute.Home)What's missing: AndroidX Navigation can save and restore the entire navigation back stack across process death via SavedStateHandle.
Why this may not matter:
When this matters:
Developers familiar with AndroidX Navigation's convention-based approach need to understand:
Core library modules target all Compose Multiplatform platforms:
| Platform | Status |
|---|---|
| Android | Supported (minSdk 24) |
| Desktop (JVM) | Supported |
| iOS (arm64, x64, simulator) | Supported |
| WasmJs (browser) | Supported |
Android-specific modules (getbackcompose-lifecycle, getbackcompose-activity) provide optional AndroidX integration for Activity-based apps.
Three complete sample apps demonstrate different dependency injection approaches with identical feature sets (onboarding, tabbed home, detail screens with nested navigation, favorites, profile):
| App | DI Approach | Platforms |
|---|---|---|
examples/simple-app |
Manual constructor injection | Android, Desktop, iOS, WasmJs |
examples/metro-app |
Metro (KMP DI) | Android, Desktop, iOS, WasmJs |
examples/dagger-app |
Dagger 2 | Android |
Run an example:
./gradlew :examples:simple-app:sample:desktop:run
./gradlew :examples:metro-app:sample:desktop:runSee docs/ROADMAP.md for planned features including: