I have a Fragment that is a RecyclerView, its ViewModel that does a Room operation - add()
. If the database is empty, that Fragment should show an AlertDialog that allows the user to either dismiss or create a new entry.
CrimeListFragment
and relevant bits:
class CrimeListFragment:
Fragment(),
EmptyAlertFragment.Callbacks {
interface Callbacks {
fun onCrimeClicked(crimeId: UUID)
}
//==========
private var callback: Callbacks? = null
private lateinit var crimeRecyclerView: RecyclerView
private val crimeListViewModel: CrimeListViewModel by lazy {
ViewModelProviders.of(this).get(CrimeListViewModel::class.java)
}
//==========
override fun onAttach(context: Context) {
super.onAttach(context)
callback = context as Callbacks?
}
override fun onCreate(savedInstanceState: Bundle?) {}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
crimeListViewModel.crimeListLiveData.observe( //crimeListLiveData: LiveData<List<Crime>>
viewLifecycleOwner,
Observer { crimes ->
crimes?.let {
Log.i(TAG, "Retrieved ${crimes.size} crimes.")
updateUI(crimes)
}
}
)
}
override fun onDetach() {
super.onDetach()
callback = null
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {}
override fun onOptionsItemSelected(item: MenuItem): Boolean {}
override fun onCreateSelected() = createNewCrime()
//==========
private fun updateUI(crimes: List<Crime>) {
if(crimes.isEmpty()) {
Log.d(TAG, "empty crime list, show empty dialog")
showEmptyDialog()
}
(crimeRecyclerView.adapter as CrimeListAdapter).submitList(crimes)
Log.d(TAG, "list submitted")
}
private fun showEmptyDialog() {
Log.d(TAG, "show empty dialog")
EmptyAlertFragment.newInstance().apply {
setTargetFragment(this@CrimeListFragment, REQUEST_EMPTY)
show(this@CrimeListFragment.requireFragmentManager(), DIALOG_EMPTY)
}
}
private fun createNewCrime() {
val crime = Crime()
crimeListViewModel.addCrime(crime)
callback?.onCrimeClicked(crime.id)
Log.d(TAG, "new crime added")
}
//==========
companion object {}
//==========
private inner class CrimeHolder(view: View)
: RecyclerView.ViewHolder(view), View.OnClickListener {}
private inner class CrimeListAdapter
: ListAdapter<Crime, CrimeHolder>(DiffCallback()) {}
private inner class DiffCallback: DiffUtil.ItemCallback<Crime>() {}
}
My EmptyAlertFragment
:
class EmptyAlertFragment: DialogFragment() {
interface Callbacks {
fun onCreateSelected()
}
//==========
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = AlertDialog.Builder(activity!!)
builder.setPositiveButton("Create") {
_, _ ->
targetFragment?.let { fragment ->
(fragment as Callbacks).onCreateSelected()
}
}
builder.setNegativeButton("Cancel") {
dialog, _ ->
dialog.dismiss()
}
val alert = builder.create()
alert.apply {
setTitle("Crime list empty!")
setMessage("Do you want to create a new crime?")
}
return alert
}
//==========
companion object {
fun newInstance(): EmptyAlertFragment {
return EmptyAlertFragment()
}
}
}
And finally my MainActivity
:
class MainActivity:
AppCompatActivity(),
CrimeListFragment.Callbacks {
override fun onCreate(savedInstanceState: Bundle?) {}
//==========
override fun onCrimeClicked(crimeId: UUID) {
val crimeFragment = CrimeDetailFragment.newInstance(crimeId)
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, crimeFragment)
.addToBackStack("crime")
.commit()
}
}
Basically the flow is this:
CrimeListFragment
observes database, updateUI()
gets called, database is empty so alert pops up aka EmptyAlertFragment
gets shown, click on Create -> onCreateSelected()
callback to CrimeListFragment
.onCreateSelected()
calls createNewCrime()
which uses ViewModel to add a crime (Room, Repository pattern), onCrimeClicked()
callback to MainActivity
.MainActivity
launches CrimeDetailFragment
which shows either an existing or empty (new) crime for us to fill. We fill it and click back, crime gets saved: CrimeDetailFragment
- onStop() { super.onStop; crimeDetailViewModel.saveCrime(crime) }
CrimeListFragment
observes database-change, updateUI()
gets called, database is not empty so alert SHOULDN'T pop up but it does.In other words the alert gets shown one time too many.
Logcat shows this:
`Retrieved 0 crimes`
`empty crime list, show empty dialog`
`show empty dialog`
`list submitted`
`*(I add a crime)*`
`new crime added`
`Retrieved 0 crimes` <--- Why? I just created a crime, Observer should notify and `updateUI()` should get called with a non-empty list
`empty crime list, show empty dialog`
`show empty dialog`
`list submitted`
`Retrieved 1 crimes.` <--- Correct behavior from here on out
Why does my dialog pop up twice instead of once?
This is due to how LiveData works: it caches and returns the last value before querying for updated data.
The first time your CrimeListFragment
starts to observe
the crimeListLiveData
, it gets an empty list, correctly showing your dialog.
When you go to CrimeDetailFragment
, the crimeListViewModel.crimeListLiveData
is not destroyed. It retains the existing value - your empty list.
Therefore when you go back to your CrimeListFragment
, onCreateView()
runs again and you start observing again. LiveData
immediately returns the cached value it had and Room asynchronously kicks off a query for updated data. Therefore it is expected that you first get an empty list before getting an updated, non-empty list.
You'll see the same behavior if you rotate your device while your EmptyAlertFragment
is on the screen and the CrimeListFragment
is behind it - you'll end up creating a second copy of your EmptyAlertFragment
for the same reason. Then a third, fourth, fifth, etc. if you continue to rotate your device.
As per the Material design guidelines for dialogs, dialogs are for critical information or important decisions, so perhaps the most appropriate solution for your "Create a new crime" requirement is to not use a dialog at all, instead using an empty state in your CrimeListFragment
alongside a Floating Action Button. Then, your updateUI
method would simply switch between the empty state and your non-empty RecyclerView based on the count.
The other option is that your CrimeListFragment
should keep track of whether you've displayed the dialog already in a boolean
field, saving that boolean into the Bundle
in onSaveInstanceState()
to ensure it survives rotation and process death / recreation. That way you can be sure you only show the dialog just a single time for a given CrimeListFragment
.