Setup: I have a RecyclerView with a vertical LinearLayoutManager. Each ViewHolder has 2 TextViews (green and red in the picture), which can be very long. RED can be scrolled horizontally thanks to a HorizontalScrollView. GREEN can be scrolled vertically thanks to a ScrollView.
Now I have implemented ItemTouchHelper, to swipe LEFT, UP or DOWN. The problem is that my ScrollViews don't work anymore. Instead, even when I swipe GREEN UP or DOWN, it is my ViewHolder that moves. How do I prevent my ItemTouchHelper from getting the touch event?
I've tried:
-android:descendantFocusability="blocksDescendants"
-NestedScrollView instead of ScrollView
-implementing setOnTouchListener inside my adapter to return true (the listener is triggered, but the event is not consumed)
-thought about using RecyclerView.findChildViewUnder inside onChildDraw to scroll manually and block the super method but I can't find a way to get the coordinates
Nothing works so far. How do I do this? Here is my ItemTouchHelper:
private fun enableSwipe() {
val simpleItemTouchCallback =
object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
if (direction == ItemTouchHelper.LEFT) {
val content = mAdapter.getContentForPosition(position)
//do stuff...
}
}
override fun onChildDraw(
c: Canvas,
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float,
dY: Float,
actionState: Int,
isCurrentlyActive: Boolean
) {
val mdX = if (abs(dY) > abs(dX)) {
0f
}else{
dX
}
val mdY = if (abs(dY) > abs(dX)) {
when{
dY > 0 -> if (dY > 500f) 500f else dY
else -> if (dY < -500f) -500f else dY
}
}else{
0f
}
//override viewHolder in case we swipe up or down on the other content
val mViewHolder: RecyclerView.ViewHolder = if (abs(dY) > abs(dX)) {
if (dY < 0) {
//swiping up
getViewHolder(1) ?: viewHolder
} else {
getViewHolder(0) ?: viewHolder
}
}else {
viewHolder
}
super.onChildDraw(c, recyclerView, mViewHolder, mdX, mdY, actionState, isCurrentlyActive)
}
}
val itemTouchHelper = ItemTouchHelper(simpleItemTouchCallback)
itemTouchHelper.attachToRecyclerView(recyclerViewClash)
}
Here is the only thing that works, finally found it. It is important to understand that the ItemTouchHelper will implement onItemTouchListener:
An OnItemTouchListener allows the application to intercept touch events in progress at the view hierarchy level of the RecyclerView before those touch events are considered for RecyclerView's own scrolling behavior.
textView.setOnTouchListener { v, event ->
//let the scrollView handle the scroll event
scrollView.onTouchEvent(event)
//this is the key feature
v.parent.requestDisallowInterceptTouchEvent(true)
//return true to consume the event (mandatory)
true
}
Nothing else is needed. Also, a ScrollView is enough, no need for a NestedScrollView.
Related
I have recyclerView inside NestedScrollView. I want to calculate speed to recyclerview but it is not working inside nestedScrollView.
This is code to calculate scroll speed which is working without NestedScrollView. I want to make it work with NestedScrollView also.
I have set nested scrolling false but its not working
recyclerView.setNestedScrollingEnabled(false)
Class:
abstract class ScrollSpeedRecycleViewScrollListener(private val maxScrollSpeedForAdInjection: Int) :
RecyclerView.OnScrollListener() {
var currentScrollSpeed: Int = 0
private var previousFirstVisibleItem = 0
private var previousEventTime: Long = 0
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager
if (layoutManager is LinearLayoutManager) {
val firstItemPosition = layoutManager.findFirstVisibleItemPosition()
if (previousFirstVisibleItem != firstItemPosition) {
}
}
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
when (newState) {
RecyclerView.SCROLL_STATE_IDLE -> {
if (currentScrollSpeed > maxScrollSpeedForAdInjection) {
listNeedsRefresh()
}
currentScrollSpeed = 0
}
}
}
}
The concept of speed involves distance covered over a certain period of time. You can get the values scrolled (distance) using the dx or dy in the onScrolled method. To get time you would need to get some timestamps at the point where you're getting the distance values and then use a calculation of speed = distance / time. But your also going to need calculate the difference between calls of onScrolled. so it'll actually end up as
speed = (distance2 - distance1) / (time2 - time1)
The problem you're going to have here is that the method will be getting called loads when the user is scrolling fast, and the calculation will need to be done each time and that will have a detrimental effect on the smoothness of the scroll.
I'm willing to bet there is a better way to overcome your problem.
I have a BottomSheetDialogFragment. But even the slightest downward swipe dismisses the Dialog.
I do not want to make it static and remove the swipe down to dismiss behaviour. I want to be able to change the sensitivity, if the swipe is x pixels downwards, then dismiss
use BottomSheetBehavior
this will get the behavior for your BottomSheetDialogFragment view
var mBehavior: BottomSheetBehavior<*> = BottomSheetBehavior.from([your view reference])
then you can setup like this
val dismissOffset: Float = [-1..0] // 0 is the starting position. -1 is hidden. -0.5 is middle
var offset: Float? = null
mBehavior.setBottomSheetCallback(object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_SETTLING) {
if (offset!! > dismissOffset) {
mBehavior.setState(BottomSheetBehavior.STATE_EXPANDED)
} else {
mBehavior.setState(BottomSheetBehavior.STATE_HIDDEN)
}
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
offset = slideOffset
}
})
The answer of ChiChung Luk almost acceptable, but I have tried it with com.google.android.material:material:1.2.1 library and it didn't work as expected. First of all slideOffset changes from 1 to -1, and not from 0 to -1. The second issue was that even when we set mBehavior.setState(STATE_EXPANDED) in onStateChanged, the system any way sets the state to STATE_HIDDEN after the bottom sheet is expanded, from onStopNestedScroll > startSettlingAnimation. So there should be a flag that disallow hide before bottom sheet is not expanded.
Solution:
bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
val dismissOffset: Float = -0.2f // when less value then wider should be swipe gesture to dismiss
private var currOffset: Float = 1f // from 1 to -1
private var dismissAllowed: Boolean = true
override fun onStateChanged(
bottomSheet: View, #BottomSheetBehavior.State newState: Int
) {
if (newState == BottomSheetBehavior.STATE_SETTLING) {
if (currOffset > dismissOffset) {
dismissAllowed = false
bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
} else {
bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
}
} else if (newState == BottomSheetBehavior.STATE_EXPANDED) {
dismissAllowed = true
} else if (newState == BottomSheetBehavior.STATE_HIDDEN) {
if (dismissAllowed) {
dialog.cancel()
}
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
currOffset = slideOffset
}
})
I'm going to assume that like me, you had a NestedScrollView in your bottom sheet (this is the only thing that caused the behavior you described to happen for me).
My solution was as follows:
/** Convenience function to fix https://github.com/material-components/material-components-android/issues/1055 */
private fun NestedScrollView.fixNestedScrolling(dialog: BottomSheetDialog) {
fun updateScrollView(scrollY: Int) {
val wasNestedScrollingEnabled = isNestedScrollingEnabled
isNestedScrollingEnabled = scrollY > 0
if (wasNestedScrollingEnabled != isNestedScrollingEnabled) {
// If property has changed, we need to requestLayout for it to apply to swipe gestures.
dialog.findViewById<View>(R.id.design_bottom_sheet)?.requestLayout()
}
}
setOnScrollChangeListener { _, _, scrollY, _, _ -> updateScrollView(scrollY) }
// Fire off initial update
updateScrollView(0)
}
The NestedScrollView still works correctly, and once scrollY == 0 (i.e. we're at the top), nested scrolling is disabled so the BottomSheetBehavior uses the (much more natural) calculations that it usually does before initiating a dismiss.
Everything works great, but one thing is not really working.
The delete icon is only rendered on the first element of the recycler view list as you can see in the image.
Here is my code of the ItemTouchHelper class:
class ItemSwipeCallback(val context: Context) : ItemTouchHelper.Callback() {
private val listeners = ArrayList<OnItemSwipe>()
private val paint = Paint()
val theme = context.themeId
val icon = ContextCompat.getDrawable(context, R.drawable.ic_delete_filled_white_24dp)!!
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder): Boolean {
return true
}
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
val direction = context.sharedPreferences.getInt(Preferences.SWIPE_DIRECTION, Preferences.SWIPE_VALUE_RIGHT)
return when (direction) {
Preferences.SWIPE_VALUE_RIGHT -> makeMovementFlags(0, ItemTouchHelper.RIGHT)
Preferences.SWIPE_VALUE_LEFT -> makeMovementFlags(0, ItemTouchHelper.LEFT)
else -> makeMovementFlags(0, ItemTouchHelper.RIGHT)
}
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
listeners.forEach { it.onSwiped(viewHolder.layoutPosition, direction) }
}
override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder,
dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
if (dX != 0f && isCurrentlyActive) {
val itemView = viewHolder.itemView
paint.color = Color.parseColor("#D32F2F")
val top = (itemView.height - icon.intrinsicHeight) / 2
val left = itemView.width - icon.intrinsicWidth - top
if (theme == Preferences.THEME_VALUE_DARK) {
icon.setTint(Color.BLACK)
} else {
icon.setTint(Color.WHITE)
}
if (dX < 0) {
val background = RectF(itemView.right.toFloat() + dX, itemView.top.toFloat(),
itemView.right.toFloat(), itemView.bottom.toFloat())
c.drawRect(background, paint)
icon.setBounds(left, top, left + icon.intrinsicWidth, top + icon.intrinsicHeight)
} else {
val background = RectF(itemView.left.toFloat() + dX, itemView.top.toFloat(),
itemView.left.toFloat(), itemView.bottom.toFloat())
c.drawRect(background, paint)
icon.setBounds(top, top, top + icon.intrinsicWidth, top + icon.intrinsicHeight)
}
icon.draw(c)
}
}
fun addOnItemSwipeListener(onItemSwipe: OnItemSwipe) {
listeners.add(onItemSwipe)
}
}
Maybe the icon which is loaded on the head of the class is only usable once? I tried already to convert it into a Bitmap and use it. I also tried loading it in the onChildDraw function.
The solution was too easy. I always used itemView.height instead of itemView.top.
The canvas includes all items. Not every item has its own canvas. So i have to add the height of the above items too.
The working code looks like this:
val top = itemView.top + (itemView.height - intrinsicHeight) / 2
val left = itemView.width - intrinsicWidth - (itemView.height - intrinsicHeight) / 2
val right = left + intrinsicHeight
val bottom = top + intrinsicHeight
if (dX < 0) {
background.setBounds(itemView.right + dX.toInt(), itemView.top, itemView.right, itemView.bottom)
icon.setBounds(left, top, right, bottom)
} else if (dX > 0) {
background.setBounds(itemView.left + dX.toInt(), itemView.top, itemView.left, itemView.bottom)
icon.setBounds(top, top, top, bottom)
}
background.draw(c)
icon.draw(c)
Did u check the value of this parameter: isCurrentlyActive?
I guess there is no error in image creation(icon). because the image is successfully created first time. So, the problem is in looping.
if (dX < 0) {...}else{...}
Here, No matter the value of dX, the image is gonna added to children(row).
if (dX != 0f && isCurrentlyActive)
This is the only check point(considerably) in your code. Technically the whole block will skipped, if isCurrentlyActive boolean is false.
Right now I have a funtional swipe left to delete in recyclerview with two layouts(foreground and background). I use itemtouchhelper in the code. However, I would like to have BOTH swipe left and swipe right showing different colors and icons, like in Google Inbox. How can I implement that?
What I want is:
swipe rightswipe left
what I have is:
only swipe right
the code is just the standard itemtouchhelper.simplecallback with 2 layouts in the xml. And I googled everywhere and only found single swipe option with single icon and single color
Use ItemTouchHelper to Implement the Gmail Like Feature
call the following function after setting the recyclerView
private fun initSwipe() {
val simpleItemTouchCallback = object : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) {
override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
return false
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
if (direction == ItemTouchHelper.LEFT) {
//Logic to do when swipe left
} else {
//Logic to do when swipe right
}
}
override fun onChildDraw(c: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean) {
val icon: Bitmap
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
//Drawing for Swife Right
val itemView = viewHolder.itemView
val height = itemView.bottom.toFloat() - itemView.top.toFloat()
val width = height / 3
if (dX > 0) {
p.color = Color.parseColor("#2F2FD3")
val background = RectF(itemView.left.toFloat(), itemView.top.toFloat(), dX, itemView.bottom.toFloat())
c.drawRect(background, p)
icon = BitmapFactory.decodeResource(resources, R.drawable.ic_archive)
val icon_dest = RectF(itemView.left.toFloat() + width, itemView.top.toFloat() + width, itemView.left.toFloat() + 2 * width, itemView.bottom.toFloat() - width)
c.drawBitmap(icon, null, icon_dest, p)
} else {
//Drawing for Swife Left
p.color = Color.parseColor("#D32F2F")
val background = RectF(itemView.right.toFloat() + dX, itemView.top.toFloat(), itemView.right.toFloat(), itemView.bottom.toFloat())
c.drawRect(background, p)
icon = BitmapFactory.decodeResource(resources, R.drawable.ic_delete)
val icon_dest = RectF(itemView.right.toFloat() - 2 * width, itemView.top.toFloat() + width, itemView.right.toFloat() - width, itemView.bottom.toFloat() - width)
c.drawBitmap(icon, null, icon_dest, p)
}
}
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
}
val itemTouchHelper = ItemTouchHelper(simpleItemTouchCallback)
itemTouchHelper.attachToRecyclerView(YOUR_RECYCLER_VIEW)
}
Where
Object p is an object of paint Paint p = new Paint()
Hope this may help you.
I have a RecyclerView managed by a LinearlayoutManager, if I swap item 1 with 0 and then call mAdapter.notifyItemMoved(0,1), the moving animation causes the screen to scroll. How can I prevent it?
Sadly the workaround presented by yigit scrolls the RecyclerView to the top. This is the best workaround I found till now:
// figure out the position of the first visible item
int firstPos = manager.findFirstCompletelyVisibleItemPosition();
int offsetTop = 0;
if(firstPos >= 0) {
View firstView = manager.findViewByPosition(firstPos);
offsetTop = manager.getDecoratedTop(firstView) - manager.getTopDecorationHeight(firstView);
}
// apply changes
adapter.notify...
// reapply the saved position
if(firstPos >= 0) {
manager.scrollToPositionWithOffset(firstPos, offsetTop);
}
Call scrollToPosition(0) after moving items. Unfortunately, i assume, LinearLayoutManager tries to keep first item stable, which moves so it moves the list with it.
Translate #Andreas Wenger's answer to kotlin:
val firstPos = manager.findFirstCompletelyVisibleItemPosition()
var offsetTop = 0
if (firstPos >= 0) {
val firstView = manager.findViewByPosition(firstPos)!!
offsetTop = manager.getDecoratedTop(firstView) - manager.getTopDecorationHeight(firstView)
}
// apply changes
adapter.notify...
if (firstPos >= 0) {
manager.scrollToPositionWithOffset(firstPos, offsetTop)
}
In my case, the view can have a top margin, which also needs to be counted in the offset, otherwise the recyclerview will not scroll to the intended position. To do so, just write:
val topMargin = (firstView.layoutParams as? MarginLayoutParams)?.topMargin ?: 0
offsetTop = manager.getDecoratedTop(firstView) - manager.getTopDecorationHeight(firstView) - topMargin
Even easier if you have ktx dependency in your project:
offsetTop = manager.getDecoratedTop(firstView) - manager.getTopDecorationHeight(firstView) - firstView.marginTop
I've faced the same problem. Nothing of the suggested helped. Each solution fix and breakes different cases.
But this workaround worked for me:
adapter.registerAdapterDataObserver(object: RecyclerView.AdapterDataObserver() {
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
if (fromPosition == 0 || toPosition == 0)
binding.recycler.scrollToPosition(0)
}
})
It helps to prevent scrolling while moving the first item for cases: direct notifyItemMoved and via ItemTouchHelper (drag and drop)
I have faced the same problem. In my case, the scroll happens on the first visible item (not only on the first item in the dataset). And I would like to thanks everybody because their answers help me to solve this problem.
I inspire my solution based on Andreas Wenger' answer and from resoluti0n' answer
And, here is my solution (in Kotlin):
RecyclerViewOnDragFistItemScrollSuppressor.kt
class RecyclerViewOnDragFistItemScrollSuppressor private constructor(
lifecycleOwner: LifecycleOwner,
private val recyclerView: RecyclerView
) : LifecycleObserver {
private val adapterDataObserver = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
suppressScrollIfNeeded(fromPosition, toPosition)
}
}
init {
lifecycleOwner.lifecycle.addObserver(this)
}
#OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun registerAdapterDataObserver() {
recyclerView.adapter?.registerAdapterDataObserver(adapterDataObserver) ?: return
}
#OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun unregisterAdapterDataObserver() {
recyclerView.adapter?.unregisterAdapterDataObserver(adapterDataObserver) ?: return
}
private fun suppressScrollIfNeeded(fromPosition: Int, toPosition: Int) {
(recyclerView.layoutManager as LinearLayoutManager).apply {
var scrollPosition = -1
if (isFirstVisibleItem(fromPosition)) {
scrollPosition = fromPosition
} else if (isFirstVisibleItem(toPosition)) {
scrollPosition = toPosition
}
if (scrollPosition == -1) return
scrollToPositionWithCalculatedOffset(scrollPosition)
}
}
companion object {
fun observe(
lifecycleOwner: LifecycleOwner,
recyclerView: RecyclerView
): RecyclerViewOnDragFistItemScrollSuppressor {
return RecyclerViewOnDragFistItemScrollSuppressor(lifecycleOwner, recyclerView)
}
}
}
private fun LinearLayoutManager.isFirstVisibleItem(position: Int): Boolean {
apply {
return position == findFirstVisibleItemPosition()
}
}
private fun LinearLayoutManager.scrollToPositionWithCalculatedOffset(position: Int) {
apply {
val offset = findViewByPosition(position)?.let {
getDecoratedTop(it) - getTopDecorationHeight(it)
} ?: 0
scrollToPositionWithOffset(position, offset)
}
}
and then, you may use it as (e.g. fragment):
RecyclerViewOnDragFistItemScrollSuppressor.observe(
viewLifecycleOwner,
binding.recyclerView
)
LinearLayoutManager has done this for you in LinearLayoutManager.prepareForDrop.
All you need to provide is the moving (old) View and the target (new) View.
layoutManager.prepareForDrop(oldView, targetView, -1, -1)
// the numbers, x and y don't matter to LinearLayoutManager's implementation of prepareForDrop
It's an "unofficial" API because it states in the source
// This method is only intended to be called (and should only ever be called) by
// ItemTouchHelper.
public void prepareForDrop(#NonNull View view, #NonNull View target, int x, int y) {
...
}
But it still works and does exactly what the other answers say, doing all the offset calculations accounting for layout direction for you.
This is actually the same method that is called by LinearLayoutManager when used by an ItemTouchHelper to account for this dreadful bug.