I am developing an app that displays a list of audio clips, I use a RecyclerView with a ListAdapter. I’ve implemented a Selection Mode feature where users can select multiple items, but I’m encountering two main issues:
1. My Custom Search View Not Appearing in Selection Mode:
When Selection Mode is activated via the startSelectionMode()
function in HomeFragment, I set the visibility of EditText (customSearchView) to View.VISIBLE
. However, the search field does not appear on the screen.
The customSearchView is defined in fragment_home.xml
2. CheckBox Not Appearing in Selection Mode:
In Selection Mode, each item in the RecyclerView should display a CheckBox (defined in clip_item.xml) to allow selection. However, the CheckBox remains invisible despite setting its visibility to View.VISIBLE in ClipAdapter when isSelectionMode is true.
I’ve tried refreshing the RecyclerView with notifyDataSetChanged()
and submitList()
, but the issue persists.
Additional Details
In HomeFragment, the startSelectionMode() function sets isSelectionMode to true and attempts to show binding.customSearchView by setting its visibility to View.VISIBLE
.
Despite this, the customSearchView (an EditText) does not appear on the screen.
The layout (fragment_home.xml) includes this EditText, and I’ve verified that the binding is correctly initialized.
Second Issue: CheckBox Not Appearing In ClipAdapter, when isSelectionMode is true, I hide the moreOptions ImageView and show the selectionCheckBox.
I’ve attempted to force an update by calling submitList()
with a new list when isSelectionMode changes, but this hasn’t helped.
My Code
this clip_item.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<ImageView
android:id="@+id/albumImage"
android:layout_width="@dimen/_48sdp"
android:layout_height="@dimen/_48sdp"
android:src="@android:drawable/ic_menu_gallery"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:padding="8dp"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="@+id/albumImage"
app:layout_constraintEnd_toStartOf="@+id/moreOptions"
app:layout_constraintStart_toEndOf="@+id/albumImage"
app:layout_constraintTop_toTopOf="@+id/albumImage">
<TextView
android:id="@+id/clipTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="end"
tools:text="Title"
android:textSize="@dimen/_12ssp"
android:textStyle="bold" />
<TextView
android:id="@+id/clipArtist"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="end"
tools:text="Artist"
android:textSize="@dimen/_11ssp" />
</LinearLayout>
<ImageView
android:id="@+id/moreOptions"
android:layout_width="@dimen/_20sdp"
android:layout_height="@dimen/_20sdp"
android:src="@drawable/baseline_more_vert_48"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<CheckBox
android:id="@+id/selectionCheckBox"
android:layout_width="@dimen/_20sdp"
android:layout_height="@dimen/_20sdp"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
ClipAdapter.kt
class ClipAdapter(
private val selectionListener: OnItemSelectionListener) : ListAdapter<Clip, ClipAdapter.ClipViewHolder>(ClipDiffCallback) {
var isSelectionMode: Boolean = false
set(value) {
field = value
val newList = currentList.map { it.copy() }
submitList(newList)
}
private val selectionState: MutableMap<Long, Boolean> = mutableMapOf()
fun updateSelectionState(clipId: Long, isSelected: Boolean) {
selectionState[clipId] = isSelected
notifyItemChanged(currentList.indexOfFirst { it.id == clipId })
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ClipViewHolder {
val binding = ClipItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ClipViewHolder(binding)
}
override fun onBindViewHolder(holder: ClipViewHolder, position: Int) {
val clip = getItem(position)
Log.d("ClipAdapter", "Binding item at position $position: ${clip.title}")
holder.bind(clip)
}
inner class ClipViewHolder(
private val binding: ClipItemBinding
): RecyclerView.ViewHolder(binding.root) {
fun bind(clip: Clip) {
binding.apply {
if (clip.artUri != null) {
Glide.with(binding.root)
.load(clip.artUri)
.placeholder(R.mipmap.ic_launcher)
.error(R.mipmap.ic_launcher)
.into(albumImage)
} else {
albumImage.setImageResource(R.mipmap.ic_launcher)
}
clipTitle.text = clip.title
clipArtist.text = clip.artist ?: "<unknown>"
if (isSelectionMode) {
moreOptions.visibility = View.GONE
selectionCheckBox.visibility = View.VISIBLE
selectionCheckBox.isChecked = clip.isSelected
selectionCheckBox.setOnCheckedChangeListener { _, isChecked ->
clip.isSelected = isChecked
selectionListener.onItemSelectionChanged(adapterPosition, isChecked)
}
} else {
moreOptions.visibility = View.VISIBLE
selectionCheckBox.visibility = View.GONE
selectionCheckBox.setOnCheckedChangeListener(null)
}
}
}
}
companion object {
val ClipDiffCallback = object : DiffUtil.ItemCallback<Clip>() {
override fun areItemsTheSame(oldItem: Clip, newItem: Clip): Boolean {
val result = oldItem.id == newItem.id
Log.d(
"ClipDiffCallback",
"areItemsTheSame: oldItem.id=${oldItem.id}, newItem.id=${newItem.id}, result=$result"
)
return result
}
override fun areContentsTheSame(oldItem: Clip, newItem: Clip): Boolean {
val result = oldItem.title == newItem.title &&
oldItem.artist == newItem.artist &&
oldItem.artUri == newItem.artUri &&
oldItem.isSelected == newItem.isSelected
Log.d(
"ClipDiffCallback",
"areContentsTheSame: oldItem=$oldItem, newItem=$newItem, result=$result"
)
return result
}
}
}
}
HomeFragment This fragment manages the RecyclerView and Selection Mode
interface OnItemSelectionListener {
fun onItemSelectionChanged(position: Int, isSelected: Boolean)
}
@AndroidEntryPoint
class HomeFragment : Fragment(), MenuProvider, OnItemSelectionListener {
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!
private lateinit var requestPermissionLauncher: ActivityResultLauncher<String>
private val clipViewModel by viewModels<ClipViewModel>()
private val selectedPositions: MutableSet<Int> = mutableSetOf()
private var clipAdapter: ClipAdapter? = null
private var mMainActivityUiController: MainActivityUiController? = null
private var actionMode: ActionMode? = null
private var originalClips: List<Clip> = emptyList()
private var clipPosition: Int = 0
private var menu: Menu? = null
private var isSelectionMode: Boolean = false
set(value) {
field = value
clipAdapter?.isSelectionMode = value
if (!value) {
selectedPositions.clear()
val newList = clipAdapter?.currentList?.map { it.copy(isSelected = false) }
binding.clipsRV.post { clipAdapter?.submitList(newList) }
}
}
private var menuHost: MenuHost? = null
fun hideMenu() {
isSelectionMode = true
menu?.setGroupVisible(0, false)
}
fun showMenu() {
isSelectionMode = false
menu?.setGroupVisible(0, true)
(requireActivity() as MainActivity).getToolbar().visibility = View.VISIBLE
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentHomeBinding.inflate(inflater, container, false)
Log.d(TAG, "Adding MenuProvider for HomeFragment")
menuHost = requireActivity()
menuHost?.addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.STARTED)
this.clipAdapter = ClipAdapter(this)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupPermissionLauncher()
checkAndRequestPermissions()
observeClips()
setupRecyclerView()
binding.clipsRV.visibility = View.VISIBLE
binding.customSearchView.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
Log.d(TAG, "Filtering with query: $s")
filterSelectedClips(s.toString())
}
override fun afterTextChanged(s: Editable?) {}
})
if (activity is MainActivityUiController) {
mMainActivityUiController = activity as MainActivityUiController
}
}
override fun onResume() {
super.onResume()
(requireActivity() as MainActivity).getToolbar().visibility = View.VISIBLE
mMainActivityUiController?.showTabLayout()
requireActivity().invalidateOptionsMenu()
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
this.menu = menu
menu.clear()
menuInflater.inflate(R.menu.clips_home_menu, menu)
customizeMenuItem(menu, R.id.sortBy, "Sort by")
customizeMenuItem(menu, R.id.change_layout, "Change layout")
if (isSelectionMode) {
menu.setGroupVisible(0, false)
}
setupSearchView(menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.select -> {
isSelectionMode = !isSelectionMode
if (isSelectionMode) {
startSelectionMode()
} else {
actionMode?.finish()
}
true
}
R.id.sortBy -> {
true
}
else -> false
}
}
true
}
R.id.change_layout -> {
true
}
else -> false
}
}
private fun setupSearchView(menu: Menu) {
val searchManager = requireActivity().getSystemService(SEARCH_SERVICE) as SearchManager
val searchView = menu.findItem(R.id.app_bar_search).actionView as SearchView
searchView.setSearchableInfo(searchManager.getSearchableInfo(requireActivity().componentName))
searchView.queryHint = getString(R.string.library_search)
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(keyword: String): Boolean {
if (keyword.isEmpty()) {
Snackbar.make(
binding.root,
"please enter keyword to search",
Snackbar.LENGTH_SHORT
).show()
return false
}
filterClips(keyword)
return false
}
override fun onQueryTextChange(newText: String): Boolean {
return true
}
})
}
private fun filterClips(keyword: String) {
val filteredList = if (keyword.isEmpty()) {
originalClips
} else {
originalClips.filter {
it.title.contains(keyword, ignoreCase = true)
}
}
clipAdapter?.submitList(filteredList)
}
private fun filterSelectedClips(keyword: String) {
val filteredList = if (keyword.isEmpty()) {
originalClips
} else {
originalClips.filter {
it.title.contains(keyword, ignoreCase = true)
}
}
clipAdapter?.submitList(filteredList)
}
private fun startSelectionMode() {
isSelectionMode = true
hideMenu()
binding.customSearchView.visibility = View.VISIBLE
mMainActivityUiController?.hideTabLayout()
actionMode = (requireActivity() as MainActivity).startSupportActionMode(object :
ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
(requireActivity()as MainActivity).getToolbar().visibility = View.GONE
mode.menuInflater.inflate(R.menu.select_all_menu, menu)
mode.title = "${selectedPositions.size} clips selected"
updateActionModeTitle()
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_select_all -> {
selectAll()
true
}
else -> false
}
}
override fun onDestroyActionMode(mode: ActionMode) {
isSelectionMode = false
clipAdapter?.isSelectionMode = false
showMenu()
mMainActivityUiController?.showTabLayout()
binding.customSearchView.visibility = View.GONE
binding.customSearchView.text.clear()
clipAdapter?.submitList(originalClips)
actionMode = null
binding.clipsRV.scrollToPosition(0)
requireActivity().invalidateOptionsMenu()
}
})
}
private fun updateActionModeTitle() {
val count = selectedPositions.size
actionMode?.title = "$count selected clips"
}
override fun onItemSelectionChanged(position: Int, isSelected: Boolean) {
if (isSelected) {
selectedPositions.add(position)
} else {
selectedPositions.remove(position)
}
val newList = clipAdapter?.currentList!!.toMutableList()
newList[position] = newList[position].copy(isSelected = isSelected)
binding.clipsRV.post { clipAdapter?.submitList(newList) }
updateActionModeTitle()
}
private fun selectAll() {
if (clipAdapter?.currentList?.size == selectedPositions.size) {
selectedPositions.clear()
val newList = clipAdapter?.currentList!!.map { it.copy(isSelected = false) }
binding.clipsRV.post { clipAdapter?.submitList(newList) }
} else {
selectedPositions.clear()
selectedPositions.addAll(clipAdapter?.currentList!!.indices)
val newList = clipAdapter?.currentList?.map { it.copy(isSelected = true) }
binding.clipsRV.post { clipAdapter?.submitList(newList) }
}
updateActionModeTitle()
}
private fun setupPermissionLauncher() {
requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
clipViewModel.syncAndFetchClips()
} else {
val permissionToRequest =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_AUDIO
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (!shouldShowRequestPermissionRationale(permissionToRequest)) {
AlertDialog.Builder(requireContext())
.setTitle("Permission Denied")
.setMessage("This app needs access to your music files to display and play them. Please enable the permission in app settings.")
.setPositiveButton("Go to Settings") { _, _ ->
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data =
Uri.fromParts("package", requireContext().packageName, null)
startActivity(intent)
}
.setNegativeButton("Cancel") { _, _ ->
Toast.makeText(
requireContext(),
"Permission denied. Cannot access music files.",
Toast.LENGTH_SHORT
).show()
}
.show()
} else {
Toast.makeText(
requireContext(),
"Permission denied. Cannot access music files.",
Toast.LENGTH_SHORT
).show()
}
}
}
}
private fun checkAndRequestPermissions() {
val permissionToRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_AUDIO
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
if (ContextCompat.checkSelfPermission(
requireContext(),
permissionToRequest
) != PackageManager.PERMISSION_GRANTED
) {
if (shouldShowRequestPermissionRationale(permissionToRequest)) {
showPermissionRationaleDialog(permissionToRequest)
} else {
requestPermissionLauncher.launch(permissionToRequest)
}
} else {
clipViewModel.syncAndFetchClips()
}
}
private fun showPermissionRationaleDialog(permission: String) {
AlertDialog.Builder(requireContext())
.setTitle("Permission Required")
.setMessage("This app needs access to your music files to display and play them. Please grant the permission to continue.")
.setPositiveButton("Grant") { _, _ ->
requestPermissionLauncher.launch(permission)
}
.setNegativeButton("Deny") { _, _ ->
Toast.makeText(
requireContext(),
"Permission denied. Cannot access music files.",
Toast.LENGTH_SHORT
).show()
}
.show()
}
private fun observeClips() {
lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
clipViewModel.clipList.collect { resource ->
when (resource) {
is Resource.Loading -> {
binding.progressBar.visibility = View.VISIBLE
}
is Resource.Success -> {
binding.progressBar.visibility = View.GONE
originalClips = resource.data ?: emptyList()
Log.d(TAG, "Clips loaded, size: ${originalClips.size}")
clipAdapter?.submitList(originalClips) {
Log.d(TAG, "Submitted original clips to clipAdapter")
}
}
is Resource.Error -> {
binding.progressBar.visibility = View.GONE
Toast.makeText(
requireContext(),
"Error: ${resource.message}",
Toast.LENGTH_SHORT
).show()
}
is Resource.Empty -> {
binding.progressBar.visibility = View.GONE
}
else -> {}
}
}
}
}
}
private fun setupRecyclerView() {
binding.clipsRV.apply {
setHasFixedSize(true)
setItemViewCacheSize(10)
layoutManager =
LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
adapter = clipAdapter
onItemClick { _, position, _ ->
if (!isSelectionMode) {
clipPosition = position
val navController = requireActivity()
.findNavController(R.id.nav_host_fragment_container)
(requireActivity() as MainActivity).showNavHostFragmentContainer()
if (navController.currentDestination?.id == R.id.homeFragment) {
navController.navigate(
HomeFragmentDirections.actionHomeFragmentToPlayerFragment(
clipPosition,
clipsArray = clipAdapter?.currentList!!.toTypedArray()
)
)
}
} else {
val clip = clipAdapter?.currentList[position]
val newCheckedState = !clip?.isSelected!!
clip.isSelected = newCheckedState
onItemSelectionChanged(position, newCheckedState)
}
}
onItemCheckChange { _, position, isChecked ->
if (isSelectionMode) {
onItemSelectionChanged(position, isChecked)
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
Log.d(TAG, "Removing MenuProvider for HomeFragment")
clipAdapter = null
menuHost?.removeMenuProvider(this)
binding.clipsRV.removeItemClickSupport()
_binding = null
}
}
And I handle clicks and checkboxes with this class ItemClickSupport.kt
class ItemClickSupport private constructor(private val recyclerView: RecyclerView) {
private var onItemClickListener: OnRecyclerViewItemClickListener? = null
private var onItemLongClickListener: OnRecyclerViewItemLongClickListener? = null
private var onItemCheckChangeListener: OnRecyclerViewItemCheckChangeListener? = null
private val attachListener: RecyclerView.OnChildAttachStateChangeListener = object : RecyclerView.OnChildAttachStateChangeListener {
override fun onChildViewAttachedToWindow(view: View) {
val holder = this@ItemClickSupport.recyclerView.getChildViewHolder(view)
val isClickable = (holder as? ItemClickSupportViewHolder)?.isClickable ?: true
val isLongClickable = (holder as? ItemClickSupportViewHolder)?.isLongClickable ?: true
if (onItemClickListener != null && isClickable) {
view.setOnClickListener(onClickListener)
}
if (onItemLongClickListener != null && isLongClickable) {
view.setOnLongClickListener(onLongClickListener)
}
val checkBox = view.findViewById<CheckBox>(R.id.selectionCheckBox)
if (checkBox != null && onItemCheckChangeListener != null) {
checkBox.setOnCheckedChangeListener { _, isChecked ->
val position = holder.getAdapterPosition()
if (position != RecyclerView.NO_POSITION) {
onItemCheckChangeListener?.invoke(recyclerView, position, isChecked)
}
}
}
}
override fun onChildViewDetachedFromWindow(view: View) {
val checkBox = view.findViewById<CheckBox>(R.id.selectionCheckBox)
checkBox?.setOnCheckedChangeListener(null)
}
}
init {
this.recyclerView.setTag(R.id.item_click_support, this)
this.recyclerView.addOnChildAttachStateChangeListener(attachListener)
}
companion object {
fun addTo(view: RecyclerView): ItemClickSupport {
var support: ItemClickSupport? = view.getTag(R.id.item_click_support) as? ItemClickSupport
if (support == null) {
support = ItemClickSupport(view)
}
return support
}
fun removeFrom(view: RecyclerView): ItemClickSupport? {
val support = view.getTag(R.id.item_click_support) as? ItemClickSupport
support?.detach(view)
return support
}
}
private val onClickListener = View.OnClickListener { v ->
val listener = onItemClickListener ?: return@OnClickListener
val holder = this.recyclerView.getChildViewHolder(v)
val position = holder.getAdapterPosition()
if (position != RecyclerView.NO_POSITION) {
listener.invoke(this.recyclerView, position, v)
}
}
private val onLongClickListener = View.OnLongClickListener { v ->
val listener = onItemLongClickListener ?: return@OnLongClickListener false
val holder = this.recyclerView.getChildViewHolder(v)
val position = holder.getAdapterPosition()
if (position != RecyclerView.NO_POSITION) {
return@OnLongClickListener listener.invoke(this.recyclerView, position, v)
}
return@OnLongClickListener false
}
private fun detach(view: RecyclerView) {
view.removeOnChildAttachStateChangeListener(attachListener)
view.setTag(R.id.item_click_support, null)
}
fun onItemClick(listener: OnRecyclerViewItemClickListener?): ItemClickSupport {
onItemClickListener = listener
return this
}
fun onItemLongClick(listener: OnRecyclerViewItemLongClickListener?): ItemClickSupport {
onItemLongClickListener = listener
return this
}
fun onItemCheckChange(listener: OnRecyclerViewItemCheckChangeListener?): ItemClickSupport {
onItemCheckChangeListener = listener
return this
}
}
interface ItemClickSupportViewHolder {
val isClickable: Boolean get() = true
val isLongClickable: Boolean get() = true
}
fun RecyclerView.addItemClickSupport(configuration: ItemClickSupport.() -> Unit = {}) = ItemClickSupport.addTo(this).apply(configuration)
fun RecyclerView.removeItemClickSupport() = ItemClickSupport.removeFrom(this)
fun RecyclerView.onItemClick(onClick: OnRecyclerViewItemClickListener) {
addItemClickSupport { onItemClick(onClick) }
}
fun RecyclerView.onItemLongClick(onLongClick: OnRecyclerViewItemLongClickListener) {
addItemClickSupport { onItemLongClick(onLongClick) }
}
fun RecyclerView.onItemCheckChange(onCheckChange: OnRecyclerViewItemCheckChangeListener) {
addItemClickSupport { onItemCheckChange(onCheckChange) }
}
This image shows the current two issues when I enter the selection mode and change all three dots with checkboxes, and shows my custom search editText
[Solved] Fragment onCreateView being called twice with ViewPager2 and NavHostFragment (Causing UI Issues)
After significant debugging, I've identified and resolved the issues described in my question, where my customSearchView
and item CheckBox
were not appearing correctly during selection mode in my HomeFragment
. The root cause was subtle, stemming from the interaction between ViewPager2
and NavHostFragment
within my MainActivity
.
Original Problems Recap:
My customSearchView
(an EditText) in fragment_home.xml
failed to become visible when selection mode was programmatically activated.
The CheckBox
within my RecyclerView items (clip_item.xml
) also remained invisible in selection mode, despite adapter logic setting its visibility to VISIBLE
.
Debugging and Root Cause Discovery:
By adding detailed lifecycle logging (onCreate
, onCreateView
, onViewCreated
with instance logging) to HomeFragment
and logging createFragment
in my ViewPagerAdapter
, I confirmed that HomeFragment
was being instantiated twice during the initial launch of MainActivity
.
Instance 1: Correctly created by ViewPagerAdapter
for the first ViewPager tab (position 0).
Instance 2: Incorrectly created by the NavHostFragment
located in the @+id/nav_host_fragment_container
because my nav_graph.xml
had app:startDestination="@id/homeFragment"
.
This double instantiation, managed by two different systems (FragmentStateAdapter
and NavHostFragment
), created lifecycle conflicts and state inconsistencies. This explained why UI updates (like making the search view visible or updating RecyclerView items to show checkboxes) were failing or behaving unpredictably. The updates were likely targeting one instance while the other was potentially being displayed or measured, or state management between the two became unreliable. The double permission requests I initially encountered were also a direct symptom of this duplicate fragment creation.
The Solution: Fixing the Conflicting startDestination
The key was to ensure only one system was responsible for creating the initial HomeFragment
. Since it belongs in the ViewPager2
, I needed to prevent the NavHostFragment
from creating it as well. (My NavHostFragment
is primarily used for navigating to detail screens like PlayerFragment
from the tabs).
Created a Placeholder Fragment: I introduced a simple, empty fragment (NavHostPlaceholderFragment
) solely to serve as a neutral starting point for the NavHost.
Kotlin
// NavHostPlaceholderFragment.kt
package com.example.mmlaudioplayer.ui // My package name
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import android.util.Log // Added Log
class NavHostPlaceholderFragment : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
Log.d("NavHostPlaceholder", "onCreateView called for placeholder")
return null // No view needed
}
}
Modified nav_graph.xml
: I changed the app:startDestination
attribute in the root <navigation>
tag to point to the ID of this new placeholder fragment. The <fragment>
definition for homeFragment
itself remains necessary to define the action
used for navigating away from it.
XML
<navigation ...
app:startDestination="@id/navHostPlaceholderFragment"> <fragment
android:id="@+id/navHostPlaceholderFragment"
android:name="com.example.mmlaudioplayer.ui.NavHostPlaceholderFragment"
android:label="NavHostPlaceholderFragment" />
<fragment
android:id="@+id/homeFragment"
android:name="com.example.mmlaudioplayer.ui.HomeFragment"
...>
<action
android:id="@+id/action_homeFragment_to_playerFragment"
app:destination="@id/playerFragment" ... />
</fragment>
<fragment android:id="@+id/playerFragment" ... > ... </fragment>
</navigation>
Outcome:
After implementing this change (and ensuring a clean build/install), HomeFragment
is now created only once by the ViewPagerAdapter
. The double lifecycle calls are gone, the permission requests behave correctly, and most importantly, the UI updates for entering selection mode now work as expected – both the customSearchView
and the item CheckBoxes
appear reliably because the logic is operating on the single, correct fragment instance.
Issue Tracker Reference:
For others who might encounter this specific interaction between ViewPager2
/FragmentStateAdapter
and a NavHostFragment
using the same fragment class for a page and as the startDestination
, I have reported this behavior to the Google Issue Tracker: https://issuetracker.google.com/issues/411460072 (You may want to star the issue or add your own findings if you encounter this).