Search code examples
androidkotlinandroid-alertdialogandroid-roomobservers

EmptyDatabaseAlert showing twice


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([email protected](), 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:

  1. App launched, CrimeListFragment observes database, updateUI() gets called, database is empty so alert pops up aka EmptyAlertFragment gets shown, click on Create -> onCreateSelected() callback to CrimeListFragment.
  2. onCreateSelected() calls createNewCrime() which uses ViewModel to add a crime (Room, Repository pattern), onCrimeClicked() callback to MainActivity.
  3. 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) }
  4. Database gets updated, CrimeListFragment observes database-change, updateUI() gets called, database is not empty so alert SHOULDN'T pop up but it does.
  5. I click Create again, create second crime, tap back and the alert won't show again.

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?


Solution

  • 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.