I am building a simple app following Mitch Tabian's youtube tutorial about Jetpack Compose.
In the State Hoisting video, he extracts the code for the search TextField into a separate Composable. When I do so, my textField doesn't update the value and I can't find what I am doing wrong.
SearchAppBar Composable
#Composable
fun SearchAppBar(
query: String,
onQueryChanged: (String) -> Unit,
onExecuteSearch: () -> Unit,
selectedCategory: FoodCategory?,
onSelectedCategoryChanged: (String) -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth(),
color = Color.White,
elevation = 4.dp
) {
Column {
Row(modifier = Modifier.fillMaxWidth()) {
val focusManager = LocalFocusManager.current
OutlinedTextField(
value = query,
onValueChange = { newValue -> onQueryChanged(newValue) },
modifier = Modifier
.background(color = MaterialTheme.colors.surface)
.fillMaxWidth()
.padding(8.dp),
label = {
Text(text = "Search")
},
...
Fragment
class RecipeListFragment : Fragment() {
private val viewModel: RecipeListViewModel by viewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return ComposeView(requireContext()).apply {
setContent {
val recipes = viewModel.recipes.value
val query = viewModel.query.value
val selectedCategory = viewModel.selectedCategory.value
Column {
SearchAppBar(
query = query,
onQueryChanged = { viewModel.onQueryChanged(query) },
onExecuteSearch = { viewModel::newSearch },
selectedCategory = selectedCategory,
onSelectedCategoryChanged = { viewModel::onSelectedCategoryChanged })
LazyColumn {
itemsIndexed(items = recipes) { index, recipe ->
RecipeCard(recipe = recipe, onClick = { })
}
}
}
}
}
}
}
ViewModel
class RecipeListViewModel #Inject constructor(private val repository: RecipeRepository, #Named("auth_token") private val token: String) : ViewModel() {
val recipes: MutableState<List<Recipe>> = mutableStateOf(listOf())
val query = mutableStateOf("")
val selectedCategory: MutableState<FoodCategory?> = mutableStateOf(null)
init {
newSearch()
}
fun onQueryChanged(query: String) {
this.query.value = query
}
fun newSearch() {
viewModelScope.launch {
recipes.value = repository.search(token = token, page = 1, query = query.value)
}
}
fun onSelectedCategoryChanged(category: String) {
val newCategory = getFoodCategory(category)
selectedCategory.value = newCategory
onQueryChanged(category)
}
}
The following are no longer states that are observed.
val recipes = viewModel.recipes.value
val query = viewModel.query.value
val selectedCategory = viewModel.selectedCategory.value
Delay the .value call or use the var by viewModel.recipes or use restructuring val (recipes, _) = viewModel.recipes
Related
I just learned Jetpack Compose and building a simple login screen with retrofit to connect with the API.
I'm able to navigate from login screen to home screen. But I'm wondering if I'm doing it right.
Here is my login screen composable
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun InsertNumberScreen(
modifier: Modifier = Modifier,
navHostController: NavHostController,
viewModel: LoginViewModel = viewModel(factory = LoginViewModel.provideFactory(
navHostController = navHostController,
owner = LocalSavedStateRegistryOwner.current
)),
) {
var phoneNumber by remember {
mutableStateOf("")
}
var isActive by remember {
mutableStateOf(false)
}
val modalBottomSheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val coroutine = rememberCoroutineScope()
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetContent = {
BottomSheetLoginContent(phoneNumber){
//Here I call login function inside viewModel
viewModel.login(phoneNumber)
}
},
sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
) {
Column {
TopAppBarCustom(text = "")
LoginText(modifier = modifier.padding(16.dp))
Row(modifier = modifier.padding(16.dp)) {
Prefix()
PhoneNumber(
shape = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp),
value = phoneNumber,
onValueChange = {
isActive = it.length >= 10
phoneNumber = it
})
}
Spacer(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
)
BottomContainer(isEnabled = isActive) {
coroutine.launch {
if (modalBottomSheetState.isVisible) {
modalBottomSheetState.animateTo(ModalBottomSheetValue.Hidden)
} else {
modalBottomSheetState.animateTo(ModalBottomSheetValue.Expanded)
}
}
}
}
}
}
Here is my ViewModel
class LoginViewModel(val navHostController: NavHostController) : ViewModel() {
var result by mutableStateOf(Data(0, "", Message("", "")))
fun login(phone: String) {
val call: Call<Data> = Network.NetworkInterface.login(phone)
call.enqueue(
object : Callback<Data> {
override fun onResponse(call: Call<Data>, response: Response<Data>) {
if (response.code() == 400) {
val error =
Gson().fromJson(response.errorBody()!!.charStream(), Data::class.java)
result = error
navHostController.navigate("login")
} else {
result = response.body()!!
navHostController.navigate("home")
}
}
override fun onFailure(call: Call<Data>, t: Throwable) {
Log.d("Data Login", t.message.toString())
}
}
)
}
companion object {
fun provideFactory(
navHostController: NavHostController,
owner: SavedStateRegistryOwner,
defaultArgs: Bundle? = null,
): AbstractSavedStateViewModelFactory =
object : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
#Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle
): T {
return LoginViewModel(navHostController) as T
}
}
}
}
In my viewModel class, it has a constructor NavHostController. And then, in the login method, I call navHostController.navigate() to navigate to home screen if the login is success.
The question is, is it okay to call navHostController.navigate() directly inside the viewModel? Because I follow codelabs from Google and the navigation is handled in the sort of NavHostBootstrap composable (Something like this)
#Composable
fun RallyNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
){
NavHost(navController = navController, startDestination = Overview.route, modifier = modifier){
composable(Overview.route){
OverviewScreen(
onClickSeeAllAccounts = {
navController.navigateSingleTopTo(Accounts.route)
},
onClickSeeAllBills = {
navController.navigateSingleTopTo(Bills.route)
},
onAccountClick = {
Log.d("Account Clicked", it)
navController.navigateToSingleAccount(it)
}
)
}
}
ViewModel does not preserve state during configuration changes, e.g., leaving and returning to the app when switching between background apps.
#HiltViewModel
class MainViewModel #Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
{
var title by mutableStateOf("")
internal set
var showMenu by mutableStateOf(false)
internal set
var tosVisible by mutableStateOf(false)
internal set
}
The menu:
Currently: It survives the rotation configuration change, the menu remains open if opened by clicking on the three ... dots there. But, changing app, i.e. leaving the app and going into another app. Then returning, does not preserve the state as expected. What am I possibly doing wrong here?
In MainActivity:
val mainViewModel by viewModels<MainViewModel>()
Main(mainViewModel) // Passing it here
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun Main(viewModel: MainViewModel = viewModel()) {
val context = LocalContext.current
val navController = rememberNavController()
EDIT: Modified my ViewModel to this, Makes no difference.
#HiltViewModel
class MainViewModel #Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
{
var title by mutableStateOf("")
internal set
var showMenu by mutableStateOf(savedStateHandle["MenuOpenState"] ?: false)
internal set
var tosVisible by mutableStateOf(savedStateHandle["AboutDialogState"] ?: false)
internal set
fun displayAboutDialog(){
savedStateHandle["AboutDialogState"] = tosVisible;
}
fun openMainMenu(){
savedStateHandle["MenuOpenState"] = showMenu;
}
}
Full code of the MainActivity:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun Main(viewModel: MainViewModel) {
val context = LocalContext.current
val navController = rememberNavController()
//val scope = rememberCoroutineScope()
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(
decayAnimationSpec,
rememberTopAppBarScrollState()
)
LaunchedEffect(navController){
navController.currentBackStackEntryFlow.collect{backStackEntry ->
Log.d("App", backStackEntry.destination.route.toString())
viewModel.title = getTitleByRoute(context, backStackEntry.destination.route);
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
viewModel.title,
//color = Color(0xFF1877F2),
style = MaterialTheme.typography.headlineSmall,
)
},
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
actions = {
IconButton(
onClick = {
viewModel.showMenu = !viewModel.showMenu
}) {
Icon(imageVector = Icons.Outlined.MoreVert, contentDescription = "")
MaterialTheme(shapes = MaterialTheme.shapes.copy(extraSmall = RoundedCornerShape(20.dp))) {
IconButton(
onClick = { viewModel.showMenu = !viewModel.showMenu }) {
Icon(imageVector = Icons.Outlined.MoreVert, contentDescription = "")
DropdownMenu(
expanded = viewModel.showMenu,
onDismissRequest = { viewModel.showMenu = false },
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.padding(0.dp),
properties = PopupProperties(focusable = true)
) {
DropdownMenuItem(text = { Text("Sign out", fontSize = 16.sp) }, onClick = { viewModel.showMenu = false })
DropdownMenuItem(text = { Text("Settings", fontSize = 16.sp) }, onClick = { viewModel.showMenu = false })
Divider(color = Color.LightGray, thickness = 1.dp)
DropdownMenuItem(text = { Text("About", fontSize = 16.sp) },
onClick = {
viewModel.showMenu = true
viewModel.tosVisible = true
})
}
}
}
}
},
scrollBehavior = scrollBehavior
) },
bottomBar = { BottomAppBar(navHostController = navController) }
) { innerPadding ->
Box(modifier = Modifier.padding(PaddingValues(0.dp, innerPadding.calculateTopPadding(), 0.dp, innerPadding.calculateBottomPadding()))) {
BottomNavigationGraph(navController = navController)
}
}
}
Solved with this:
#HiltViewModel
class MainViewModel #Inject constructor(
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
{
companion object {
const val UI_MENU_STATE = "ui.menu.state"
}
init {
savedStateHandle.get<Boolean>(UI_MENU_STATE)?.let {
m -> onMenuStateChange(m);
}
}
private var m_title by mutableStateOf("")
private var displayMenu by mutableStateOf( false)
var tosVisible by mutableStateOf( false)
internal set
fun updateTitleState(title: String){
m_title = title;
}
fun onMenuStateChange(open: Boolean){
Log.d("App", open.toString());
displayMenu = open;
savedStateHandle.set<Boolean>(UI_MENU_STATE, displayMenu);
}
fun isMenuOpen(): Boolean {
return displayMenu;
}
fun getTitle(): String { return m_title; }
}
Maybe you are trying to access another instance of the view model or creating a new instance of the view model upon re-opening the app. The code should work fine if you are accessing the same instance.
Edit:
Seems like you were in fact reinitializing the ViewModel. You are creating a new instance of ViewModel by putting the ViewModel inside the constructor because when you re-open the app the composable will be created again, thus creating a new ViewModel instance. What I will suggest you do is initialise the ViewModel inside composable like this maybe:
fun Main(){
val viewModel = ViewModelProvider(this#MainActivity)[MainViewModel::class.java]
}
I have top tab view. Each tab returns same composable that shows list of different posts. Initially i fetch tabs and data initial data for each tab. It looks like this:
#Composable
fun Feed() {
val tabsViewModel: TabsViewModel = hiltViewModel()
val tabs = tabsViewModel.state.collectAsState()
val communityFieldViewModel: CommunityFeedViewModel = hiltViewModel()
val selectedTab = tabsViewModel.selectedTab.collectAsState()
val indexOfTile = tabs.value.indexOfFirst { it.id == selectedTab.value }
if(tabs.value.isNotEmpty()) {
var tabIndex by remember { mutableStateOf(indexOfTile) }
Column {
TabRow(selectedTabIndex = tabIndex) {
tabs.value.forEachIndexed { index, tab ->
communityFieldViewModel.loadInitialState(tab.id, tab.tiles, "succeed", tab.token)
Tab(selected = tabIndex == index,
onClick = { tabIndex = index },
text = { Text(text = tab.title) })
}
}
tabs.value.forEachIndexed { _, tab ->
CommunityFeed(tab.id)
}
}
}
}
My CommunityFeed is getting data from CommunityFeedViewModel and I set initial data for CommunityFeedViewModel in Feed. But problem is that I do not know how to get different data in CommunityFeed and now both render for example all posts and should be different. Here is CommunityFeed and CommunityFeedModel. Any advice?
data class CommunityState(
val path: String = "",
val tiles: List<Tile> = emptyList(),
val loadingState: String = "idle",
val token: String? = null
)
#HiltViewModel
class CommunityFeedViewModel #Inject constructor(
private val tileRepository: TileRepository,
): ViewModel() {
private val state = MutableStateFlow(CommunityState())
val modelState = state.asStateFlow()
fun loadInitialState(path: String, tiles: List<Tile>?, loadingState: String, token: String?) {
val tileList = tiles ?: emptyList()
state.value = CommunityState(path, tileList, loadingState, token)
}
}
#Composable
fun CommunityFeed(path: String) {
val feedViewModel: CommunityFeedViewModel = hiltViewModel()
val state = feedViewModel.modelState.collectAsState()
if(state.value.path == path) {
TileListView(tiles = state.value.tiles, loadMore = {})
}
}
I am try to learning android jetpack compose, and I have simple app. In ScreenA I have a text field and when I click the button, I am save this data to firestore, and when I come in ScreenB, I want to save city name also in firebase, but I am using one viewmodel, so how can save both text field data in the same place in firestore, I did not find any solution.
ScreenA:
class ScreenAActivity : ComponentActivity() {
private lateinit var viewModel: MyViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = ViewModelProvider(this)[MyViewModel::class.java]
setContent {
ScreenA(viewModel)
}
}
}
#Composable
fun ScreenA(
viewModel : MyViewModel
) {
val name = remember { mutableStateOf(TextFieldValue()) }
OutlinedTextField(
value = name.value,
onValueChange = { name.value = it },
label = { Text(text = "name") },
)
Button(
modifier = Modifier
.width(40.dp)
.height(20.dp),
onClick = {
focus.clearFocus(force = true)
viewModel.onSignUp(
name.value.text,
)
context.startActivity(
Intent(
context,
ScreenB::class.java
)
)
},
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Red
),
shape = RoundedCornerShape(60)
) {
Text(
text = "OK"
)
)
}
}
ScreenB:
class ScreenBActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ScreenB()
}
}
}
#Composable
fun ScreenB(
) {
val city = remember { mutableStateOf(TextFieldValue()) }
OutlinedTextField(
value = city.value,
onValueChange = { city.value = it },
label = { Text(text = "city") },
)
Button(
modifier = Modifier
.width(40.dp)
.height(20.dp),
onClick = {
focus.clearFocus(force = true)
viewModel.onSignUp(
city.value.text,
)
},
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Red
),
shape = RoundedCornerShape(60)
) {
Text(
text = "OK"
)
)
}
}
The recommendation is use a single activity and navigate through screens using Navigation library.
After you've refactored your code to use the Navigation library, you can pass the previous back stack entry in order to get the same instance of the View Model.
val navController = rememberNavController()
NavHost(
navController = navController,
...
) {
composable("ScreenA") { backStackEntry ->
val viewModel: MyViewModel = viewModel(backStackEntry)
ScreenA(viewModel)
}
composable("ScreenB") { backStackEntry ->
val viewModel: MyViewModel = viewModel(navController.previousBackStackEntry!!)
ScreenB(viewModel)
}
}
But if you really need to do this using activity, my suggestion is define a shared object between the view models. Something like:
object SharedSignInObject {
fun signUp(name: String) {
// do something
}
// other things you need to share...
}
and in your viewmodels you can use this object...
class MyViewModel: ViewModel() {
fun signUp(name: String) {
SharedSignInObject.signUp(name)
}
}
I have this project that I'm migrating to compose and I can't load the data from Firestore in the TextField. With the view system I could reference it and it uploaded automatically but with compose I'm unable to load. Below is the compose page and the FirestoreClass.
class ProfileFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
FirestoreClass().loadUserDataOnProfile()
return ComposeView(requireContext()).apply {
setContent {
ReiDoFifaTheme {
Column(
modifier = Modifier
.padding(16.dp)
) {
Image(
modifier = Modifier
.padding(top = 24.dp, bottom = 24.dp)
.align(Alignment.CenterHorizontally),
painter = painterResource(R.drawable.ic_user_place_holder),
contentDescription = null
)
TextField(value = /*TODO*/ , onValueChange = { /*TODO*/ })
}
}
}
fun loadUserDataOnProfile(){
firestore.collection(USERS)
.document(getCurrentUserID())
.get()
.addOnSuccessListener { document ->
val loggedUser = document.toObject(Player::class.java)
***
}
I had a function *** called from on addOnSuccessListener that filled my views with the data on ProfileFragment. How do I do it?
Storing the new data to Kotlin Flow APIs
fun loadUserDataOnProfile() : Flow<LoggedUser> = callbackFlow {
firestore.collection(USERS)
...
.addOnSuccessListener { document ->
val loggedUser = document.toObject(Player::class.java)
offer(loggedUser)
}
awaitClose { /* unregister firebase listener here */ }
}
Then:
...
return ComposeView(requireContext()).apply {
setContent {
val user: LoggedUser by FirestoreClass().loadUserDataOnProfile().collectAsState(initial = /*DefaultLoggedUser*/)
ReiDoFifaTheme {
...
TextField(value = user.name , onValueChange = { /*TODO*/ })
}
}
Note: But for better, you should combine with the ViewModel