I have an app structured with one MainActivity and several fragments which are communicated with by some ImageButtons in this activity. In one fragment (UnreviewedFragment), I have a RecyclerView whose Adapter can be changed to display different information. This functions perfectly well when first booting into this fragment.
However, when I enter the EditFragment using the following code in MainActivity.kt:
addButton.setOnClickListener {
supportFragmentManager.commit {
replace<EditItemFragment>(R.id.fragmentContainerView, "edit")
}
reviewedButton.isEnabled = true
unreviewedButton.isEnabled = true
}
And then return to either view with the following code:
unreviewedButton.setOnClickListener {
Log.d("MainActivity", "Unreviewed Button pressed! Is it enabled: ${unreviewedButton.isEnabled}")
if (supportFragmentManager.findFragmentByTag("edit") != null) {
supportFragmentManager.commit {
replace<UnreviewedFragment>(R.id.fragmentContainerView, "restaurants")
}
}
supportFragmentManager.executePendingTransactions()
restaurantsFragment.setModeUnreviewed()
unreviewedButton.isEnabled = false
reviewedButton.isEnabled = true
}
reviewedButton.setOnClickListener {
Log.d("MainActivity", "Reviewed Button pressed! Is it enabled: ${reviewedButton.isEnabled}")
if (supportFragmentManager.findFragmentByTag("edit") != null) {
supportFragmentManager.commit {
replace<UnreviewedFragment>(R.id.fragmentContainerView, "restaurants")
}
}
supportFragmentManager.executePendingTransactions()
restaurantsFragment.setModeReviewed()
unreviewedButton.isEnabled = true
reviewedButton.isEnabled = false
}
The binding used in the setModeReviewed and setModeUnreviewed methods in the UnreviewedFragment suddenly become null.
My logging statements and some debugging indicate that after the replace call, the bindings still have values in onViewCreated, but they seem to lose these values and become nulls when the transaction is finished and restaurantsFragment.setModeReviewed() is called in the onClickListener of the button.
My fragment's onCreateView function looks like this:
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
Log.d("UnreviewedFragment", "onCreateView")
// set view bindings
// TODO try resetting with simple findviewbyid to see if binding null exception leaves
_binding = FragmentUnreviewedBinding.inflate(layoutInflater)
Log.d("UnreviewedFragment", "binding is: $_binding")
Log.d("UnreviewedFragment", "Inflated! Viewmodel: $viewModel")
reviewedAdapter = ReviewedItemAdapter()
unReviewedAdapter = UnreviewedItemAdapter()
binding.unreviewedRecyclerView.adapter = unReviewedAdapter
binding.unreviewedRecyclerView.layoutManager = LinearLayoutManager(activity?.applicationContext!!)
viewModel.restaurantList.observe(viewLifecycleOwner, Observer { restaurants ->
restaurants?.let { unReviewedAdapter.setRestaurants(it) }
restaurants?.let { reviewedAdapter.setRestaurants(it) }
})
val view = binding.root
return view
}
And the code to exit the EditItemFragment looks like this:
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
_binding = FragmentEditItemBinding.inflate(layoutInflater)
// set up listeners
binding.submitButton.setOnClickListener {
viewModel.insert(Restaurant(
binding.restaurantNameEdit.text.toString(),
binding.restaurantAddressEdit.text.toString(),
binding.restaurantWebsiteEdit.text.toString(),
"dragon_hotpot_outside", // TODO fix this later
binding.priceBar.rating.toInt(),
null,
Date(),
null
))
}
val view: View = binding.root
return view
}
1 Answer 1
This is a classic stale fragment instance + view-binding lifecycle combo.
Here's what's happening:
The line `replace<UnreviewedFragment>(..., "restaurants")` creates a new instance of UnreviewedFragment.kt and destroys the old one. When that old instance is destroyed, its `onDestroyView()` function is called, and that sets its `binding` to null. When you call restaurantsFragment.setModeReviewed(), it most likely calls the old instance, which already has its binding variable set to null.
One quick way to fix this problem is to use a shared viewModel. Something like this:
class RestaurantsUiViewModel : ViewModel() {
enum class Mode { Reviewed, Unreviewed }
private val _mode = MutableStateFlow(Mode.Unreviewed)
val mode: StateFlow<Mode> = _mode.asStateFlow()
fun setMode(m: Mode) { _mode.value = m }
}
Then, in your activity, you can have this:
private val uiVm: RestaurantsUiViewModel by viewModels()
unreviewedButton.setOnClickListener {
showUnreviewedFragmentIfNeeded()
uiVm.setMode(RestaurantsUiViewModel.Mode.Unreviewed)
}
reviewedButton.setOnClickListener {
showUnreviewedFragmentIfNeeded()
uiVm.setMode(RestaurantsUiViewModel.Mode.Reviewed)
}
private fun showUnreviewedFragmentIfNeeded() {
val existing = supportFragmentManager.findFragmentByTag("restaurants")
if (existing == null || existing !is UnreviewedFragment) {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.fragmentContainerView, UnreviewedFragment(), "restaurants")
}
supportFragmentManager.executePendingTransactions()
}
}
Finally, your UnreviewedFragment.kt can look like this:
private var _binding: FragmentUnreviewedBinding? = null
private val binding get() = _binding!!
private val uiVm: RestaurantsUiViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentUnreviewedBinding.inflate(inflater, container, false)
binding.unreviewedRecyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.unreviewedRecyclerView.adapter = unReviewedAdapter
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
uiVm.mode.collect { mode ->
when (mode) {
RestaurantsUiViewModel.Mode.Reviewed -> {
binding.unreviewedRecyclerView.adapter = reviewedAdapter
}
RestaurantsUiViewModel.Mode.Unreviewed -> {
binding.unreviewedRecyclerView.adapter = unReviewedAdapter
}
}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
With this approach, you are guaranteed that the binding can only be touched if (and only if) the view exists.
MutableStateFlow here?