When I switch an Android Switch on or off, why do Switches in adjacent rows within a Recycler View also switch?

25 Views Asked by At

I am implementing a custom settings system where each setting is represented as a row in a RecyclerView. Several of the settings are on/off settings, so I include a Switch UI element at the end of the row layout. I have implemented the adapter logic with both a ListAdapter and RecyclerView.Adapter, and both end up with this issue. When I have two of the on/off rows next to each other, the Switches seem to share state. When I toggle one of them on or off, the other will make the same change. This only happens after switching one Switch, then the other, then the first one again.

What I'm expecting to happen of course is that the Switch elements maintain their individual states and don't change when their neighbor Switch changes.

I've tried both the ListAdapter and RecyclerView.Adapter. I've also tried briefly with a BaseAdapter with ListView implementation, and didn't see this issue, but I'd like to use more up-to-date systems for this. I've checked to be sure the view ids and view holder data are correct when the switch change is triggered. I just cannot figure out why the Switches will toggle on their own when I toggle one of their neighbors. I presume it has something to do with how the adapters are recycling the views, but I re-set the listeners each time in onBindViewHolder so I don't get how they are sharing that state.

I've also tried adding checks in onBindViewHolder to not set the Switch state for the row if the Switch is already in the correct state.

Screenshot showing adjacent Switches.

Edit:

Here is the relevant adapter code, view holder class, and data object class for the Switch case.



class AppPreferenceListAdapter(
    private val fragmentManager: FragmentManager,
    private val onItemClickListener: OnItemClickListener? = null
) : ListAdapter<Configurable, AppPreferenceViewHolder>(DIFF_CALLBACK) {

    companion object {
        private const val TAG = "AppPreferenceListAdapter"

        val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Configurable>() {
            override fun areItemsTheSame(oldItem: Configurable, newItem: Configurable): Boolean {
                return oldItem.uid == newItem.uid
            }

            override fun areContentsTheSame(oldItem: Configurable, newItem: Configurable): Boolean {
                return oldItem.areContentsTheSame(newItem)
            }
        }
    }

    /**
     * Return the display type of the item at the specified position so that
     * onCreateViewHolder can inflate the correct layout.
     */
    override fun getItemViewType(position: Int): Int = getItem(position).displayType.ordinal

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppPreferenceViewHolder {
        val layoutId = when (viewType) {
            DisplayType.Default.ordinal -> R.layout.app_preference_row
            DisplayType.Switch.ordinal -> R.layout.app_preference_switch_row
            DisplayType.CheckBox.ordinal -> R.layout.app_preference_check_row
            DisplayType.LaunchActivity.ordinal -> R.layout.app_preference_launch_activity_row
            DisplayType.Option.ordinal -> R.layout.app_preference_option_row
            DisplayType.ColorPicker.ordinal -> R.layout.app_preference_color_picker_row
            DisplayType.NumberPicker.ordinal -> R.layout.app_preference_number_picker_row
            DisplayType.VolumeLevel.ordinal -> R.layout.app_preference_volume_picker_row
            DisplayType.SoundSelector.ordinal -> R.layout.app_preference_sound_selector_row
            else -> throw IllegalArgumentException("Invalid view type: $viewType")
        }
        val view = LayoutInflater.from(parent.context).inflate(layoutId, parent, false)
        return when (viewType) {
            DisplayType.Default.ordinal -> DefaultViewHolder(view)
            DisplayType.Switch.ordinal -> SwitchViewHolder(view)
            DisplayType.CheckBox.ordinal -> CheckboxViewHolder(view)
            DisplayType.LaunchActivity.ordinal -> LaunchActivityViewHolder(view)
            DisplayType.Option.ordinal -> OptionViewHolder(view)
            DisplayType.ColorPicker.ordinal -> ColorPickerViewHolder(view)
            DisplayType.NumberPicker.ordinal -> NumberPickerViewHolder(view)
            DisplayType.VolumeLevel.ordinal -> VolumeLevelViewHolder(view)
            DisplayType.SoundSelector.ordinal -> SoundSelectorViewHolder(view)
            else -> throw IllegalArgumentException("Invalid view type: $viewType")
        }
    }

    override fun onBindViewHolder(holder: AppPreferenceViewHolder, position: Int) {
        val configurable = getItem(position)
        holder.titleTextView.text = configurable.configurableTitle()
        holder.subtitleTextView.text = configurable.configurableSubtitle() ?: ""
        bindViews(holder, configurable)
        holder.subtitleTextView.visibility = if (holder.subtitleTextView.text.isNullOrEmpty()) View.GONE else View.VISIBLE
    }

    private fun bindViews(holder: AppPreferenceViewHolder, configurable: Configurable) {
        when {
            configurable is SwitchConfigurable && holder is SwitchViewHolder -> {
                holder.switch.isChecked = configurable.isChecked
                holder.switch.setOnCheckedChangeListener { _, isChecked ->
                    configurable.isChecked = isChecked
                }
                holder.itemView.setOnClickListener {
                    holder.switch.toggle()
                }
            }

            configurable is CheckBoxConfigurable && holder is CheckboxViewHolder -> {
..... [Continued switch case]
}

class SwitchViewHolder(view: View) : AppPreferenceViewHolder(view) {
    override val titleTextView: TextView = view.findViewById(R.id.switch_preference_row_title)
    override val subtitleTextView: TextView = view.findViewById(R.id.switch_preference_row_subtitle)
    val switch: SwitchCompat = view.findViewById(R.id.preference_row_switch)
}



abstract class SwitchConfigurable(changeHandler: ChangeHandler? = null) : ChildConfigurable(changeHandler) {
    open var isChecked: Boolean = false
        set(value) {
            if (value == isChecked) return
            field = value
            contentsUpToDate = false
            changeHandler?.onConfigurableChanged(this)
        }
    override val displayType: DisplayType
        get() = DisplayType.Switch

    override fun toXml(): String = "${configID()}=\"$isChecked\""
}

Each preferences fragment overrides that onConfigurableChanged method and re-submits the list of settings for that screen to the ListAdapter:

appPreferenceListAdapter.submitList(configurable.childrenConfigurables)

I added logs to the onCheckedChangedListener for the switch, as well as when the ListAdapter's diff callback checks for whether the state of the row has changed. That looks like the following when I reproduce the issue:

* turn switch 1 off

13:48:42.301 AppPreferenceListAdapter            bindViews: SwitchConfigurable Use Pin Code isChecked changed to false
13:48:42.301 AppPreferenceFragment               onConfigurationChanged() called with: restored = false
13:48:42.303 Configurable                        (UsePINCode) areContentsTheSame: returning false
13:48:42.303 Configurable                        (UseTouchID) areContentsTheSame: returning true
13:48:42.303 Configurable                        (ShowCash) areContentsTheSame: returning true


turn switch 2 off 

13:48:49.540 AppPreferenceListAdapter            bindViews: SwitchConfigurable Biometrics isChecked changed to false
13:48:49.541 AppPreferenceFragment               onConfigurationChanged() called with: restored = false
13:48:49.542 Configurable                        (UsePINCode) areContentsTheSame: returning true
13:48:49.542 Configurable                        (UseTouchID) areContentsTheSame: returning false
13:48:49.542 Configurable                        (ShowCash) areContentsTheSame: returning true


turn switch 1 back on 

13:48:57.609 AppPreferenceListAdapter            bindViews: SwitchConfigurable Use Pin Code isChecked changed to true
13:48:57.609 AppPreferenceFragment               onConfigurationChanged() called with: restored = false
13:48:57.610 Configurable                        (UsePINCode) areContentsTheSame: returning false
13:48:57.610 Configurable                        (UseTouchID) areContentsTheSame: returning true
13:48:57.610 Configurable                        (ShowCash) areContentsTheSame: returning true
13:48:57.632 AppPreferenceListAdapter            bindViews: SwitchConfigurable Biometrics isChecked changed to true
13:48:57.632 AppPreferenceFragment               onConfigurationChanged() called with: restored = false
13:48:57.632 Configurable                        (UsePINCode) areContentsTheSame: returning true
13:48:57.632 Configurable                        (UseTouchID) areContentsTheSame: returning false
13:48:57.632 Configurable                        (ShowCash) areContentsTheSame: returning true


Now, both switches are in the on position
0

There are 0 best solutions below