I feel like I am being silly,
I want to use MotionLayout on an ViewHolder within my RecyclerView to animate between two states (the current playing song is expanded, while the last playing song is shrunk)
However it seems that the recyclerview is too good, it simply changes the contents without changing the views, i.e. when the current playing song changes, the view is already in the End Transition state, so my transition does nothing.
Same my previously expanded item is rebound into the closed state so my animation does nothing.
So Okay i thought lets check the state of the transition and set the progress, but this leads to the transition not running if i set the progress the line before. I have tried, adding in some delays but no real improvement,
I feel like maybe I am over engineering this, or I am missing something fundamental about how to reset motionlayout animations.. Any help will be much appreciated.
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
if (songs.isNotEmpty() && position < songs.size) {
val current = songs[position]
holder.songName?.text = current.song
holder.albumArt?.setImageResource(current.albumArtId)
holder.artistName?.text = current.artist
var ml = holder.motionLayout
if (current.currentPlaying){
//The view is recycled so its already in the end state... so set it to the start state and animate
if (ml?.progress!! > 0f) {
ml?.progress = 0f //<- This resets the animation state
}
ml?.transitionToEnd() <- but at this point the animation does not work if i have manually set the progress the line above
}else{
if (current.previoussong){
//The view that was last expended is not in the end state, so set it then transation to start
if (ml?.progress!! < 1f) {
ml?.progress = 1f
}
ml?.transitionToStart()
}
}
}
}
Okay incase anyone has the same issue, i found "an" answer to my dilemma.
Insteead of setting the progress, i explicitly set the transition then asked it to transition to end, this worked for expand.
And to get shrink working i had to create a different initial layout with an inverted motionscene, then transition to the end, and set the "previoussong" as a different viewType in my creatViewHolder
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SongViewHolder {
//Use the int to switch?
val itemView : View = when (viewType) {
TYPEPLAYING -> inflater.inflate(R.layout.list_motion_layout, parent, false)
TYPEUPCOMING -> inflater.inflate(R.layout.list_motion_layout, parent, false)
TYPEPREVIOUS -> inflater.inflate(R.layout.list_motion_layout_shrink, parent, false)
else
-> inflater.inflate(R.layout.footer, parent, false)
}
...
}
override fun onBindViewHolder(holder: SongViewHolder, position: Int) {
if (songs.isNotEmpty() && position < songs.size) {
val current = songs[position]
holder.songName?.text = current.song
holder.albumArt?.setImageResource(current.albumArtId)
holder.artistName?.text = current.artist
var ml = holder.motionLayout
if (current.currentPlaying){
ml?.setTransition(list_motion_layout_start, currentplaying_song)
ml?.transitionToEnd()
}
if (current.previouslyPlaying) {
ml?.setTransition(currentplaying_song, list_motion_layout_start) // Not sure if this is actually required
ml?.transitionToEnd()
}
}
}
All in all i have a working expand/shrink list adapter using motionview it looks quite nice, but there is probably nicer ways to do this out there ,,, sorry cant share any pics.
Related
I am attempting to write a recyclerview which has some of the Viewholders inside it as stacked ontop of one another. The idea is that you can drag the topmost view above the stacked list and have drop it above where it becomes separate.
I managed to get this working using a Recyclerview with a custom RecyclerView.ItemDecoration. However, after I drop the item, i have the adapter call notifyDataSetChange to update the background code. This causes the the next item in the stack to appear to be the wrong one (though this does change sometimes if you touch the item and start scrolling, then it displays the correct one).
The custom RecyclerView.ItemDecoration class:
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
)
{
val itemPosition = parent.getChildAdapterPosition(view)
val adapter = parent.adapter
if (adapter is BaseRecVAdapter)
{
val item = adapter.getDataModel(itemPosition)
if (item is DragDropModel && item.mStackedPos != PMConsts.negNum)
{
if (item.mStackedPos != 0)
{
val context = view.context
val top = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 148f, context.resources.displayMetrics).toInt()
outRect.set(0, -top, 0, 0)
return
}
}
}
super.getItemOffsets(outRect, view, parent, state)
}
The drag interface I made for the Adapter and the ItemTouchHelper.Callback can be found below:
interface ItemTouchHelperListener
{
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
fun onClearView(recyclerView: RecyclerView?, viewHolder: RecyclerView.ViewHolder?)
}
The onItem move code is as follows:
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
{
var newToPosition = toPosition
if (toPosition <= mDragUpLimit)
{//Prevent items from being dragged above maximum movement.
newToPosition = mDragUpLimit + 1
}
else if (toPosition >= mDragDownLimit)
{//Cannot drag below stacked List...
newToPosition = mDragDownLimit - 1
}
if (fromPosition < newToPosition)
{
for (i in fromPosition until newToPosition)
{
swap(mDataList, i, i + 1)
}
}
else
{
for (i in fromPosition downTo newToPosition + 1)
{
swap(mDataList, i, i - 1)
}
}
notifyItemMoved(fromPosition, newToPosition)
return true
}
I have a simple viewholder which is an invisible bar which i mark as the position you need to drag above in order to make a valid change to the list order.
I have the code call notifyDataSetChanged after the onClearView() method is called as I need to update the background features so that the next item in the stack is draggable and the background data feeding into the adapter is also updated. It seems the simplest way to keep the data updating smoothly, but I wonder if it is causing my problems
If someone would be able to give me a hand with this, I would be most grateful. I am tearing my hair out somewhat. I thought I had a good system setup but it was not quite working. I hope that this is enough information to get some help with this issue.
Thank you in advance
I'm building a contact list (ArrayList) in a messaging app using RecyclerView.
When a contact updates, I want to move that user to the top of the list, while all other items in the list move one step down.
I'm using Firestore to get the list of users.
DocumentChange.Type.MODIFIED -> {
val matchThatChanged = dc.newIndex
matchesArrayList[matchThatChanged] = ChatMatchListMatch(
matchUserID)
adapter.notifyItemChanged(matchThatChanged) //Ensures change is visible immediately
val fromPosition = matchesArrayList.indexOfFirst {
it!!.matchUserID == matchUserID
}
Log.d(TAG, "From position A: $matchThatChanged")
if (fromPosition != 0) {
adapter.moveMatchToTop(
fromPosition, ChatMatchListMatch(
matchUserID
)
)
}
}
In the Log here it correctly outputs 1 when I make the first move. However, thereafter it outputs 0 when I try to update the other user? This is not correct. The user that gets pushed down should no longer be at 0, it should be at 1. Because it is now wrongly at 0, it is not running the code (this is not the issue. The issue is that is should not be 0 in the first place).
Here's the code in my adapter:
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val view: View = LayoutInflater.from(parent.context)
.inflate(R.layout.chat_matchlist_item, parent, false)
return CustomViewHolder(view)
}
// Passes the ContactListMatch object to a ViewHolder so that the contents can be bound to UI.
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val contactListMatch = mMatchesArrayList[position]
(holder as CustomViewHolder).bind(contactListMatch)
}
fun moveMatchToTop(fromPosition: Int, chatMatchListMatch: ChatMatchListMatch) {
mMatchesArrayList.removeAt(fromPosition)
notifyItemChanged(fromPosition)
notifyItemRemoved(fromPosition)
mMatchesArrayList.add(0, chatMatchListMatch)
notifyItemInserted(0)
notifyItemChanged(0)
notifyItemChanged(1)
}
The list I have to begin with is:
When I update only the bottom user (2), it displays correctly (moving user two to the top and the other user down):
I now try to update original user 1 again, and I expect it to go on top again, like this:
But instead I get this:
I figured out the solution. I had to make several changes, but I'll try to make this helpful to others who may struggle with similar issues:
I used an index in Firestore and used two "orderby" queries to get the list in the order I wanted:
myCollectionRef
.orderBy("unread", Query.Direction.DESCENDING)
.orderBy("timeStamp", Query.Direction.DESCENDING)
.addSnapshotListener
Then under case MODIFIED:
DocumentChange.Type.MODIFIED -> {
I had to get the old index and the new index of the item changed. When I get the old index (by iterating through my list to look for it with a for loop), I ensure to remove the item from the list and notify my adapter that I removed the item:
val newIndex = dc.newIndex
var oldIndex = -1
for (i in myList.indices.reversed()) {
if (myList[i]!!.IDnumber == userID) {
oldIndex = i
myList.removeAt(oldIndex)
adapter.notifyItemRemoved(oldIndex)
}
}
Then I have to add the changed item back into my list in the correct position. Since I have the new index, this is easy. I also notify my adapter of the change:
myList.add(
newIndex, MySpecialItem(
userID!!,
userImageOrWhatever!!
)
)
adapter.notifyItemInserted(newIndex)
and that's it.
First: I created a sample project showing this problem. By now I begin to think that this is a bug in either RecyclerView or MotionLayout.
https://github.com/muetzenflo/SampleRecyclerView
This project is set up a little bit different than what is described below: It uses data binding to toggle between the MotionLayout states. But the outcome is the same. Just play around with toggling the state and swiping between the items. Sooner than later you'll come upon a ViewHolder with the wrong MotionLayout state.
So the main problem is:
ViewHolders outside of the screen are not updated correctly when transition from one MotionLayout state to another.
So here is the problem / What I've found so far:
I am using a RecyclerView.
It has only 1 item type which is a MotionLayout (so every item of the RV is a MotionLayout).
This MotionLayout has 2 states, let's call them State big and State small
All items should always have the same State. So whenever the state is switched for example from big => small then ALL items should be in small from then on.
But what happens is that the state changes to small and most(!) of the items are also updated correctly. But one or two items are always left with the old State. I am pretty sure it has to do with recycled ViewHolders. These steps produce the issue reliably when using the adapter code below (not in the sample project):
swipe from item 1 to the right to item 2
change from big to small
change back from small to big
swipe from item 2 to the left to item 1
=> item 1 is now in the small state, but should be in the big state
Additional findings:
After step 4 if I continue swiping to the left, there comes 1 more item in the small state (probably the recycled ViewHolder from step 4). After that no other item is wrong.
Starting from step 4, I continue swiping for a few items (let's say 10) and then swipe all the way back, no item is in the wrong small state anymore. The faulty recycled ViewHolder seems to be corrected then.
What did I try?
I tried to call notifyDataSetChanged() whenever the transition has completed
I tried keeping a local Set of created ViewHolders to call the transition on them directly
I tried to use data-binding to set the motionProgress to the MotionLayout
I tried to set viewHolder.isRecycable(true|false) to block recycling during the transition
I searched this great in-depth article about RVs for hint what to try next
Anyone had this problem and found a good solution?
Just to avoid confusion: big and small does not indicate that I want to collapse or expand each item! It is just a name for different arrangement of the motionlayouts' children.
class MatchCardAdapter() : DataBindingAdapter<Match>(DiffCallback, clickListener) {
private val viewHolders = ArrayList<RecyclerView.ViewHolder>()
private var direction = Direction.UNDEFINED
fun setMotionProgress(direction: MatchCardViewModel.Direction) {
if (this.direction == direction) return
this.direction = direction
viewHolders.forEach {
updateItemView(it)
}
}
private fun updateItemView(viewHolder: RecyclerView.ViewHolder) {
if (viewHolder.adapterPosition >= 0) {
val motionLayout = viewHolder.itemView as MotionLayout
when (direction) {
Direction.TO_END -> motionLayout.transitionToEnd()
Direction.TO_START -> motionLayout.transitionToStart()
Direction.UNDEFINED -> motionLayout.transitionToStart()
}
}
}
override fun onBindViewHolder(holder: DataBindingViewHolder<Match>, position: Int) {
val item = getItem(position)
holder.bind(item, clickListener)
val itemView = holder.itemView
if (itemView is MotionLayout) {
if (!viewHolders.contains(holder)) {
viewHolders.add(holder)
}
updateItemView(holder)
}
}
override fun onViewRecycled(holder: DataBindingViewHolder<Match>) {
if (holder.adapterPosition >= 0 && viewHolders.contains(holder)) {
viewHolders.remove(holder)
}
super.onViewRecycled(holder)
}
}
I made some progress but this is not a final solution, it has a few quirks to polish. Like the animation from end to start doesn't work properly, it just jumps to the final position.
https://github.com/fmatosqg/SampleRecyclerView/commit/907ec696a96bb4a817df20c78ebd5cb2156c8424
Some things that I changed but are not relevant to the solution, but help with finding the problem:
made duration 1sec
more items in recycler view
recyclerView.setItemViewCacheSize(0) to try to keep as few unseen items as possible, although if you track it closely you know they tend to stick around
eliminated data binding for handling transitions. Because I don't trust it in view holders in general, I could never make them work without a bad side-effect
upgraded constraint library with implementation "androidx.constraintlayout:constraintlayout:2.0.0-rc1"
Going into details about what made it work better:
all calls to motion layout are done in a post manner
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
Compared actual vs desired properties
val goalProgress =
if (currentState) 1f
else 0f
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
if (motionLayout.currentState != desiredState) {
safeRunBlock {
startTransition(currentState)
}
}
}
This would be the full class of the partial solution
class DataBindingViewHolder<T>(private val binding: ViewDataBinding) :
RecyclerView.ViewHolder(binding.root) {
val motionLayout: MotionLayout =
binding.root.findViewById<MotionLayout>(R.id.root_item_recycler_view)
.also {
it.setTransitionDuration(1_000)
it.setDebugMode(DEBUG_SHOW_PROGRESS or DEBUG_SHOW_PATH)
}
var lastPosition: Int = -1
fun bind(item: T, position: Int, layoutState: Boolean) {
if (position != lastPosition)
Log.i(
"OnBind",
"Position=$position lastPosition=$lastPosition - $layoutState "
)
lastPosition = position
setMotionLayoutState(layoutState)
binding.setVariable(BR.item, item)
binding.executePendingBindings()
}
// https://stackoverflow.com/questions/51929153/when-manually-set-progress-to-motionlayout-it-clear-all-constraints
fun safeRunBlock(block: () -> Unit) {
if (ViewCompat.isLaidOut(motionLayout)) {
block()
} else {
motionLayout.post(block)
}
}
fun setMotionLayoutState(currentState: Boolean) {
val goalProgress =
if (currentState) 1f
else 0f
safeRunBlock {
startTransition(currentState)
}
if (motionLayout.progress != goalProgress) {
val desiredState =
if (currentState) motionLayout.startState
else motionLayout.endState
if (motionLayout.currentState != desiredState) {
Log.i("Pprogress", "Desired doesn't match at position $lastPosition")
safeRunBlock {
startTransition(currentState)
}
}
}
}
fun startTransition(currentState: Boolean) {
if (currentState) {
motionLayout.transitionToStart()
} else {
motionLayout.transitionToEnd()
}
}
}
Edit: added constraint layout version
The code above is the RecyclerViewAdapter, which changes color only when it is the first item, as shown below.
class TestAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val textColor1 = Color.BLACK
private val textColor2 = Color.YELLOW
private val items = ArrayList<String>()
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val textColor = if(position==0) textColor1 else textColor2
holder.itemView.textView.setTextColor(textColor)
holder.itemView.textView.text = items[position]
}
fun move(from:Int,to:Int){
val item = items[from]
items.remove(item)
items.add(to,item)
notifyItemMoved(from,to)
}
}
In this state I would like to move Value 3 to the first position using the move function. The results I want are shown below.
But in fact, it shows the following results
When using notifyDataSetChanged, I can not see the animation transition effect,
Running the onBindViewHolder manually using findViewHolderForAdapterPosition results in what I wanted, but it is very unstable. (Causing other parts of the error that I did not fix)
fun move(from:Int,to:Int){
val item = items[from]
val originTopHolder = recyclerView.findViewHolderForAdapterPosition(0)
val afterTopHolder = recyclerView.findViewHolderForAdapterPosition(from)
items.remove(item)
items.add(to,item)
notifyItemMoved(from,to)
if(to==0){
onBindViewHolder(originTopHolder,1)
onBindViewHolder(afterTopHolder,0)
}
}
Is there any other way to solve this?
Using the various notifyItemFoo() methods, like moved/inserted/removed, doesn't re-bind views. This is by design. You could call
if (from == 0 || to == 0) {
notifyItemChanged(from, Boolean.FALSE);
notifyItemChanged(to, Boolean.FALSE);
}
in order to re-bind the views that moved.
notifyItemMoved will not update it. According to documentation:
https://developer.android.com/reference/android/support/v7/widget/RecyclerView.Adapter
This is a structural change event. Representations of other existing items in the data set are still considered up to date and will not be rebound, though their positions may be altered.
What you're seeing is expected.
Might want to look into using notifyItemChanged, or dig through the documentation and see what works best for you.
I'm new in Android dev and now porting my iOS app.
Trying to make pretty complex RecyclerView, but at some moment behavoir of the specific row is duplicated on other row after notifyDataSetChanged() method.
There are three rows with same ViewType in first part of RecyclerView
Each of them has TextView and EditText widgets, that I'm populating in CustomViewHolder class.
First and second rows should work as always: when I'm click in EditText - the keyboard opens. But third row EditText's focus should initiate dialog alert. Everything works great until reload of adapter's DataSet. After DataSet reload the first row's EditText also begins to open the dialog alert instead of normal opening of the keyboard.
Looks like I'm missing something and somehow referencing to the same object when customizing my rows. Here's my adapter code (simplified):
class NewRequestsRecyclerAdapter(val context: Context, val parameters:ArrayList<NewRequestsFragment.ParameterCell>,val delegate:NewRequestProtocol?): RecyclerView.Adapter<NewRequestsRecyclerAdapter.CustomViewHolder>() {
enum class RowType {
Header,Parameter
}
override fun getItemCount(): Int {
// count logic
}
override fun onCreateViewHolder(parent : ViewGroup, viewType: Int): CustomViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val cellForRow = when (RowType.values()[viewType]) {
RowType.Header -> layoutInflater.inflate(R.layout.cell_header,parent,false)
RowType.Parameter -> layoutInflater.inflate(R.layout.cell_parameter_new_requests,parent,false)
}
return CustomViewHolder(cellForRow, RowType.values()[viewType])
}
override fun getItemViewType(position: Int): Int {
// Here's ItemViewType logic ...
}
override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
holder.bindMenu(position)
}
inner class CustomViewHolder(val cellView: View, val type:RowType): RecyclerView.ViewHolder(cellView) {
fun bindMenu(row:Int) {
when (type) {
RowType.Header -> {
val nameView = cellView.findViewById<TextView>(R.id.headerName)
// other logic to populate Header views
}
RowType.Parameter -> {
val nameView = cellView.findViewById<TextView>(R.id.paramName)
val editText = cellView.findViewById<EditText(R.id.paramEditText)
nameView.text = parameters[row-1].name
editText.apply {
hint = parameters[row-1].placeholder
when (parameters[row-1].type) {
NewRequestsFragment.PartsCellType.Name -> {
setText(delegate?.currentItem?.name)
}
NewRequestsFragment.PartsCellType.Number -> {
setText(delegate?.currentItem?.number)
}
NewRequestsFragment.PartsCellType.StateType-> {
setText(delegate?.currentItem?.state)
showSoftInputOnFocus = false
setOnFocusChangeListener { view, changed ->
if (changed) {
inputType = InputType.TYPE_NULL
delegate?.showStateDialog()
}
}
}
}
}
}
I know to resolve that problem I can show alert with button, but I would like to know why my code leads to this behavior.
Could you please guide me what I'm missing?
This type of problem with a RecyclerView where one item mysteriously takes on the attributes or behavior of another item is usually due to not resetting the view holder.
You are defining the behavior of your view holders when they are created, so, the first time, all view holders are created and behave appropriately. When things change, the view holders are reused and not recreated. As a result, things can get mixed up such as getting a dialog opened when the keyboard should show.
To correct this, reset the view holder when it is bound to behave the way you want.