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)
}
Related
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
}
I have a simple app for testing. It has a "basic activity" that Android Studio brings by default. Also a Google maps fragment shown in the basic activity (the code is at the end of the post).
As you all know, the basic activity has a floating button like this:
The main problem is that I'm completely new to working with fragments, and I don't quite understand how they work (I am working on it) but what I want to do is that: by clicking on the floating button, the map is "restarted", reloading again and cleaning it of markers that the user has placed.
What would be the correct way to do this?
I have found several suggestions but I can't get them to work or I can't see how to implement them correctly. One of the ways I have tried is to detach and attach the map fragment, but this causes it to crash and shows no results. The code is the following, which I add in the floating button listener:
val frg : Fragment? = supportFragmentManager.findFragmentById(R.id.map);
val frgTransac = supportFragmentManager.beginTransaction();
if (frg != null) {
frgTransac.detach(frg);
frgTransac.attach(frg);
frgTransac.commit();
}
Another option is to use "googleMap.clear" but I don't know exactly how to get access to that object from the floating button listener.
I hope you can help me with this and, above all, understand how the fragments work and what I am doing wrong.
Main activity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
setSupportActionBar(findViewById(R.id.toolbar))
findViewById<FloatingActionButton>(R.id.fab).setOnClickListener { view ->
// CLEAR MAP FROM HERE
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_settings -> true
else -> super.onOptionsItemSelected(item)
}
}
}
Map Fragment
class FMaps : Fragment() {
private val callback = OnMapReadyCallback { googleMap ->
val sydney = LatLng(-34.0, 151.0)
googleMap.addMarker(MarkerOptions().position(sydney).title("Marker in Sydney"))
googleMap.moveCamera(CameraUpdateFactory.newLatLng(sydney))
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_f_maps, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val mapFragment = childFragmentManager.findFragmentById(R.id.map) as SupportMapFragment?
mapFragment?.getMapAsync(callback)
}
}
You can use custom interface defined in the fragment and then called from the activity when the button is pressed (example how to create interface here: Communicating between a fragment and an activity - best practices). When you press the button and the interface is called, you can just refresh the map using mapFragment?.getMapAsync(callback) inside the fragment.
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)
I know this already asked a few times, but I still don't get anything after all (I'm quite new in android development).
So i set up my back button in the MainActivity.kt like this:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
val navController = this.findNavController(R.id.myNavHostFragment)
NavigationUI.setupActionBarWithNavController(this, navController)
supportActionBar?.setDisplayHomeAsUpEnabled(false)
}
// Set up the back button on action bar
override fun onSupportNavigateUp(): Boolean {
val navController = this.findNavController(R.id.myNavHostFragment)
return navController.navigateUp()
}
}
What I want is that this back button is disabled in some fragments, so I tried to override the onBackPressed() function (It is what most people on the internet told) in one of the fragments:
class DashboardFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Declare that this fragment has menu
setHasOptionsMenu(true)
// Set action bar title to "Main Dashboard"
(activity as AppCompatActivity).supportActionBar?.title = "Main Dashboard"
// Binding object for this fragment and the layout
val binding: FragmentDashboardBinding = DataBindingUtil.inflate(inflater, R.layout.fragment_dashboard, container, false)
//Some codes here//
return binding.root
}
// This is where the error occured
override fun onBackPressed() {
super.onBackPressed()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater?.inflate(R.menu.nav_overflow_menu, menu)
}
}
But it returns an error saying:
"OnBackPressed" overrides Nothing
Am I missing something? I'm already searching for the solutions but still confused over this.
Who knew... onSupportNavigateUp() works only on 4.0 and above. For below onNavigateUp() is called.
so
override fun onNavigateUp(): Boolean {
val navController = this.findNavController(R.id.myNavHostFragment)
return navController.navigateUp()
}
What you could do is set your nonbackbutton fragments to backstack when inserting them, which is done by
val transaction = supportFragmentManager.beginTransaction()
transaction.addToBackStack(null)
transaction.replace(R.id.frame, fragment) //or add, whatever you use
transaction.commit()
(if you want back button functionality, just skip the addtoBackStack line)
Then, also in your activity, you override the onBackPressed() and check whether the backstack is empty or full. If it is empty, which is checked by
if(supportFragmentManager.backStackEntryCount() == 0)
then you are, for example, on a fragment that supports back button, and just do super.onBackPressed().
On the other hand, if it has something, you can do the navController.navigateUp() part, and when done, pop it using supportFragmentManager.popBackStackImmediate().
I think you could make something like this work, try it, and let us know :)
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)
}
}