androidengineers.Book a session

Modern Android Architecture Components

Navigation & Paging 3 in Practice

article25 minMedium

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

ConceptSummary
NavGraphDeclares destinations, actions, and arguments in XML
Safe ArgsType-safe navigation with compile-time argument checking
Deep linksDeclare in NavGraph; handle implicit and explicit deep links
Multi-back-stackEach bottom nav tab maintains its own back stack
navGraphViewModels()ViewModel shared among all fragments in a sub-graph
PagingSourceLoad from single source; return LoadResult.Page or .Error
RemoteMediatorNetwork + Room combo; REFRESH/PREPEND/APPEND lifecycle
cachedIn()Cache pages in ViewModel; survives rotation without re-fetching
collectLatestCancel previous collection when new PagingData arrives
LoadStateAdapterFooter progress/retry driven by loadStateFlow

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Navigation & Paging 3 in Practice | Android System Design | Android Engineers