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.
Related
I'm having a problem with the View I want to drag and drop from the Fragment that is in ViewPager2, which is nested in BottomSheetDialogFragment, not being able to be dragged onto the View that is located in the parent Activity.
The DragListener, which is installed in the CodeView (the View where data is dragged from BottomSheet), simply does not react to the dragged element
override fun initDragNDropListener() {
binding.fieldLayout.setOnDragListener { _, dragEvent ->
val draggableItem = dragEvent?.localState as View
val itemParent = draggableItem.parent as ViewGroup
when (dragEvent.action) {
DragEvent.ACTION_DRAG_STARTED,
DragEvent.ACTION_DRAG_ENTERED,
DragEvent.ACTION_DRAG_LOCATION,
DragEvent.ACTION_DRAG_EXITED -> true
DragEvent.ACTION_DROP -> {
handleDropEvent(itemParent, draggableItem, dragEvent)
true
}
DragEvent.ACTION_DRAG_ENDED -> {
draggableItem.post { draggableItem.animate().alpha(1f).duration =
UIMoveableCodeBlockInterface.ITEM_APPEAR_ANIMATION_DURATION
}
this.invalidate()
true
}
else -> false
}
}
}
private fun handleDropEvent(
itemParent: ViewGroup,
draggableItem: View,
dragEvent: DragEvent
) =
with(binding) {
val draggableItemWithLastTouchInformation =
draggableItem as? UICodeBlockWithLastTouchInformation
draggableItem.x = dragEvent.x - (draggableItem.width / 2)
draggableItem.y = dragEvent.y - (draggableItem.height / 2)
draggableItemWithLastTouchInformation?.let {
draggableItem.x = dragEvent.x - (it.touchX)
draggableItem.y = dragEvent.y - (it.touchY)
}
itemParent.removeView(draggableItem)
addView(draggableItem)
}
The drag initializer, which is set to all View that are successfully moved inside the CodeView, looks like this:
interface UIMoveableCodeBlockInterface {
#SuppressLint("ClickableViewAccessibility")
fun initDragNDropGesture(view: View, tag: String) {
view.tag = tag + Random.Default.nextInt().toString()
var touchX = 0
var touchY = 0
view.setOnTouchListener { _, motionEvent ->
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
touchX = motionEvent.x.toInt()
touchY = motionEvent.y.toInt()
}
false
}
view.setOnLongClickListener {
val item = ClipData.Item(tag as? CharSequence)
val dataToDrag = ClipData(
tag as? CharSequence,
arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN),
item
)
(it as? UICodeBlockWithLastTouchInformation)?.setLastTouchInformation(touchX, touchY)
val maskShadow = BlockDragShadowBuilder(view, touchX, touchY)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
#Suppress("DEPRECATION")
it.startDrag(dataToDrag, maskShadow, this, 0)
} else {
it.startDragAndDrop(dataToDrag, maskShadow, this, 0)
}
view.animate().alpha(0f).duration = ITEM_DISAPPEAR_ANIMATION_DURATION
true
}
}
companion object {
const val ITEM_APPEAR_ANIMATION_DURATION: Long = 400
const val ITEM_DISAPPEAR_ANIMATION_DURATION: Long = 500
}
}
The listener that is set for the BottomSheetFragment. Used to hide the BottomBheet in case the dragged View will exceed its limits
override fun initDragNDropListener() {
binding.viewPager.setOnDragListener { _, dragEvent ->
val draggableItem = dragEvent?.localState as View
when (dragEvent.action) {
DragEvent.ACTION_DRAG_EXITED -> {
behaviour.state = BottomSheetBehavior.STATE_HIDDEN
val bottomSheetDialog = dialog as BottomSheetDialog
bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.container)
?.let {
it.visibility = View.GONE
}
true
}
DragEvent.ACTION_DRAG_STARTED,
DragEvent.ACTION_DRAG_LOCATION,
DragEvent.ACTION_DRAG_ENTERED,
DragEvent.ACTION_DRAG_ENDED -> {
true
}
DragEvent.ACTION_DROP -> {
draggableItem.post { draggableItem.visibility = ConstraintLayout.VISIBLE }
}
else -> false
}
}
}
In trying to fix what was going on, I thought that some View from BottomSheet was overlapping CodeView, so I used the LayoutInspector to look at the id's that are used to layout BottomSheet and hide them programmatically, but it didn't help
bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.container)
?.let {
it.visibility = View.GONE
}
Highlighted the element I'm trying to move to fieldLayout
I also tried setting the drag listener to the binding.root of the parent Activity, but that doesn't help either, it doesn't catch any events
Tried calling dismiss(), which also failed
Thank you in advance for your help. I hope there is a solution
I have implemented a "page peek" feature for my ViewPager2:
private fun setViewPager() {
inventoryVp?.apply {
clipToPadding = false // allow full width shown with padding
clipChildren = false // allow left/right item is not clipped
offscreenPageLimit = 2 // make sure left/right item is rendered
}
inventoryVp?.setPadding(Utility.dpToPx(25), 0, Utility.dpToPx(25), 0)
val pageMarginPx = Utility.dpToPx(6)
val marginTransformer = MarginPageTransformer(pageMarginPx)
inventoryVp?.setPageTransformer(marginTransformer)
}
Doing this I am able to view a portion of the previous and next page. But first and last page show a bigger white space because there's no other page in this direction to show.
How can I set a different padding for the first and last page?
I solved it using ItemDecoration.
class CartOOSVPItemDecoration(val marginStart: Int,
val marginEnd: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
if (parent.getChildAdapterPosition(view) == 0) {
outRect.left = marginStart
}
else if(parent.getChildAdapterPosition(view) == ((parent.adapter?.itemCount ?: 0) - 1)) {
outRect.right = marginEnd
}
}
}
inventoryVp?.addItemDecoration( CartOOSVPItemDecoration(Utility.dpToPx(-9), Utility.dpToPx(-9)))
I want to change a view size based on the current size of the BottomSheetDialogFragment programmatically, but I don't know how to get or calculate it. I need this information in the onStateChanged callback.
On sliding of BottomSheetDialogFragment you can get the height by a little calculation using locationOnScreen of the BottomSheetDialogFragment.
view.getLocationOnScreen(int[] outLocation): Computes the coordinates of this view on
the screen. The argument must be an array of two integers. After the
method returns, the array contains the x and y location in that order.
bottomSheetBehavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
//your code here
} else if (newState == BottomSheetBehavior.STATE_EXPANDED) {
//your code here
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
var locationOnScreen: IntArray = intArrayOf(0, 0)
bottomSheet.getLocationOnScreen(locationOnScreen)
// coordinates of bottomsheet when sliding up/down
val (x, y) = locationOnScreen
}
})
My app has an Activity that's declared with:
android:windowSoftInputMode="adjustResize|stateAlwaysHidden"
adjustResize is set and in that activity I have a RecyclerView and an EditText as in a chat like app.
The problem I'm facing is that when the keyboard shows up the layout is resized as intented but it also scrolls up the RecyclerView contents.
The behavior desired is that the scroll stays put.
I've tried using LayoutManager#onSaveInstanceState() and it's counterpart LayoutManager#onRestoreInstanceState() with no avail.
I know many had similar/same issues but I couldn't find a good solutions for this.
Ok, amazing how a clear head and a sudden glimpse of thought makes wonders.
I don't know about everyone but I haven't found a solution for this simple problem this way, and it works for me. Sharing:
recyclerView.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
val y = oldBottom - bottom
val firstVisibleItem = linearLayoutManager.findFirstCompletelyVisibleItemPosition()
if (y.absoluteValue > 0 && !(y < 0 && firstVisibleItem == 0)) {
recycler_view.scrollBy(0, y)
}
}
Only drawback so far is that when you're scrolled to the second item and you hide the soft keyboard, it scrolls to the very end but no big deal for me.
Hope it helps someone.
EDIT:
Here's how I solved without any drawbacks now:
private var verticalScrollOffset = AtomicInteger(0)
recyclerView.addOnLayoutChangeListener { _, _, _, _, bottom, _, _, _, oldBottom ->
val y = oldBottom - bottom
if (y.absoluteValue > 0) {
// if y is positive the keyboard is up else it's down
recyclerView.post {
if (y > 0 || verticalScrollOffset.get().absoluteValue >= y.absoluteValue) {
recyclerView.scrollBy(0, y)
} else {
recyclerView.scrollBy(0, verticalScrollOffset.get())
}
}
}
}
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
var state = AtomicInteger(RecyclerView.SCROLL_STATE_IDLE)
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
state.compareAndSet(RecyclerView.SCROLL_STATE_IDLE, newState)
when (newState) {
RecyclerView.SCROLL_STATE_IDLE -> {
if (!state.compareAndSet(RecyclerView.SCROLL_STATE_SETTLING, newState)) {
state.compareAndSet(RecyclerView.SCROLL_STATE_DRAGGING, newState)
}
}
RecyclerView.SCROLL_STATE_DRAGGING -> {
state.compareAndSet(RecyclerView.SCROLL_STATE_IDLE, newState)
}
RecyclerView.SCROLL_STATE_SETTLING -> {
state.compareAndSet(RecyclerView.SCROLL_STATE_DRAGGING, newState)
}
}
}
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (state.get() != RecyclerView.SCROLL_STATE_IDLE) {
verticalScrollOffset.getAndAdd(dy)
}
}
})
EDIT2: This can be easily converted to Java if that's your poison.
use addOnscrollListener to check weather currently recyclerview is at bottom or not
if yes then use the method provided in accepted answer by doing it youe recycler only scroll when last item is visible else it wont scroll
recycler.addOnScrollListener(new RecyclerView.OnScrollListener() {
#Override
public void onScrollStateChanged(#NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
cannotScrollVertically = !recyclerView.canScrollVertically(1);//use a Boolean variable
}
});
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.