I am following this tutorial and source code to implement Collapsing Toolbar by using Motion Layout.
When you do "fast scroll" on recyclerView and then do click event on any item of the recyclerView, this event only works after you the 2nd click. Or you have to wait 2-3 second then do the click event works. Otherwise click event can not be dispatch to the recyclerView. (Assuming it waits to finish animation on MotionLayout). How can we fix this issue?
Anyone can simulate the issue by adding below code to the adapter of the article's source code. And do fast scroll.
class ViewHolder(
view: View,
private val textView: TextView = view.findViewById(android.R.id.text1)
) : RecyclerView.ViewHolder(view) {
var text: CharSequence
get() = textView.text
set(value) {
textView.text = value
}
init {
view.setOnClickListener { showMessage(it) }
}
private fun showMessage(view: View) {
Toast.makeText(view.context, "OnClickListener: item ${adapterPosition + 1}", Toast.LENGTH_SHORT).show()
}
}
Update ConstraintLayout to version 2.0.0-beta2
https://issuetracker.google.com/issues/128914828
Related
Im trying to create an app with a recyclerview, and I am trying to figure out the following code from an android example. Like what is the onClick value they are putting in the first class, and what is the lambda expression for and what does it do? I notice a similar lambda is in the class below it as well. If anyone can please explain the code. Thank you.
class FlowersAdapter(private val onClick: (Flower) -> Unit) :
ListAdapter<Flower, FlowersAdapter.FlowerViewHolder>(FlowerDiffCallback) {
/* ViewHolder for Flower, takes in the inflated view and the onClick behavior. */
class FlowerViewHolder(itemView: View, val onClick: (Flower) -> Unit) :
RecyclerView.ViewHolder(itemView) {
private val flowerTextView: TextView = itemView.findViewById(R.id.flower_text)
private val flowerImageView: ImageView = itemView.findViewById(R.id.flower_image)
private var currentFlower: Flower? = null
init {
itemView.setOnClickListener {
currentFlower?.let {
onClick(it)
}
}
}
/* Bind flower name and image. */
fun bind(flower: Flower) {
currentFlower = flower
flowerTextView.text = flower.name
if (flower.image != null) {
flowerImageView.setImageResource(flower.image)
} else {
flowerImageView.setImageResource(R.drawable.rose)
}
}
}
The onClick parameter has a type of (Flower) -> Unit. That represents a function, which takes a single Flower parameter (in the parentheses) and returns Unit (i.e. "doesn't return anything").
That means that onClick is a function, and you can call it like onClick(someFlower), which is what's happening in that click listener set up in the init block. The naming might make it a little confusing, but it's basically this:
pass in some handler function
set up a click listener on itemView
when itemView is clicked, call the handler function, passing currentFlower
so it's just a way for you to provide some behaviour to handle a flower being clicked. You still need the click listener - that's a thing that operates on a View and handles click interactions. But inside that listener, you can do what you like when the click is detected, and in this case it's running some externally provided function
I am working on an idea, which is make a RecyclerView auto scrolling but allow user to click item without stop scrolling.
First, I create a custom LayoutManager to disable manual scroll, also change the speed of scroll to a certain position
class CustomLayoutManager(context: Context, countOfColumns: Int) :
GridLayoutManager(context, countOfColumns) {
// Custom smooth scroller
private val smoothScroller = object : LinearSmoothScroller(context) {
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float =
500f / displayMetrics.densityDpi
}
// Disable manual scroll
override fun canScrollVertically(): Boolean = false
// Using custom smooth scroller to control the duration of smooth scroll to a certain position
override fun smoothScrollToPosition(
recyclerView: RecyclerView,
state: RecyclerView.State?,
position: Int
) {
smoothScroller.targetPosition = position
startSmoothScroll(smoothScroller)
}
}
Then I do the initial work for the RecyclerView and start smooth scroll after 1 sec
viewBinding.list.apply {
// initial recycler view
setHasFixedSize(true)
customLayoutManager = CustomLayoutManager(context = context, countOfColumns = 2)
layoutManager = customLayoutManager
// data list
val dataList = mutableListOf<TestModel>()
repeat(times = 100) { dataList.add(TestModel(position = it, clicked = false)) }
// adapter
testAdapter =
TestAdapter(clickListener = { testAdapter.changeVhColorByPosition(position = it) })
adapter = testAdapter
testAdapter.submitList(dataList)
// automatically scroll after 1 sec
postDelayed({ smoothScrollToPosition(dataList.lastIndex) }, 1000)
}
Everything goes as my expected until I found that the auto scrolling stopped when I clicked on any item on the RecycelerView, the function when clickListener triggered just change background color of the view holder in TestAdapter
fun changeVhColor(position: Int) {
position
.takeIf { it in 0..itemCount }
?.also { getItem(it).clicked = true }
?.also { notifyItemChanged(it) }
}
here is the screen recording screen recording
issues I encounter
auto scrolling stopped when I tap any item on the ReycelerView
first tap make scrolling stopped, second tap trigger clickListener, but I expect to trigger clickListener by one tap
Can anybody to tell me how to resolve this? Thanks in advance.
There is a lot going on here. You should suspect the touch handling of the RecyclerView and, maybe, the call to notifyItemChanged(it), but I believe that the RecyclerView is behaving correctly. You can look into overriding the touch code in the RecyclerView to make it do what you want - assuming you can get to it and override it.
An alternative would be to overlay the RecyclerView with another view that is transparent and capture all touches on the transparent view. You can then write code for the transparent view that interacts with the RecyclerView in the way that meets your objectives. This will also be tricky and you will have to make changes to the RecyclerView as it is constantly layout out views as scrolling occurs. Since you have your own layout manager, this might be easier if you queue changes to occur pre-layout as scrolling occurs.
After tried several ways, found that the key of keep recycler view scrolling automatically is override onInterceptTouchEvent
Example
class MyRecyclerView #JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : RecyclerView(context, attrs, defStyle) {
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean = false
}
that will make the custom RecyclerView ignore all touch event
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
I have a RecyclerView which allows swipe-to-delete functionality. After deliting, a Snackbar shows to confirm deletion with an action that allows users to "undo" the delete.
Everything works fine until I delete the item at position 0 then hit undo. The item will be reinserted back into the list but users will need to scroll up to bring it back into view.
What I have tried
Setting recyclerView.isNestedScrollingEnabled" on the RecyclerView
Using layoutCoordinator.scrollTo(0, 0) on the Coordinator Layout
Using recyclerView.smoothScrollToPosition(0) on the RecyclerView
Using recyclerView.scrollToPosition(0) on the RecyclerView
Using recyclerView.scrollTo(0, 0) on the RecyclerView
Using itemAdapter.notifiyItemInserted(0) on the Adapter
Using itemAdapter.notifiyItemchanged(0) on the Adapter
Using itemAdapter.notifyDataSetChanged() on the Adapter
Using layoutManager.scrollToPositionWithOffset(0, 0) on the
LayoutManager
Creating a custom LayoutManager and overriding
smoothScrollToPosotion() with my own implementation.
None of the above have offered a solution.
Below are the workings.
ItemsFragment
Inside onCreateView - here is setting up the itemAdapter and recyclerView:
val itemAdapter = object : ItemRecyclerViewAdapter() {
override fun onItemClicked(item: Item) {
// todo
}
}
val layoutManager = LinearLayoutManager(context)
recyclerView.layoutManager = layoutManager
recyclerView.adapter = itemAdapter
Here is my swipeToDeleteCallback with Snackbar action to "undo" the delete:
val swipeToDeleteCallback = object : SwipeToDeleteCallback() {
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
val item = itemAdapter.currentList[position]
viewModel.deleteItem(item)
val snackBar = Snackbar.make(
recyclerView,
getString(R.string.snackbar_msg_deleted_card),
Snackbar.LENGTH_LONG
)
snackBar.setAction(R.string.snack_bar_undo) {
viewModel.restoreItem(item)
recyclerView.smoothScrollToPosition(position)
}
snackBar.show()
}
}
val itemTouchHelper = ItemTouchHelper(swipeToDeleteCallback)
itemTouchHelper.attachToRecyclerView(recyclerView)
Setting the items which are LiveData observed from the ViewModel. Changes are immediately passed to the adapter:
val items = viewModel.items.await()
items.observe(viewLifecycleOwner, Observer {
it?.let {
itemAdapter.setItems(it)
}
})
ItemsAdapter
Submitting the list with DiffUtilCallback:
fun setItems(list: List<Item>?) {
adapterScope.launch {
val itemsList = when(list) {
null -> emptyList()
else -> list.sortedByDescending {
it.itemId
}
}
withContext(Dispatchers.Main) {
submitList(itemsList)
}
}
}
Temporary fix
So far the only thing that has worked is this hacky solution inside my Snackbar action:
snackBar.setAction(R.string.snack_bar_undo) {
viewModel.restoreItem(item)
if (position == 0) {
itemsAdapter.notifyItemInserted(0)
recyclerView.smoothScrollToPosition(0)
}
}
Here I'm checking if the item position is 0. If so then telling the adapter that there's a new item inserted at position 0 - then initiating scrolling. Otherwise, don't do anything because items at any position other than 0 will insert and animate fine beceause of the DiffUtilCallback.
This temporary fix works but the scrolling is "snappy" and produces an error in the logs:
RecyclerView: Passed over target position while smooth scrolling
Also, this solution does not work 100% of the time. It's more like 60% of the time.
Does anybody know of a better solution/something I am missing and a way to resolve the error above?
I have activity with recycleView which included some edit texts.when selecting any edit text keyboard getting the popup. But when I moving recycle view to up keyboard changing to default one.
I found the reason for it that when edit text gets disappear it removed from the recycle view so it lost the focus. Then keyboard changing to default.
Now I need to close keyboard when there is no focus for any edit text when moving recycleview.But I do not know how to get that kind of call back when there is no forcus for the edit texts.
I want to suggest you one method of recyclerView called onViewDetachedFromWindow.
For example when you scroll the recyclerView, item become invisible and at that moment adapter calls:
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
super.onViewAttachedToWindow(holder)
if (holder is MyViewHolder) {
holder.checkForFocus()
}
}
After in viewHolder you may check for focus:
inner class MyViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(item: MyViewItem) {
itemView.editText.hint = item.title
}
fun checkForFocus() {
if(itemView.editText.hasFocus()){
callback.hideKeyBoard()
}
}
}
}
var callback: IKeyBoard? = null // initialize it in fragment/activity
interface IKeyBoard {
fun hideKeyBoard()
}
After that you can implement the callback method in fragment/activity to hide keyboard
I hope that I helped you