| name | Navigation & Adaptive UI |
| description | Best practices for Type-Safe Navigation (Nav 3) and Adaptive Layouts in Upnext. |
Navigation & Adaptive UI Skill
This skill encapsulates the critical patterns for implementing adaptive layouts and navigation in the Upnext application. It specifically addresses "Gotchas" related to ListDetailPaneScaffold and Compose Navigation 3.
📱 Adaptive Layouts (List-Detail)
Upnext uses NavigableListDetailPaneScaffold to support phones, tablets, and foldables.
[!NOTE]
Adaptive UI utilities, such as WindowSizeClassUtil and the @ReferenceDevices Preview annotation, are now centralized in the :core:designsystem module to prevent circular dependencies. Ensure feature modules depend on :core:designsystem to use them.
⚠️ Critical Rule: Navigator Mismatch
NEVER use rememberSupportingPaneScaffoldNavigator with NavigableListDetailPaneScaffold. This causes back navigation conflicts and UI flickering.
✅ Correct Usage:
val listDetailNavigator = rememberListDetailPaneScaffoldNavigator<Any>()
NavigableListDetailPaneScaffold(
navigator = listDetailNavigator,
defaultBackBehavior = BackNavigationBehavior.PopUntilScaffoldValueChange,
)
Back Navigation
The scaffold handles back navigation between panes (Detail -> List) automatically if defaultBackBehavior is set correctly.
- Do NOT manually intercept back presses for pane navigation using
BackHandler if the scaffold can handle it.
- Do use
BackHandler only for top-level destination changes (e.g., Explore -> Dashboard).
Resetting Pane State (OAuth / Deep Links)
When returning to the list view from an external intent (like an OAuth browser callback), the navigation graphs may become out of sync, leading to IllegalArgumentException: Destination with route cannot be found.
- Fix: Route the detail pane to the
EmptyDetail destination to gracefully clear the view. The scaffold will interpret the empty detail state and automatically jump back to the list cleanly logic!
mainNavController.navigate(Destinations.EmptyDetail) {
popUpTo(Destinations.EmptyDetail) { inclusive = true }
}
🧭 Type-Safe Navigation (Nav 3)
We use the official Compose Navigation 3 with Kotlin Serialization.
1. Defining Routes
Routes are defined in Destinations.kt as @Serializable classes or objects.
@Serializable
object Dashboard : Destinations
@Serializable
data class ShowDetail(
val showId: String?,
val showTitle: String?
) : Destinations
2. Navigating (State-driven Back Stack)
Under Navigation 3, the back stack is developer-owned, typically managed via a SnapshotStateList<Any>:
backStack.add(Destinations.ShowDetail(showId = "123", showTitle = "Arcane"))
3. Extracting Arguments (Pattern Matching)
Because the back stack keys themselves are the typed route instances, extracting arguments (e.g., for a TopBar title) is simple pattern matching:
✅ Correct Pattern:
val currentKey = backStack.lastOrNull()
val showTitle = when (currentKey) {
is Destinations.ShowDetail -> currentKey.showTitle
is Destinations.PersonDetail -> currentKey.personName
else -> null
}
4. ViewModel Arguments (Hilt Assisted Injection)
In Navigation 3, the runtime does not automatically bundle and inject the route arguments into the default SavedStateHandle. Calling toRoute<T>() in the ViewModel constructor will crash with a deserialization failure.
✅ Correct Pattern:
Use @AssistedInject and @AssistedFactory in your ViewModels, and resolve them using the creationCallback on hiltViewModel:
class EpisodeDetailViewModel @AssistedInject constructor(
@Assisted val route: Destinations.EpisodeDetail,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(route: Destinations.EpisodeDetail): EpisodeDetailViewModel
}
}
val viewModel: EpisodeDetailViewModel = hiltViewModel(
creationCallback = { factory: EpisodeDetailViewModel.Factory ->
factory.create(episodeDetailArg)
}
)
🔧 Common Pitfalls
"Title Unknown"
- Cause: Hardcoding titles or failing to parse args from the current route.
- Fix: Always attempt to extract the specific route args (like
ShowDetail) when the destination matches.
Infinite Navigation Loops
- Cause: Updating state (like
isDetailFlowActive) inside a LaunchedEffect that observes that same state without proper checks.
- Fix: Ensure state updates only happen when the target state differs from current state.
Lazy Grid Null Pointer Exceptions (Stream Drops)
- Cause: Using aggressive non-null assertions (
!!) on LazyRow or LazyVerticalGrid items(list!!) when the underlying data stream is briefly interrupted (e.g. during a Trakt logout where the Database momentarily clears its snapshot before the Viewmodel drops the authorization state).
- Fix: Always use safe-unwrapping
.orEmpty() on Compose list arguments, like items(list.orEmpty()), ensuring Compose safely renders zero items instead of instantly crashing the active Activity during auth transitions.
Bottom Bar Visibility
- Rule: The
NavigationSuiteScaffold (Bottom Bar / Rail) should wrap the content.
- Logic: The list-detail scaffold lives inside the navigation suite.