androidkotlinandroid-recyclerviewandroid-night-mode

Problem with RecyclerView in Night Mode android kotlin NullPointerException


I have a RecyclerView in my Fragment and two themes in app: Day, Night and System Default.

There is a strange problem that causes a NullPointerException. If I switch the theme to night and exit the application, and then enter it again, then a NullPointerException crashes and the application will not open again until I delete it from the phone or emulator. However, if I stay on the light theme all the time and close and open the application again, then everything will be fine.

Code for Fragment:

private  var _binding: FragmentListBinding? = null
private val binding get() = _binding!!

private lateinit var rvAdapter: RvStatesAdapter
private var statesList = ArrayList<State>()
private var databaseReferenceStates: DatabaseReference? = null

override fun onCreateView(
    inflater: LayoutInflater, container: ViewGroup?,
    savedInstanceState: Bundle?
): View {
    _binding = FragmentListBinding.inflate(inflater, container, false)

    checkTheme()
    initDatabase()
    getStates()

    binding.rvStates.layoutManager = LinearLayoutManager(requireContext())

    binding.ibMenu.setOnClickListener {
        openMenu()
    }

    return binding.root
}

override fun onDestroyView() {
    super.onDestroyView()
    _binding = null
}

private fun getStates() {
    databaseReferenceStates?.addValueEventListener(object: ValueEventListener {
        override fun onDataChange(snapshot: DataSnapshot) {
            if (snapshot.exists()) {
                for (stateSnapshot in snapshot.children) {
                    val state = stateSnapshot.getValue(State::class.java)

                    statesList.add(state!!)
                }

                rvAdapter = RvStatesAdapter(statesList)
                binding.rvStates.adapter = rvAdapter
            }
        }

        override fun onCancelled(error: DatabaseError) {

        }
    })
}

private fun initDatabase() {
    FirebaseApp.initializeApp(requireContext());
    databaseReferenceStates = FirebaseDatabase.getInstance().getReference("States")
}

private fun openMenu() {
    binding.drawerLayout.openDrawer(GravityCompat.START)

    binding.navigationView.setNavigationItemSelectedListener {
        when (it.itemId) {
            R.id.about_app -> Toast.makeText(context, "item clicked", Toast.LENGTH_SHORT).show()

            R.id.change_theme -> {
                chooseThemeDialog()
            }
        }

        binding.drawerLayout.closeDrawer(GravityCompat.START)
        true
    }
}

private fun checkTheme() {
    when (ThemePreferences(requireContext()).darkMode) {
        0 -> {
            AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
            (activity as AppCompatActivity).delegate.applyDayNight()
        }

        1 -> {
            AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
            (activity as AppCompatActivity).delegate.applyDayNight()
        }

        2 -> {
            AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
            (activity as AppCompatActivity).delegate.applyDayNight()
        }
    }
}

private fun chooseThemeDialog() {
    val builder = AlertDialog.Builder(requireContext())
    builder.setTitle("Choose Theme")

    val themes = arrayOf("Light", "Dark", "System default")

    val checkedItem = ThemePreferences(requireContext()).darkMode

    builder.setSingleChoiceItems(themes, checkedItem) {dialog, which ->
        when (which) {
            0 -> {
                AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
                (activity as AppCompatActivity).delegate.applyDayNight()
                ThemePreferences(requireContext()).darkMode = 0
                dialog.dismiss()
            }

            1 -> {
                AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
                (activity as AppCompatActivity).delegate.applyDayNight()
                ThemePreferences(requireContext()).darkMode = 1
                dialog.dismiss()
            }

            2 -> {
                AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
                (activity as AppCompatActivity).delegate.applyDayNight()
                ThemePreferences(requireContext()).darkMode = 2
                dialog.dismiss()
            }
        }
    }

    val dialog = builder.create()
    dialog.show()
}

ThemePreferences class:

companion object {
    private const val DARK_STATUS = ""
}

private val preferences = PreferenceManager.getDefaultSharedPreferences(context)

var darkMode = preferences.getInt(DARK_STATUS, 0)
    set(value) = preferences.edit().putInt(DARK_STATUS, value).apply()

RecyclerView in .xml code:

<androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvStates"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_marginTop="20dp"
        android:background="@color/background"
        app:layoutManager="LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/tvLabelDescription"
        tools:listitem="@layout/rv_state_list" />

and also code from RecyclerView Adapter:

inner class MyViewHolder(val binding: RvStateListBinding): RecyclerView.ViewHolder(binding.root)

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
    return MyViewHolder(RvStateListBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}

override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
    val currentItem = stateList[position]

    with(holder) {
        with(stateList[position]) {
            binding.tvState.text = this.name

            Picasso.with(itemView.context)
                .load(this.image)
                .into(binding.ibState, object: Callback {
                    override fun onSuccess() {
                        binding.progressBar.visibility = View.GONE
                    }

                    override fun onError() {

                    }
                })

            itemView.ibState.setOnClickListener {
                val action = StatesFragmentDirections.actionListFragmentToAttractionsFragment(currentItem)
                itemView.findNavController().navigate(action)
            }
        }
    }
}

override fun getItemCount(): Int {
    return stateList.size
}

Exception


Solution

  • Your crash is coming from that onDataChange callback - you're calling getStates (after binding has been set) but by the time the results come back and onDataChange tries to access binding, it's null again.

    If I had to guess, when you call checkTheme and it calls applyDayNight, that probably does nothing if the activity is already using the theme you're applying. So if you're setting a light theme, and it's already using a light theme, no problem. (You could test this by seeing if it stops crashing if you set the system to a dark theme, assuming your app theme is a DayNight one)

    But if it needs to change to a dark theme, that means recreating the Activity and Fragment. I don't know the specifics of what gets recreated now, but at the very least you're probably going to be recreating your view layout with the new theme. Which means the layout is getting destroyed, which means onDestroyView is getting called - and in there, you're setting binding to null

    So I'm assuming your onDataChange callback is either arriving between layout (and binding) destruction and recreation, or the whole Fragment is getting destroyed and the callback is just calling a binding variable that's never getting restored.


    Easiest fix is just to not set binding to null. Make it lateinit like Emmanuel says, and it'll get initialised/overwritten every time onCreateView is called. If the callback updates an old binding layout, it's cool, the new one will ask for an update in onCreateView anyway

    Just make sure you're cleaning up the event listeners you're setting on databaseReferenceStates if you need to - if that's the reason you're clearing the binding in onDestroyView, the listener still has a reference to the fragment that holds that variable, and you could end up keeping dead ones in memory (otherwise you could just null-check binding)