In my app I have 3 screens: QueryScreen, BookListScreen and BookDetailsScreen. Each has a UiState class which is managed by a ViewModel. Navigation between screens is done using NavHost.
The logic behind my app is this:
QueryScreen: I enter a book that I want to search and press search. I call the function
searchBooks()
from the viewModel which fetches a list of books from a server and navigates to BookListScreen.BookListScreen: Shows the list of books fetched and when user clicks on a book, the function
searchBook()
from the viewModel is called which fetches more details about that book and navigates to BookDetailsScreen.BookDetailsScreen: Shows details about the book fetched earlier.
When I navigate from first screen onward, I update the state accordingly and my app behaves as it should. The problem I am facing is for the back navigation. Let's say I am in BookDetails and I go back to BookList. That means I should make val book: Book = null
in BookDetailsUiState.
The same thing for BookList. When I go back to QueryScreen I should set val books: List<Book> = emptyList()
in BookListUiState.
What is the best approach to do this? Let's say I have 20 screens and it will be really tedious having a flag for each screen to keep track whether I am going back or forth. If you can explain me 2 ways of doing this I will be grateful, thank you!
These are my state classes:
data class QueryUiState(
val query: String = "",
val isFetchingBooks: Boolean = false,
val errorMessage: String? = null
)
data class BookListUiState(
val books: List<Book> = emptyList(),
val isFetchingBook: Boolean = false,
val errorMessage: String? = null,
val selectedBook: Book? = null
)
data class BookDetailsUiState(
val book: Book? = null
)
This is how I perform navigation between screens:
object BookshelfDestinations {
const val QUERY_ROUTE = "query"
const val BOOK_LIST_ROUTE = "bookList"
const val BOOK_DETAILS_ROUTE = "bookDetails"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookshelfApp(
appContainer: AppContainer,
navController: NavHostController = rememberNavController(),
modifier: Modifier = Modifier
) {
Scaffold { innerPadding ->
BookshelfNavHost(
appContainer = appContainer,
navController = navController,
modifier = modifier.padding(innerPadding)
)
}
}
@Composable
fun BookshelfNavHost(
appContainer: AppContainer,
navController: NavHostController,
modifier: Modifier = Modifier
) {
val viewModel: AppViewModel =
viewModel(factory = AppViewModel.AppViewModelFactory(appContainer.bookshelfRepository))
NavHost(
navController = navController,
startDestination = BookshelfDestinations.QUERY_ROUTE,
modifier = modifier
) {
composable(route = BookshelfDestinations.QUERY_ROUTE) {
QueryScreen(
viewModel = viewModel,
onSearch = {
navController.navigate(BookshelfDestinations.BOOK_LIST_ROUTE)
}
)
}
composable(route = BookshelfDestinations.BOOK_LIST_ROUTE) {
BookListScreen(
viewModel = viewModel,
onBookClicked = {
navController.navigate(BookshelfDestinations.BOOK_DETAILS_ROUTE)
}
)
}
composable(route = BookshelfDestinations.BOOK_DETAILS_ROUTE) {
BookDetailsScreen(
viewModel = viewModel
)
}
}
}
This is my viewModel class:
class AppViewModel(private val repository: BookshelfRepository) : ViewModel() {
private val _queryUiState = MutableStateFlow(QueryUiState())
val queryUiState: StateFlow<QueryUiState> = _queryUiState.asStateFlow()
private val _bookListUiState = MutableStateFlow(BookListUiState())
val bookListUiState: StateFlow<BookListUiState> = _bookListUiState.asStateFlow()
private val _bookDetailsUiState = MutableStateFlow(BookDetailsUiState())
val bookDetailsUiState: StateFlow<BookDetailsUiState> = _bookDetailsUiState.asStateFlow()
init {
Log.d("AppViewModel", "App view model initialized!")
}
fun onQueryChanged(query: String) {
_queryUiState.value = _queryUiState.value.copy(query = query)
}
fun onBookSelected(book: Book) {
_bookListUiState.value = _bookListUiState.value.copy(selectedBook = book)
searchBook()
}
fun searchBooks() {
viewModelScope.launch {
_queryUiState.value = _queryUiState.value.copy(isFetchingBooks = true)
try {
val result = repository.getBooks(_queryUiState.value.query)
when(result) {
is BookshelfResult.Success -> {
_bookListUiState.value = _bookListUiState.value.copy(books = result.data)
}
is BookshelfResult.Error -> {
_queryUiState.value = _queryUiState.value.copy(errorMessage = result.exception.message)
}
}
} catch (e: Exception) {
_queryUiState.value = _queryUiState.value.copy(errorMessage = e.message)
} finally {
_queryUiState.value = _queryUiState.value.copy(isFetchingBooks = false)
}
}
}
fun searchBook() {
viewModelScope.launch {
_bookListUiState.value = _bookListUiState.value.copy(isFetchingBook = true)
try {
val result = repository.getBook(_bookListUiState.value.selectedBook!!.id)
when(result) {
is BookshelfResult.Success -> {
_bookDetailsUiState.value = _bookDetailsUiState.value.copy(book = result.data)
}
is BookshelfResult.Error -> {
_bookListUiState.value = _bookListUiState.value.copy(errorMessage = result.exception.message)
}
}
} catch (e: Exception) {
_bookListUiState.value = _bookListUiState.value.copy(errorMessage = e.message)
} finally {
_bookListUiState.value = _bookListUiState.value.copy(isFetchingBook = false)
}
}
}
class AppViewModelFactory(private val appRepository: BookshelfRepository) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(AppViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return AppViewModel(appRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
In my app I have 3 screens: QueryScreen, BookListScreen and BookDetailsScreen. Each has a UiState class which is managed by a ViewModel. Navigation between screens is done using NavHost.
The logic behind my app is this:
QueryScreen: I enter a book that I want to search and press search. I call the function
searchBooks()
from the viewModel which fetches a list of books from a server and navigates to BookListScreen.BookListScreen: Shows the list of books fetched and when user clicks on a book, the function
searchBook()
from the viewModel is called which fetches more details about that book and navigates to BookDetailsScreen.BookDetailsScreen: Shows details about the book fetched earlier.
When I navigate from first screen onward, I update the state accordingly and my app behaves as it should. The problem I am facing is for the back navigation. Let's say I am in BookDetails and I go back to BookList. That means I should make val book: Book = null
in BookDetailsUiState.
The same thing for BookList. When I go back to QueryScreen I should set val books: List<Book> = emptyList()
in BookListUiState.
What is the best approach to do this? Let's say I have 20 screens and it will be really tedious having a flag for each screen to keep track whether I am going back or forth. If you can explain me 2 ways of doing this I will be grateful, thank you!
These are my state classes:
data class QueryUiState(
val query: String = "",
val isFetchingBooks: Boolean = false,
val errorMessage: String? = null
)
data class BookListUiState(
val books: List<Book> = emptyList(),
val isFetchingBook: Boolean = false,
val errorMessage: String? = null,
val selectedBook: Book? = null
)
data class BookDetailsUiState(
val book: Book? = null
)
This is how I perform navigation between screens:
object BookshelfDestinations {
const val QUERY_ROUTE = "query"
const val BOOK_LIST_ROUTE = "bookList"
const val BOOK_DETAILS_ROUTE = "bookDetails"
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookshelfApp(
appContainer: AppContainer,
navController: NavHostController = rememberNavController(),
modifier: Modifier = Modifier
) {
Scaffold { innerPadding ->
BookshelfNavHost(
appContainer = appContainer,
navController = navController,
modifier = modifier.padding(innerPadding)
)
}
}
@Composable
fun BookshelfNavHost(
appContainer: AppContainer,
navController: NavHostController,
modifier: Modifier = Modifier
) {
val viewModel: AppViewModel =
viewModel(factory = AppViewModel.AppViewModelFactory(appContainer.bookshelfRepository))
NavHost(
navController = navController,
startDestination = BookshelfDestinations.QUERY_ROUTE,
modifier = modifier
) {
composable(route = BookshelfDestinations.QUERY_ROUTE) {
QueryScreen(
viewModel = viewModel,
onSearch = {
navController.navigate(BookshelfDestinations.BOOK_LIST_ROUTE)
}
)
}
composable(route = BookshelfDestinations.BOOK_LIST_ROUTE) {
BookListScreen(
viewModel = viewModel,
onBookClicked = {
navController.navigate(BookshelfDestinations.BOOK_DETAILS_ROUTE)
}
)
}
composable(route = BookshelfDestinations.BOOK_DETAILS_ROUTE) {
BookDetailsScreen(
viewModel = viewModel
)
}
}
}
This is my viewModel class:
class AppViewModel(private val repository: BookshelfRepository) : ViewModel() {
private val _queryUiState = MutableStateFlow(QueryUiState())
val queryUiState: StateFlow<QueryUiState> = _queryUiState.asStateFlow()
private val _bookListUiState = MutableStateFlow(BookListUiState())
val bookListUiState: StateFlow<BookListUiState> = _bookListUiState.asStateFlow()
private val _bookDetailsUiState = MutableStateFlow(BookDetailsUiState())
val bookDetailsUiState: StateFlow<BookDetailsUiState> = _bookDetailsUiState.asStateFlow()
init {
Log.d("AppViewModel", "App view model initialized!")
}
fun onQueryChanged(query: String) {
_queryUiState.value = _queryUiState.value.copy(query = query)
}
fun onBookSelected(book: Book) {
_bookListUiState.value = _bookListUiState.value.copy(selectedBook = book)
searchBook()
}
fun searchBooks() {
viewModelScope.launch {
_queryUiState.value = _queryUiState.value.copy(isFetchingBooks = true)
try {
val result = repository.getBooks(_queryUiState.value.query)
when(result) {
is BookshelfResult.Success -> {
_bookListUiState.value = _bookListUiState.value.copy(books = result.data)
}
is BookshelfResult.Error -> {
_queryUiState.value = _queryUiState.value.copy(errorMessage = result.exception.message)
}
}
} catch (e: Exception) {
_queryUiState.value = _queryUiState.value.copy(errorMessage = e.message)
} finally {
_queryUiState.value = _queryUiState.value.copy(isFetchingBooks = false)
}
}
}
fun searchBook() {
viewModelScope.launch {
_bookListUiState.value = _bookListUiState.value.copy(isFetchingBook = true)
try {
val result = repository.getBook(_bookListUiState.value.selectedBook!!.id)
when(result) {
is BookshelfResult.Success -> {
_bookDetailsUiState.value = _bookDetailsUiState.value.copy(book = result.data)
}
is BookshelfResult.Error -> {
_bookListUiState.value = _bookListUiState.value.copy(errorMessage = result.exception.message)
}
}
} catch (e: Exception) {
_bookListUiState.value = _bookListUiState.value.copy(errorMessage = e.message)
} finally {
_bookListUiState.value = _bookListUiState.value.copy(isFetchingBook = false)
}
}
}
class AppViewModelFactory(private val appRepository: BookshelfRepository) :
ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(AppViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return AppViewModel(appRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
Share
Improve this question
asked 18 hours ago
Andrei RusuAndrei Rusu
153 bronze badges
1 Answer
Reset to default 0I see two possible approaches here.
Option A
You can try to use a BackHandler
to detect back events, and then call a ViewModel function to reset the respective value:
BookDetailsScreen(
viewModel: AppViewModel,
onBack: () -> Unit
) {
BackHandler {
viewModel.resetBook()
onBack()
}
}
And then, in your NavHost
do
composable(route = BookshelfDestinations.BOOK_DETAILS_ROUTE) {
BookDetailsScreen(
viewModel = viewModel,
onBack = {
navController.popBackStack()
}
)
}
Option B (recommended)
What I think is more clean however is to choose a proper scope for your ViewModel. Currently, your ViewModel is outside of the NavHost and thus scoped to the Activity. If you had three smaller ViewModels, you can scope them to destinations in the NavGraph and then they will be reset whenever you leave this destination.
You can try code like this:
QueryScreen(
viewModel: QueryViewModel = viewModel(),
onSearch = () -> Unit
) {
//...
}
BookListScreen(
viewModel: BookListScreen = viewModel(),
onBookClicked: () -> Unit
) {
//...
}
BookDetailsScreen(
viewModel: BookDetailsViewModel = viewModel(),
) {
//...
}
The viewModel
function is a convenience function included in the following dependency:
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
It automatically generates a ViewModel instance without needing a manual factory class.
Then, you can pass the needed data, like a book ID, from one destination to the next using navigation arguments as described in the official documentation. Also have a look at the navigation documentation.