Observer is triggered twice even though the observed variable is changed once - android

My ViewModel class should load User data and an image asynchronously, when done it should set a variable userLoaded to true and trigger the observer inside the hosting fragment to inflate the options menu. The initialization inside the ViewModel is done via
init {
viewModelScope.launch {
userLoaded.value = false
try {
loadUser(id)
loadImg(id)
} catch (e: Exception){
Log.e(TAG, "${e.message}")
} finally {
userLoaded.value = true
}
}
}
loadUser() and loadImg() are two suspend fun that load some data from Firebase. The main problem is that, even though userLoaded.value is changed only once, the menu is inflated twice. The following is the observer inside the fragment
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
// menu.clear()
viewModel.userLoaded.observe(this, Observer {
if (it == true) {
inflater.inflate(R.menu.show_profile_menu, menu)
}
})
}
Update1:
How about this solution?
override fun onPrepareOptionsMenu(menu: Menu) {
viewModel.userLoaded.observe(this, Observer {
menu.findItem(R.id.edit_icon).isVisible = it
menu.findItem(R.id.edit_icon).isEnabled = it
})
super.onPrepareOptionsMenu(menu)
}

You should not subscribe to the observer in onCreateOptionsMenu because this method may be called multiple times. You should instead subscribe in onViewCreated,
fun onViewCreated(view: View, savedInstanceState: Bundle?) {
/* other stuff */
viewModel.userLoaded.observe(this, Observer {
if (it == true) {
// toggle a global flag and recreate the menu
hasLoaded = true
activity?.invalidateOptionsMenu()
}
})
}
then
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
// menu.clear()
if (hasLoaded) {
inflater.inflate(R.menu.show_profile_menu, menu)
}
}

Related

Restoring the state of a SearchView and filtering LiveData with the restored state

I'm using a SearchView in my layout to let a user search for categories.
The problem that arises is when restoring state when i navigate away from the
fragment and come back (View gets restored)...
What happens is that the onQueryTextChange method gets called
from the SearchView's setOnQueryTextListener with its last entered query, before submitList() is called. The result of this is an empty list instead of a filtered list.
The question: How can I make sure onQueryTextChange gets called (with restored state) after submitlist has been called, without breaking anything
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
(binding.rvCategories.adapter as CategoriesAdapter).filter.filter(newText)
return false
}
})
viewModel.categories.observe(viewLifecycleOwner) {
categoryAdapter.submitList(it)
}
}
I'm not sure how to solve this problem. I want the SearchView to restore its state and filter the list after it has received its data. I have tried filtering using my ViewModel but the problem in this scenario is that i can't use a SwitchMap function for the categories LiveData because i already have one.
The only 'solution' I have now is to disable the SearchView from restoring its state by doing:
searchView.isSaveEnabled = false
searchView.isSaveFromParentEnabled = false
But that's not really what i want because I'd like the fragment to restore its state correctly
My ViewModel:
private val _rootCategory = MutableLiveData<Category?>()
val rootCategory: LiveData<Category?>
get() = _rootCategory
val categories = rootCategory.switchMap {
loadData(it)
}
private fun loadData(rootCategory: Category?): LiveData<List<Category>> {
return if (rootCategory == null) {
//No root category, retrieve root categories
databaseManager.getRootCategories().asLiveData().map {
removeDefaultCategory(it)
}
} else {
//Root category, retrieve its subcategories
databaseManager.getRootCategories().asLiveData().map {
it.first { c -> c == rootCategory }.subCategories
}
}
}
IMHO it is better if you filter categories in ViewModel and then submit the filtered data to categoryAdapter instead of directly calling filter in CategoriesAdapter. This way, every time your view gets restored you have the latest data from categories LiveData gets submitted to categoriesAdapter and also you applying Unidirectional Data Flow. Something like this
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
// notify viewModel of text change
viewModel.onQueryTextChange(newText ?: "")
return false
}
})
viewModel.categories.observe(viewLifecycleOwner) {
categoryAdapter.submitList(it)
}
}
then in your ViewModel
fun onQueryTextChange(newQuery: String) {
// get filtered categories from database or
// filter categories list and then emit the
// new list using categories LiveData
}

Observing variable in viewmodel is not working

I have this code in which I am trying to observe a variable from my viewmodel. However, whenever I observe the variable, it always returns false, which is the default value, even though it should be returning true. I don't understand why it's not working, any idea and advice would be great.
This is the viewmodel part:
val isSuccessful = MutableLiveData(false)
fun acceptAgreement() = currentAgreement.value?.let {
viewModelScope.launch {
runCatching { agreementsRepository.acceptAgreement(it.id) }
.onSuccess { isSuccessful.postValue(true) }
.onFailure { isSuccessful.postValue(false) }
}
}
The observation in the fragment, where it always returns the showError():
binding.btnAccept.setOnClickListener { onAccept().also { continue()} }
private fun onAccept() = viewModel.acceptAgreement()
private fun continue() {
viewModel.isSuccessful.observe(viewLifecycleOwner, {
if (it) { start() } else { showError() }
})
}
Repository:
suspend fun acceptAgreement(id: String) = changeAgreement(id, status.ACCEPTED)
private suspend fun changeAgreement(id: String, status: status) {
try { agreementsService.changeAgreement(id, status.serialize()) }
catch (e: Throwable) { logger.error(this::class.java.name, "Failed to change status ${id}", e) }
}
Is there a reason you are running continue() after your run onAccept?
I believe what is happening is you haven't set the observer before you are observing.
So your flow goes:
onAccept -> triggers the update of the livedata.
Continue -> Sets the observer of the livedata.
I would suggest that you move the method call "continue()" into your onCreateView method of the fragment. It won't be triggered until it changes state in the viewmodel anyway.
Also you need to check you have set the viewLifecycleOwner of the fragment.
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentYourFragmentNameBinding.inflate(inflater, container, false).apply {
lifecycleOwner = viewLifecycleOwner
}
continue()
return binding.root
}
isSuccessful.postValue(
runCatching { agreementsRepository() }.isSuccess
)
Instead of using isSuccessful.postValue() use isSuccessful.value = true. I have found that assignment, not the postValue method, updates registered observers for LiveData.

Observer isn't notified when data has been changed

I have an activity that has a SearchView that I use to enter a query, my app then uses to query to access an API. My activity further contains a fragment, and within this fragment I have my observer.
Further I have my ViewModel, which makes the API call when given a query. However, my observer is never notified about the update, and thus my view never updates. Unless I call it directly from my ViewModel upon initiation. I'll show it specifically here:
ViewModel
class SearchViewModel : ViewModel() {
val booksResponse = MutableLiveData<MutableList<BookResponse>>()
val loading = MutableLiveData<Boolean>()
val error = MutableLiveData<String>()
init {
getBooks("How to talk to a widower")
}
fun getBooks(bookTitle: String) {
GoogleBooksService.api.getBooks(bookTitle).enqueue(object: Callback<ResponseWrapper<BookResponse>> {
override fun onFailure(call: Call<ResponseWrapper<BookResponse>>, t: Throwable) {
onError(t.localizedMessage)
}
override fun onResponse(
call: Call<ResponseWrapper<BookResponse>>,
response: Response<ResponseWrapper<BookResponse>>
) {
if (response.isSuccessful){
val books = response.body()
Log.w("2.0 getFeed > ", Gson().toJson(response.body()));
books?.let {
// booksList.add(books.items)
booksResponse.value = books.items
loading.value = false
error.value = null
Log.i("Content of livedata", booksResponse.getValue().toString())
}
}
}
})
}
private fun onError(message: String) {
error.value = message
loading.value = false
}
}
Query Submit/ Activity
class NavigationActivity : AppCompatActivity(), SearchView.OnQueryTextListener, BooksListFragment.TouchActionDelegate {
lateinit var searchView: SearchView
lateinit var viewModel: SearchViewModel
private val mOnNavigationItemSelectedListener =
BottomNavigationView.OnNavigationItemSelectedListener { menuItem ->
when (menuItem.itemId) {R.id.navigation_search -> {
navigationView.getMenu().setGroupCheckable(0, true, true);
replaceFragment(SearchListFragment.newInstance())
return#OnNavigationItemSelectedListener true
}
R.id.navigation_books -> {
navigationView.getMenu().setGroupCheckable(0, true, true);
replaceFragment(BooksListFragment.newInstance())
return#OnNavigationItemSelectedListener true
}
}
false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
replaceFragment(SearchListFragment.newInstance())
navigationView.setOnNavigationItemSelectedListener(mOnNavigationItemSelectedListener)
//Set action bar color
val actionBar: ActionBar?
actionBar = supportActionBar
val colorDrawable = ColorDrawable(Color.parseColor("#FFDAEBE9"))
// actionBar!!.setBackgroundDrawable(colorDrawable)
// actionBar.setTitle(("Bobs Books"))
setSupportActionBar(findViewById(R.id.my_toolbar))
viewModel = ViewModelProvider(this).get(SearchViewModel::class.java)
}
override fun onBackPressed() {
super.onBackPressed()
navigationView.getMenu().setGroupCheckable(0, true, true);
}
private fun replaceFragment(fragment: Fragment){
supportFragmentManager
.beginTransaction()
.replace(R.id.fragmentHolder, fragment)
.commit()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.book_search_menu, menu)
val searchItem = menu.findItem(R.id.action_search)
searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.queryHint = "Search for book"
/*searchView.onActionViewExpanded()
searchView.clearFocus()*/
// searchView.setIconifiedByDefault(false)
return true
}
override fun onQueryTextSubmit(query: String): Boolean {
//replaces fragment if in BooksListFragment when searching
replaceFragment(SearchListFragment.newInstance())
val toast = Toast.makeText(
applicationContext,
query,
Toast.LENGTH_SHORT
)
toast.show()
searchView.setQuery("",false)
searchView.queryHint = "Search for book"
// viewModel.onAddBook(Book(title = query!!, rating = 5, pages = 329))
Log.i("Query fra text field", query)
// viewModel.getBooks(query)
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
return false
}
override fun launchBookFragment(bookId: Book) {
supportFragmentManager
.beginTransaction()
.replace(R.id.fragmentHolder, com.example.bobsbooks.create.BookFragment.newInstance(bookId.uid))
.addToBackStack(null)
.commit()
navigationView.getMenu().setGroupCheckable(0, false, true);
}
}
Fragment
class SearchListFragment : Fragment() {
lateinit var viewModel: SearchViewModel
lateinit var contentListView: SearchListView
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
return inflater.inflate(R.layout.fragment_search_list, container, false).apply {
contentListView = this as SearchListView
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindViewModel()
setContentView()
}
private fun setContentView(){
contentListView.initView()
}
private fun bindViewModel(){
Log.i("ViewmodelCalled", "BindViewModel has been called")
viewModel = ViewModelProvider(this).get(SearchViewModel::class.java)
viewModel.booksResponse.observe(viewLifecycleOwner, Observer {list ->
list?.let {
Log.i("Observer gets called", "Updatelistgetscalled")
contentListView.updateList(list)
}
} )
viewModel.error.observe(viewLifecycleOwner, Observer { errorMsg ->
})
viewModel.loading.observe(viewLifecycleOwner, Observer { isLoading ->
})
}
companion object {
fun newInstance(): SearchListFragment {
return SearchListFragment()
}
}
When I put the getBooks call into my Viewmodel Init, it will do everything correctly. It gets the bookresponse through the API, adds it to my LiveData and notifies my adapter.
However, if I instead delete that and call it through my Querysubmit in my Activity, it will, according to my logs, get the data and put it into my booksReponse:LiveData, but thats all it does. The observer is never notifed of this change, and thus the adapter never knows that it has new data to populate its views.
I feel like I've tried everything, I even have basically the same code working in another app, where it runs entirely in an activity instead of making the query in an activity, and rest is called in my fragment. My best guess is this has an impact, but I cant figure out how.
As per your explanation
However, if I instead delete that and call it through my Querysubmit in my Activity, it will, according to my logs, get the data and put it into my booksReponse:LiveData, but thats all it does. The observer is never notifed of this change, and thus the adapter never knows that it has new data to populate its views.
the problem is you are initializing SearchViewModel in both activity & fragment, so fragment doesn't have the same instance of SearchViewModel instead you should use shared viewmodel in fragment like :
viewModel = ViewModelProvider(requireActivity()).get(SearchViewModel::class.java)

LiveData observer vs onPrepareOptionsMenu race

I'm working on a project which lets users in either as guests or registerd users.
There is an application scope user object with LiveData of the current user type
private val _isGuest = MutableLiveData<Boolean>()
val isGuest: LiveData<Boolean>
get() = _isGuest
There is HomeFragment which needs to show logout menu item for registered users.
The fragment has a ViewModel bound to the global property
val isGuest: LiveData<Boolean> = MainApplication.user.isGuest
and the fragment observes the data
var menu: Menu? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
viewModel.isGuest.observe(viewLifecycleOwner, Observer {
menu?.findItem(R.id.action_logout)?.isVisible = !it
})
}
override fun onPrepareOptionsMenu(menu: Menu) {
this.menu = menu
menu.findItem(R.id.action_logout)?.isVisible = !isGuest
super.onPrepareOptionsMenu(menu)
}
I need to toggle the menu item in the observer because registered users can logout at runtime and the current screen will need to be updated respectively.
The problem is that I also have to duplicate the code in onPrepareOptionsMenu because the observer may get notified before menu is initilized at startup.
Definitely I can move that line of code into a separate function and call it from the two points but aren't there a better solution?
Use invalidateOptionsMenu() to trigger onPrepareOptionMenu()
var menu: Menu? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
viewModel.isGuest.observe(viewLifecycleOwner, Observer {
activity?.invalidateOptionsMenu()//This will trigger onPrepareOptionsMenu
})
}
override fun onPrepareOptionsMenu(menu: Menu) {
this.menu = menu
menu.findItem(R.id.action_logout)?.isVisible = !isGuest
super.onPrepareOptionsMenu(menu)
}

setSupportActionBar inside of a Fragment

I have a Fragment:
class HomeFragment : Fragment() { ... }
Now I'm trying to add an Actionbar to it, but this doesn't work:
setSupportActionBar(findViewById(R.id.toolbar_main))
How can I set the Support and then add Items to the ActionBar?
This is how it works in an AppCompatActivity:
// This adds items to the ActionBar
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_toolbar_main, menu)
return true
}
// This is the OnClickListener for the Buttons in the ActionBar
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.toolbar_edit -> {
true
}
R.id.toolbar_search -> {
true
}
else -> {
// If we got here, the user's action was not recognized.
// Invoke the superclass to handle it.
super.onOptionsItemSelected(item)
}
}
Big thanks in advance!
Override onCreateOptionsMenu in your Fragment and inflate your menu inside. Than in onCreate method of Fragment set setHasOptionsMenu() to true. To inflate different menus depending on Fragment creation clear the menu first.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setHasOptionsMenu(true)
}
override fun onCreateOptionsMenu(menu: Menu?, inflater: MenuInflater?) {
super.onCreateOptionsMenu(menu, inflater)
inflater?.inflate(Your menu here, menu)
}

Categories

Resources