RecyclerView and notifyDataSetChanged LongClick mismatch - kotlin

I'm having a weird problem with notifyDataSetChanged() in my Recycler Adapter. If I keep 5 items in an array the code works fine and I can check the checkbox at the item I LongClick, but when I add 5 items or more to the array other checkboxes get checked in my list.
I am using a boolean to toggle between VISIBLE and GONE on the checkboxes when the user LongClicks as well.
Here is my code:
class RecyclerAdapter(private val listActivity: ListActivity) : RecyclerView.Adapter<RecyclerAdapter.Holder>() {
lateinit var binding: ActivityListItemRowBinding
var checkboxesVisibility = false
val dummyArrayWorks = arrayOf("000", "111", "222", "333", "444")
val dummyArrayFails = arrayOf("000", "111", "222", "333", "444", "555")
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
binding = ActivityListItemRowBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return Holder(binding)
}
override fun getItemCount(): Int = dummyArrayFails.size
#SuppressLint("NotifyDataSetChanged")
override fun onBindViewHolder(holder: Holder, position: Int) {
val item = dummyArrayFails[position]
holder.binding.checkbox.visibility = if (checkboxesVisibility) VISIBLE else GONE
holder.bindItem(item)
holder.itemView.setOnLongClickListener {
if (!checkboxesVisibility) {
checkboxesVisibility = true
holder.binding.checkbox.isChecked = true
notifyDataSetChanged()
true
} else {
false
}
}
holder.itemView.setOnClickListener {
if (!checkboxesVisibility) {
//Some other unrelated code
} else {
holder.binding.checkbox.isChecked = !holder.binding.checkbox.isChecked
notifyDataSetChanged()
}
}
}
class Holder(internal val binding: ActivityListItemRowBinding) : RecyclerView.ViewHolder(binding.root) {
var item = String()
fun bindItem(item: String) {
this.item = item
binding.itemPlaceHolder.text = item
}
}
}
I should add that when I remove the toggle for the checkboxes, and just show the checkboxes on first load, the clicks match the checkmarks without a problem.
Does anybody have any idea of what is going on? All help will be much appreciated!

The problem is you're holding your checked state in the ViewHolder itself - you're toggling its checkbox on and off depending on clicks, right?
The way a RecyclerView works is that instead of having a ViewHolder for every single item (like a ListView does), it only creates a handful of them - enough for what's on screen and a few more for scrolling - and recycles those, using them to display different items.
That's what onBindViewHolder is about - when it needs to display the item at position, it hands you a ViewHolder from its pool and says here you go, use that to display this item's details. This is where you do things like setting text, changing images, and setting things like checkbox state to reflect that particular item.
What you're doing is you're not storing the item's state anywhere, you're just setting the checkbox on the view holder. So if you check it, every item that happens to be displayed in that reusable holder object will have its box ticked. That's why you're seeing it pop up on other items - that checked state has nothing to do with the items themselves, just which view holder they all happen to use because of their position in the list.
So instead, you need to keep their checked state somewhere - it could be as simple as a boolean array that matches the length of your item list. Then you just set and get from that when binding your data (displaying it). Working with what you've got:
// all default to false
val itemChecked = BooleanArray(items.size)
override fun onBindViewHolder(holder: Holder, position: Int) {
...
// when displaying the data, refer to the checked state we're holding
holder.binding.checkbox.checked = itemChecked[position]
...
holder.itemView.setOnLongClickListener {
...
// when checking the box, update our checked state
// since we're calling notifyDataSetChanged, the item will be redisplayed
// and onBindViewHolder will be called again (which sets the checkbox)
itemChecked[position] = true
// notifyItemChanged(position) is better here btw, just refreshes this one
notifyDataSetChanged()
...
}
}

Related

Can you change the color of a textview in a recyclerview adapter after a certain condition is met in Main Activity?

I have a basic function that displays the elapsed time every time the button is pressed. I cannot get the logic in MainActivity to transfer to the recyclerview adapter. I simply want the text output color to change to red after the time passes 5 seconds. I have tried to research how to do this for the past week and I cannot find the exact answer. I'm hoping someone can help.
I have tried it with and without the boolean in the data class. I wasn't sure if that was required.
Here is my code:
Main Activity:`
class MainActivity : AppCompatActivity() {
var startTime = SystemClock.elapsedRealtime()
var displaySeconds = 0
private lateinit var binding: ActivityMainBinding
private val secondsList = generateSecondsList()
private val secondsAdapter = Adapter(secondsList)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
recyclerView.adapter = secondsAdapter
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.setHasFixedSize(false)
binding.button.setOnClickListener {
getDuration()
addSecondsToRecyclerView()
}
}
fun getDuration(): Int {
val endTime = SystemClock.elapsedRealtime()
val elapsedMilliSeconds: Long = endTime - startTime
val elapsedSeconds = elapsedMilliSeconds / 1000.0
displaySeconds = elapsedSeconds.toInt()
return displaySeconds
}
private fun generateSecondsList(): ArrayList<Seconds> {
return ArrayList()
}
fun addSecondsToRecyclerView() {
val addSeconds =
Seconds(getDuration(), true)
secondsList.add(addSeconds)
secondsAdapter.notifyItemInserted(secondsList.size - 1)
}
}
Adapter:
var adapterSeconds = MainActivity().getDuration()
class Adapter(
private val rvDisplay: MutableList<Seconds>
) : RecyclerView.Adapter<Adapter.AdapterViewHolder>() {
class AdapterViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val textView1: TextView = itemView.tv_seconds
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AdapterViewHolder {
val myItemView = LayoutInflater.from(parent.context).inflate(
R.layout.rv_item,
parent, false
)
return AdapterViewHolder(myItemView)
}
override fun onBindViewHolder(holder: Adapter.AdapterViewHolder, position: Int) {
val currentDisplay = rvDisplay[position]
currentDisplay.isRed = adapterSeconds > 5
holder.itemView.apply {
val redColor = ContextCompat.getColor(context, R.color.red).toString()
val blackColor = ContextCompat.getColor(context, R.color.black).toString()
if (currentDisplay.isRed) {
holder.textView1.setTextColor(redColor.toInt())
holder.textView1.text = currentDisplay.rvSeconds.toString()
} else {
holder.textView1.setTextColor(blackColor.toInt())
holder.textView1.text = currentDisplay.rvSeconds.toString()
}
}
}
override fun getItemCount() = rvDisplay.size
}
Data Class:
data class Seconds(
var rvSeconds: Int,
var isRed: Boolean
)
when you call secondsList.add(addSeconds) then the data that is already inside secondsList should be updated too.
you could do something like
private var secondsList = generateSecondsList() // make this var
fun addSecondsToRecyclerView() {
val addSeconds =
Seconds(getDuration(), true)
secondsList.add(addSeconds)
if ( /* TODO check if time has passed */) {
secondsList = secondsList.map { it.isRed = true }
secondsAdapter.rvDisplay = secondsList // TODO also make rvDisplay a var
secondsAdapter.notifyDatasetChanged() // also need to tell rv to redraw the all views
} else {
secondsAdapter.notifyItemInserted(secondsList.size - 1)
}
}
that might work, but to be honest it looks bad... There is already a lot of logic inside Activity. Read about MVVM architecture and LiveData, there should be another class called ViewModel that would keep track of time and the data. Activity should be as simple as possible, because it has lifecycle, so if you rotate the screen, all your state will be lost.
Your code isn't really working because of this:
var adapterSeconds = MainActivity().getDuration()
override fun onBindViewHolder(holder: Adapter.AdapterViewHolder, position: Int) {
...
currentDisplay.isRed = adapterSeconds > 5
...
}
You're only setting adapterSeconds right there, so it never updates as time passes. I assume you want to know the moment 5 seconds has elapsed, and then update the RecyclerView at that moment - in that case you'll need some kind of timer task that will fire after 5 seconds, and can tell the adapter to display things as red. Let's deal with that first:
class Adapter( private val rvDisplay: MutableList ) : RecyclerView.Adapter<Adapter.AdapterViewHolder>() {
private var displayRed = false
set(value) {
field = value
// Refresh the display - the ItemChanged methods mean something about the items
// has changed, rather than a structural change in the list
// But you can use notifyDataSetChanged if you want (better to be specific though)
notifyItemRangeChanged(0, itemCount)
}
override fun onBindViewHolder(holder: Adapter.AdapterViewHolder, position: Int) {
if (displayRed) {
// show things as red - you shouldn't need to store that state in the items
// themselves, it's not about them - it's an overall display state, right?
} else {
// display as not red
}
}
So with that setter function, every time you update displayRed it'll refresh the display, which calls onBindViewHolder, which checks displayRed to see how to style things. It's better to put all this internal refreshing stuff inside the adapter - just pass it data and events, let it worry about what needs to happen internally and to the RecyclerView it's managing, y'know?
Now we have a thing we can set to control how the list looks, you just need a timer to change it. Lots of ways to do this - a CountdownTimer, a coroutine, but let's keep things simple for this example and just post a task to the thread's Looper. We can do that through any View instead of creating a Handler:
// in MainActivity
recyclerView.postDelayed({ secondsAdapter.displayRed = true }, 5000)
That's it! Using any view, post a delayed function that tells the adapter to display as red.
It might be more helpful to store that runnable as an object:
private val showRedTask = Runnable { secondsAdapter.displayRed = true }
...
recyclerView.postDelayed(showRedTask, 5000)
because then you can easily cancel it
recyclerView.removeCallbacks(showRedTask)
Hopefully that's enough for you to put some logic together to get what you want. Set displayRed = false to reset the styling, use removeCallbacks to cancel any running task, and postDelayed to start a new countdown. Not the only way to do it, but it's pretty neat!
I finally figured it out using a companion object in Main Activity with a boolean set to false. If the time exceeded 5 seconds, then it set to true.
The adapter was able to recognize the companion object and change the color of seconds to red if they exceeded 5.

Filter Observed ViewModel data for Spinner based on another Spinners selection (LiveData)

I have two Spinners, each populated by ArrayList, which is observed in the ViewModel from the Fragment as below:
InventoryAddEdit Fragment
// Observe ProductGroups and populate Spinner
businessViewModel.allAppDataProductGroups.observe(viewLifecycleOwner, { productGroupArrayList -> // ArrayList<ProductGroupObject>
if (!productGroupArrayList.isNullOrEmpty()){
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, productGroupArrayList)
binding.inventoryAddEditProductGroupSpinner.adapter = adapter
}
})
// Observe ProductTypes and populate Spinner
businessViewModel.allAppDataProductTypes.observe(viewLifecycleOwner, { productTypeArrayList -> // ArrayList<ProductTypeObject>
if (!productTypeArrayList.isNullOrEmpty()){
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, productTypeArrayList)
binding.inventoryAddEditProductTypeSpinner.adapter = adapter
binding.inventoryAddEditProductTypeSpinner.setSelection(17) // Sets default value
}
})
I am trying to avoid keeping data in the Fragment (MVVM), so I am wondering how best to filter the second ArrayList based on the selection of the first.
I thought I could use the onItemSelected method on the first spinner to cancel the observer and reattach, but then filter the newly observed ArrayList by a selection of the first spinner. However, this seems a bit clunky. Another idea was to create another filtered list in the ViewModel, but that will mean more data in the ViewModel.
Is there another option I am missing, please?
For info, the ProductGroupObject and ProductTypeObject look like this:
ProductGroupObject
#IgnoreExtraProperties
data class ProductGroupObject (
//ProductGroup fields (1 fields)
var productGroup: String = "",
#ServerTimestamp
var dateEditedTimestamp: Date? = null,
#Exclude #set:Exclude #get:Exclude
var productGroupID: String = ""
) : Serializable {
override fun toString(): String {
return productGroup
}
}
ProductTypeObject
#IgnoreExtraProperties
data class ProductTypeObject (
//ProductType fields (2 fields)
var productType: String = "",
var productGroup: String = "",
#ServerTimestamp
var dateEditedTimestamp: Date? = null,
#Exclude #set:Exclude #get:Exclude
var productTypeID: String = ""
) : Serializable {
override fun toString(): String {
return productType
}
fun detailsText(): String {
val detailsString = StringBuilder()
if(productTypeID.isNotEmpty()) detailsString.append("$productTypeID\n")
if(productType.isNotEmpty()) detailsString.append("$productType\n")
if(productGroup.isNotEmpty()) detailsString.append("$productGroup\n")
return detailsString.toString()
}
}
So the best solution I came up with was to create a 'full list' and 'filtered list' for each Spinner data set in the ViewModel + 'current selection' object for each Spinner (also kept in the ViewModel).
The 'full list' is populated by the Cloud Database on startup, the 'filtered lists' are filtered depending on the Spinner selection by way of the following code:
binding.inventoryAddEditProductGroupSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener{
override fun onItemSelected(parent:AdapterView<*>?, view: View?, position: Int, id: Long){
val productGroupObject = parent?.selectedItem as ProductGroupObject
if (productGroupObject.productGroupID.isNotEmpty()){
businessViewModel.updateCurrentProductGroupVMLiveData(productGroupObject.productGroupID)
}
}
This updates the 'filtered lists' (filtering the 'full list') which hold data to any of the linked Spinners as below:
fun updateCurrentProductGroupVMLiveData (currentProductGroupId: String) {
val newProductGroup = allAppDataProductGroups.value?.find { productGroup -> productGroup.productGroupID == currentProductGroupId }
_currentProductGroup.value = newProductGroup
if(newProductGroup?.productGroup != null) {
val filteredProductsList = allAppDataProductTypes.value?.filter { productTypeObject -> productTypeObject.productGroup == newProductGroup.productGroup} as ArrayList<ProductTypeObject>
_filteredAppDataProductTypes.value = filteredProductsList
}
// UPDATE OTHER SPINNERS HERE
Log.d(TAG, "updateCurrentProductGroupVMLiveData(): '_currentProductGroupId.value' updated ($currentProductGroupId)")
}
One issue I faced was that the View was nullable because of how LiveData works, which was solved in the following post: Spinner Listener LiveData Issue

How to show "Toast" after scrolling down a certain amount in Kotlin

I want to implement a feature in my app where it shows a Toast/message when a user scrolls down the recyclerview. For example when the user is scrolling down the screen, after they pass about 10 items a Toast pops up saying "Tap the home button to go back to the top"
How would I go about doing this?
I don't know if this would work, but you can try doing it in your adapter. like
when (position) {
10 -> Toast.makeText().show
}
or use an if statment.
Again, I don't know for sure if it would work, but I think so.
I think it's probably preferable to base it on distance scrolled instead of which item has appeared on screen most recently, so the threshold for when the message should be shown is not dependent on screen size. It's also best to keep this behavior out of the Adapter because of separation of concerns.
Here's a scroll listener you could use to do this behavior. I think the code is self-explanatory.
open class OnScrolledDownListener(
private val context: Context,
private val thresholdDp: Int,
var resetOnReturnToTop: Boolean = true
): RecyclerView.OnScrollListener() {
private var eventFired = false
private var y = 0
open fun onScrolledDown() {}
open fun onScrolledBackToTop() {}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
y += dy
val yDp = (y / context.resources.displayMetrics.density).roundToInt()
if (yDp >= thresholdDp && !eventFired) {
eventFired = true
onScrolledDown()
} else if (resetOnReturnToTop && yDp == 0 && eventFired) {
eventFired = false
onScrolledBackToTop()
}
}
}
You can subclass and override the two events for when it scrolls down at least a certain amount for the first time (onScrolledDown), and for when it is scrolled back to the top and resets itself (onScrolledBackToTop).
myRecyclerView.addOnScrollListener(object: OnScrolledDownListener(context, 120) {
override fun onScrolledDown() {
showMyMessage()
}
override fun onScrolledBackToTop() {
hideTheMessage()
}
})

Is it possible to get a recyclerview/listview to navigate to different screens?

So I've seen many Kotlin recyclerview tutorials however they all seem to be based on item reviews/contacts lists which all bring you to the same screen when selected, albeit with the different viewholder content. I was wondering if it's possible to use a standalone recyclerview/listview for navigation instead of a navigation drawer which doesn't seem suitable for extensive directories. And if so are there any tutorials for this out there?
Essentially something like this:
In your adapter, you can set a tag on each view in your RecyclerView in onBindViewHolder, and in the click listener, you can get the tag and act according to which tag it is.
You can make your RecyclerView adapter extend View.onClickListener
override fun onBindViewHolder(holder: OpportunityViewHolder, position: Int) {
// Differentiate between items by having a base class that you implement for your different items
val item = getItem(position)
item.tag = when(item) {
is ThisThing -> "thisThing"
is ThatThing -> "thatThing"
is AnotherThing -> "anotherThing"
}
}
override fun onClick(v: View) {
when(v.tag) {
"thisThing" -> // do something
"thatThing" -> // something else...
}
Of course, you would probably want to use an enum for the tag possibilities, or constant values, or even use the class names
enum class Tag {
THIS_THING, THAT_THING...
}
companion object {
private const val THIS_THING = "THIS_THING"
...
}
// Inside classes
companion object {
val name = ThisThing::class.simpleName
...
}

How to update data in nested child RecyclerView without losing animations/initialising a new adapter?

I have a ParentData class structured as follows:
class ParentData(
//parent data fields
var childList: List<ChildData>
I am populating a parent RecylerView with a list of ParentData and then populating each nested child RecyclerView with the embedded List<ChildData> in onBindViewHolder of the parent RecylerView Adapter (ListAdapter) like so:
val mAdapter = ChildAdapter()
binding.childRecyclerView.apply {
layoutManager = LinearLayoutManager(this.context)
adapter = mAdapter
}
mAdapter.submitList(item.childList)
//item from getItem(position)
I observe a LiveData<List<ParentData>>, so every time the embedded ChildData changes, I submit the new List<ParentData> to my parent recycler, which in turn calls OnBindViewHolder and submits the new `childList' and updates the inner child RecyclerView.
The issue is val mAdapter = ChildAdapter() is called every time the data is updated, resetting the entire child RecyclerView. This results in no item add animations being seen and scroll position being reset.
Is there any way to avoid initialising a new Adapter every time or some other way I can avoid resetting the child RecyclerViews?
I know this is an old question but I have worked on a project that is exactly like that and this answer might help others.
Basically we have a single RecyclerView approach where the main interface has a Parent RecyclerView that receives a list of Pair<ViewType, BaseViewBinder>.
class ParentAdapter: RecyclerView.Adapter<BaseViewHolder>() {
private var mList: List<Pair<Int, BaseViewBinder>> = listOf() //Int is the ViewType
fun updateData(mList: List<Pair<Int, BaseViewBinder>>) {
if (this.mList!= mList) {
this.mList = mList
this.notifySetDataChanged()
...
We observe a MediatorLiveData that feeds a new list to the Parent RecyclerView every time there is new data.
The problem is that this.notifySetDataChanged() will update everything in the parent recyclerview. So the solution for the issue where child RecyclerViews "reset" and scroll back to beggining when a new List is received was solved by sending one extra variable to the ParentAdapter "updateData" function informing which view type in the list have changed, so we can "notifyItemChanged" only that specific index of the list, therefore not refreshing the entire recyclerview ui.
fun updateData(mList: List<Pair<Int, BaseViewBinder>>, sectionUpdated: Int) {
if (this.mList!= mList) {
this.mList = mList
when(sectionUpdated){
SECTION_A -> this.notifyItemChanged(mList.indexOfFirst { it.first == VIEW_TYPE_A })
SECTION_B -> this.notifyItemChanged(mList.indexOfFirst { it.first == VIEW_TYPE_B })
SECTION_C -> this.notifyItemChanged(mList.indexOfFirst { it.first == VIEW_TYPE_C })
}
So basically you need to edit whatever function is generating your LiveData, check for differences in the new data, and return the list and an Int specifying that that specific child changed.
Ex:
private var mListA: List<X>
fun returnSomeLiveData(): LiveData<Pair<Int, List<X>>> {
var result = MutableLiveData<Pair<Int, List<X>>>()
if (mListA != someLiveData.value) { //could be DiffUtils
mListA = someLiveData.value
result.postValue(Pair(0, mListA))
}
return result
}
companion object {
val SECTION_A = 0
val SECTION_B = 1
val SECTION_C = 2
}