I want to disable right to left swipe in ViewPager2
.
I basically have a viewpager2 element with 2 pages in my navigation drawer. I want my second page to show up on
Solution for more than 2 Fragments. (scroll down for solution)
This one was a little tricky.
I think the reason why people are looking to disable swipes in one direction, in my opinion, is because there is no way to add Fragments at run-time, while maintaining the state of previous Fragments on display.
So what people are doing is that they are preloading all Fragments, and making it as if the ones not on display are simply not there.
Now if the team would've made the Adapter not bounded by the ViewLifeCycle, this could be easily solved by using the ListDiffer option, that would correctly propagate updates to the RecyclerView Adapter, but because a new Adapter is required for each dataSetChanged of the ViewPager2, the entirety of the Fragments needs to be recreated, and the ListDiffer has no effect on the ViewPager2.
But maybe not entirely as I'm not sure the ListDiffer is able to recognize a "position swap" to preserve state.
Now, on the answer that advices the use of registerOnPageChangeCallback()
.
The reason why it's of no use to registerOnPageChangeCallback() with more than 2 Fragments, its because by the time this method gets called, it's already too late to do something, what this creates is that the window becomes unresponsive mid way, as opposed to the addOnItemTouchListener(); which is able to intercept touches before they reach the view.
In some sense the complete transaction of blocking and allowing swipes, are going to be performed by the two methods, registerOnPageChangeCallback() and addOnItemTouchListener().
registerOnPageChangeCallback()
Will tell our adapter what direction should stop working (usually from left to right (I will call this simply "left")) and at what page, while addOnItemTouchListener()
would tell the view to intercept the fling at the right moment in the direction we want.
The problem is that to use that TouchListener we need to access the inner RecyclerView inside the ViewPager2.
The way to do this is by overriding the onAttachedToWindow()
method from the FragmentStateAdapter
.
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
super.onAttachedToRecyclerView(recyclerView);
}
Now the correct listener to attach to the RecyclerView is called RecyclerView.SimpleOnItemTouchListener()
, the problem is that the listener does not distinguish a "right" fling from a "left" one.
public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e)
We need to mix 2 behaviours to get the desired result:
a) rv.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING
b) e.getX()
We also need to keep track of the last x point, the reason this works is because the listener will trigger multiple times before the rv.getScrollState()
turns SCROLL_STATE_DRAGGING
.
SOLUTION.
The class I used to identify left from right:
public class DirectionResolver {
private float previousX = 0;
public Direction resolve(float newX) {
Direction directionResult = null;
float result = newX - previousX;
if (result != 0) {
directionResult = result > 0 ? Direction.left_to_right : Direction.right_to_left ;
}
previousX = newX;
return directionResult;
}
public enum Direction {
right_to_left, left_to_right
}
}
There is no need to enzero the previousX int after the transaction because the resolve() method is at least executed more than 3 times before the rv.getScrollState()
becomes SCROLL_STATE_DRAGGING
Once this class is defined the whole code should be like this (inside the FragmentStateAdapter
):
private final DirectionResolver resolver = new DirectionResolver();
private final AtomicSupplier<DirectionResolver.Direction> directionSupplier = new AtomicSupplier<>();
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
recyclerView.addOnItemTouchListener(
new RecyclerView.SimpleOnItemTouchListener(){
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
boolean shouldIntercept = super.onInterceptTouchEvent(rv, e);
DirectionResolver.Direction direction = directionSupplier.get();
if (direction != null) {
DirectionResolver.Direction resolved = resolver.resolve(e.getX());
if (rv.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING) {
//resolved will never be null if state is already dragging
shouldIntercept = resolved.equals(direction);
}
}
return shouldIntercept;
}
}
);
super.onAttachedToRecyclerView(recyclerView);
}
public void disableDrag(DirectionResolver.Direction direction) {
Log.println(Log.WARN, TAG, "disableDrag: disabling swipe: " + direction.name());
directionSupplier.set(() -> direction);
}
public void enableDrag() {
Log.println(Log.VERBOSE, TAG, "enableDrag: enabling swipe");
directionSupplier.set(() -> null);
}
If you are asking what AtomicSupplier is, it is something similar to an AtomicReference<> so if you want to use that instead it will give the same results.
The idea is to reuse the same SimpleOnItemTouchListener()
and in order to do that we need to supply it with the parameter.
we need to check for nulls because the supplier will be null the first time (unless you rpovide it an initial value) the recyclerView is firstly attached to the window.
Now using it.
binding.journalViewPager.registerOnPageChangeCallback(
new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
if (conditionToDisableLeftSwipe.at(position)) {
adapter.disableDrag(DirectionResolver.Direction.right_to_left);
} else {
adapter.enableDrag();
}
}
}
}
);
Extend the viewpager class and override the functions onInterceptTouchEvent
and onTouchEvent
. Then identify the direction of the swipe and return false if you don't want to swipe.
You can use this helper method for swipe detection:
float downX; // define class level variable in viewpager
private boolean wasSwipeToLeftEvent(MotionEvent event){
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
downX = event.getX();
return false;
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_UP:
return event.getX() - downX > 0;
default:
return false;
}
}
Then in your method for touch events:
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
return !this.wasSwipeToLeftEvent(event);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return return !this.wasSwipeToLeftEvent(event);
}
I modified the code from this answer, if you need more explanation please see this: https://stackoverflow.com/a/34111034/4428159
I found a listener which can listen when the user tries to swipe, it'll then check the current page, if it's the first page, disable the user input else enable it as it was by default.
Here's the code snippet for that:-
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageScrollStateChanged(int state) {
super.onPageScrollStateChanged(state);
if (state == SCROLL_STATE_DRAGGING && pager.getCurrentItem() == 0) {
pager.setUserInputEnabled(false);
} else {
pager.setUserInputEnabled(true);
}
}
});
Since my scenario was of 2 pages only, checking the page number would be good for me, but in case we have more than 2 pages and we need to disable the swipe in one particular direction, we may use onPageScrolled(int position, float positionOffset, int positionOffsetPixels)
listener of viewpager2
and handle the desired scenario according to the positive or negative values of position
and positionOffset
.