Jetpack Navigation Component
Navigation Component provides a declarative way to manage in-app navigation. The NavGraph defines all destinations and connections; NavController handles the back stack at runtime.
NavGraph
<!-- res/navigation/main_graph.xml -->
<navigation
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/main_graph"
app:startDestination="@id/homeFragment">
<fragment
android:id="@+id/homeFragment"
android:name="com.example.HomeFragment">
<action
android:id="@+id/action_home_to_detail"
app:destination="@id/detailFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left" />
</fragment>
<fragment
android:id="@+id/detailFragment"
android:name="com.example.DetailFragment">
<!-- Safe Args argument declaration -->
<argument
android:name="articleId"
app:argType="string" />
<argument
android:name="showComments"
app:argType="boolean"
android:defaultValue="false" />
</fragment>
<!-- Deep link destination -->
<fragment
android:id="@+id/profileFragment"
android:name="com.example.ProfileFragment">
<deepLink
android:id="@+id/deeplink_profile"
app:uri="https://example.com/profile/{userId}"
app:action="android.intent.action.VIEW" />
<argument
android:name="userId"
app:argType="string" />
</fragment>
<!-- Nested graph for a flow (checkout, onboarding) -->
<include app:graph="@navigation/checkout_graph" />
</navigation>
NavController and Safe Args
// In Activity — host the NavController
class MainActivity : AppCompatActivity() {
private val navController by lazy {
(supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment)
.navController
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Wire up the ActionBar with back navigation
setupActionBarWithNavController(navController)
}
override fun onSupportNavigateUp(): Boolean =
navController.navigateUp() || super.onSupportNavigateUp()
}
// In Fragment — navigate with Safe Args (generated classes)
class HomeFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter.onArticleClick = { articleId ->
// Safe Args generates type-safe directions
val action = HomeFragmentDirections.actionHomeToDetail(
articleId = articleId,
showComments = true
)
findNavController().navigate(action)
}
}
}
// In destination Fragment — receive args via Safe Args
class DetailFragment : Fragment() {
// Safe Args generates Args class
private val args: DetailFragmentArgs by navArgs()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val articleId = args.articleId
val showComments = args.showComments
viewModel.load(articleId)
}
}
Multi-Back-Stack (Bottom Navigation)
// Bottom navigation with multiple back stacks — each tab remembers state
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding.bottomNav.setupWithNavController(
navController,
// Each item ID matches a nested nav graph ID
)
}
}
// Programmatic multi-stack navigation
binding.bottomNav.setOnItemSelectedListener { item ->
NavigationUI.onNavDestinationSelected(item, navController)
true
}
NavBackStackEntry-Scoped ViewModel
ViewModels can be scoped to a navigation back stack entry, enabling shared state within a navigation sub-graph:
// Share ViewModel between fragments within the checkout graph
class ShippingFragment : Fragment() {
// Scoped to the checkout graph — same instance across all checkout fragments
private val checkoutViewModel: CheckoutViewModel by navGraphViewModels(R.id.checkout_graph) {
defaultViewModelProviderFactory
}
}
class PaymentFragment : Fragment() {
// Same instance as ShippingFragment's checkoutViewModel
private val checkoutViewModel: CheckoutViewModel by navGraphViewModels(R.id.checkout_graph)
}
Paging 3
Paging 3 loads data in pages, handles loading states, and integrates natively with Room and Compose/RecyclerView.
PagingSource
PagingSource loads data from a single source (network-only or Room DAO — not both; for both use RemoteMediator).
class ArticlePagingSource(
private val api: ArticleApi,
private val query: String
) : PagingSource<Int, Article>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
val page = params.key ?: 1
return try {
val response = api.searchArticles(
query = query,
page = page,
pageSize = params.loadSize
)
LoadResult.Page(
data = response.articles.map { it.toDomain() },
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.articles.isEmpty()) null else page + 1
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
// Called when the data is invalidated (e.g., search query changes)
override fun getRefreshKey(state: PagingState<Int, Article>): Int? {
return state.anchorPosition?.let { anchor ->
state.closestPageToPosition(anchor)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchor)?.nextKey?.minus(1)
}
}
}
Repository with Pager
class ArticleRepository @Inject constructor(
private val api: ArticleApi
) {
fun searchArticles(query: String): Flow<PagingData<Article>> = Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 5, // load next page when 5 items from end
enablePlaceholders = false, // no placeholder items in list
initialLoadSize = 40 // load 2 pages on first load
),
pagingSourceFactory = { ArticlePagingSource(api, query) }
).flow
}
ViewModel
@HiltViewModel
class ArticleViewModel @Inject constructor(
private val articleRepository: ArticleRepository
) : ViewModel() {
private val _query = MutableStateFlow("")
val pagingData: Flow<PagingData<Article>> = _query
.debounce(300)
.filter { it.isNotBlank() }
.flatMapLatest { query ->
articleRepository.searchArticles(query)
}
// cachedIn: cache pages in ViewModel scope — survives configuration changes
.cachedIn(viewModelScope)
fun search(query: String) {
_query.value = query
}
}
PagingDataAdapter in RecyclerView
class ArticleAdapter : PagingDataAdapter<Article, ArticleViewHolder>(ArticleDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
val binding = ItemArticleBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ArticleViewHolder(binding)
}
override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
getItem(position)?.let { holder.bind(it) }
}
}
class ArticleDiffCallback : DiffUtil.ItemCallback<Article>() {
override fun areItemsTheSame(old: Article, new: Article) = old.id == new.id
override fun areContentsTheSame(old: Article, new: Article) = old == new
}
// Fragment: collecting and handling load states
class ArticleListFragment : Fragment() {
private val viewModel: ArticleViewModel by viewModels()
private val adapter = ArticleAdapter()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.recyclerView.adapter = adapter.withLoadStateFooter(
footer = LoadStateAdapter { adapter.retry() }
)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Collect paging data
launch {
viewModel.pagingData.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
// Observe load states for showing progress/error
launch {
adapter.loadStateFlow.collect { loadStates ->
val refresh = loadStates.refresh
binding.progressBar.isVisible = refresh is LoadState.Loading
binding.errorGroup.isVisible = refresh is LoadState.Error
if (refresh is LoadState.Error) {
binding.errorText.text = refresh.error.localizedMessage
binding.btnRetry.setOnClickListener { adapter.retry() }
}
// Empty state: not loading and no data
binding.emptyState.isVisible =
refresh is LoadState.NotLoading && adapter.itemCount == 0
}
}
}
}
binding.searchBar.addTextChangedListener { text ->
viewModel.search(text.toString())
}
}
}
LoadStateAdapter for Footer Loading
class LoadStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<LoadStateViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadStateViewHolder {
val binding = ItemLoadStateBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return LoadStateViewHolder(binding, retry)
}
override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) {
holder.bind(loadState)
}
}
class LoadStateViewHolder(
private val binding: ItemLoadStateBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init { binding.btnRetry.setOnClickListener { retry() } }
fun bind(loadState: LoadState) {
binding.progressBar.isVisible = loadState is LoadState.Loading
binding.btnRetry.isVisible = loadState is LoadState.Error
binding.errorText.isVisible = loadState is LoadState.Error
if (loadState is LoadState.Error) {
binding.errorText.text = loadState.error.localizedMessage
}
}
}
Key Takeaways
| Concept | Summary |
|---|---|
| NavGraph | Declares destinations, actions, and arguments in XML |
| Safe Args | Type-safe navigation with compile-time argument checking |
| Deep links | Declare in NavGraph; handle implicit and explicit deep links |
| Multi-back-stack | Each bottom nav tab maintains its own back stack |
navGraphViewModels() | ViewModel shared among all fragments in a sub-graph |
PagingSource | Load from single source; return LoadResult.Page or .Error |
RemoteMediator | Network + Room combo; REFRESH/PREPEND/APPEND lifecycle |
cachedIn() | Cache pages in ViewModel; survives rotation without re-fetching |
collectLatest | Cancel previous collection when new PagingData arrives |
LoadStateAdapter | Footer progress/retry driven by loadStateFlow |