Android ViewPager with RecyclerView works incorrectly inside BottomSheet
Asked Answered
I

11

25

When I try to scroll list, sometimes this works incorrect - BottomSheet intercepts the scroll event and hides.

How to reproduce this:

  1. Open Bottom Sheet
  2. Change a page of ViewPager
  3. Try scroll the list

Result: BottomSheet will be hidden.

Here is sample code:

compile 'com.android.support:design:23.4.0'

MainActivity.java

package com.nkdroid.bottomsheetsample;

import android.os.Bundle;
import android.support.design.widget.BottomSheetBehavior;
import android.support.design.widget.TabLayout;
import android.support.v4.view.PagerAdapter;
import android.support.v4.view.ViewPager;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;

public
class MainActivity
        extends AppCompatActivity
{

    private BottomSheetBehavior behavior;

    @Override
    protected
    void onCreate(final Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        final Button btnView = (Button) findViewById(R.id.btnView);
        btnView.setOnClickListener(new View.OnClickListener()
        {
            @Override
            public
            void onClick(final View v) {
                behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
            }
        });

        final View bottomSheet = findViewById(R.id.bottom_sheet);
        behavior = BottomSheetBehavior.from(bottomSheet);

        final ViewPager viewPager = (ViewPager) findViewById(R.id.viewPager);
        viewPager.setAdapter(new MyPagerAdapter());

        final TabLayout tabLayout = (TabLayout) findViewById(R.id.tabs);
        tabLayout.setupWithViewPager(viewPager);


    }

    private
    class MyPagerAdapter
            extends PagerAdapter
    {
        @Override
        public
        int getCount() {
            return 15;
        }

        @Override
        public
        Object instantiateItem(final ViewGroup container, final int position) {
            final RecyclerView recyclerView = new RecyclerView(MainActivity.this);

            recyclerView.setLayoutManager(new LinearLayoutManager(MainActivity.this));
            recyclerView.setAdapter(new ItemAdapter());

            container.addView(recyclerView);
            return recyclerView;
        }

        @Override
        public
        boolean isViewFromObject(final View view, final Object object) {
            return view.equals(object);
        }

        @Override
        public
        void destroyItem(final ViewGroup container, final int position, final Object object) {
            container.removeView((View) object);
        }

        @Override
        public
        CharSequence getPageTitle(final int position) {
            return String.valueOf(position);
        }
    }

    public
    class ItemAdapter
            extends RecyclerView.Adapter<ItemAdapter.ViewHolder>
    {

        @Override
        public
        ViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType) {
            return new ViewHolder(new TextView(MainActivity.this));
        }

        @Override
        public
        void onBindViewHolder(final ViewHolder holder, final int position) {
        }

        @Override
        public
        int getItemCount() {
            return 100;
        }

        public
        class ViewHolder
                extends RecyclerView.ViewHolder
        {
            public TextView textView;

            public
            ViewHolder(final View itemView) {
                super(itemView);
                textView = (TextView) itemView;
            }
        }
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout android:id = "@+id/coordinatorLayout"
    xmlns:android = "http://schemas.android.com/apk/res/android"
    xmlns:app = "http://schemas.android.com/apk/res-auto"
    xmlns:tools = "http://schemas.android.com/tools"
    android:layout_width = "match_parent"
    android:layout_height = "match_parent"
    android:background = "#a3b1ef"
    android:fitsSystemWindows = "true"
    tools:context = ".ui.MainActivity"
    >

    <Button
        android:id = "@+id/btnView"
        android:layout_width = "match_parent"
        android:layout_height = "wrap_content"
        android:text = "Show view"
        app:layout_behavior = "@string/appbar_scrolling_view_behavior"
        />


    <LinearLayout
        android:id = "@+id/bottom_sheet"
        android:layout_width = "match_parent"
        android:layout_height = "400dp"
        android:background = "#fff"
        android:gravity = "center"
        android:orientation = "vertical"
        app:layout_behavior = "@string/bottom_sheet_behavior"
        >


        <android.support.design.widget.TabLayout
            android:id = "@+id/tabs"
            android:layout_width = "match_parent"
            android:layout_height = "wrap_content"
            app:tabMode = "scrollable"
            />

        <android.support.v4.view.ViewPager
            android:id = "@+id/viewPager"
            android:layout_width = "match_parent"
            android:layout_height = "match_parent"
            />

    </LinearLayout>
</android.support.design.widget.CoordinatorLayout>

Screenshot

Any ideas for a workaround?

Ingrain answered 9/6, 2016 at 2:19 Comment(0)
W
34

I came across the same limitation but were able to solve it.

The reason for the effect you described is that BottomSheetBehavior (as of v24.2.0) only supports one scrolling child which is identified during layout in the following way:

private View findScrollingChild(View view) {
    if (view instanceof NestedScrollingChild) {
        return view;
    }
    if (view instanceof ViewGroup) {
        ViewGroup group = (ViewGroup) view;
        for (int i = 0, count = group.getChildCount(); i < count; i++) {
            View scrollingChild = findScrollingChild(group.getChildAt(i));
            if (scrollingChild != null) {
                return scrollingChild;
            }
        }
    }
    return null;
}

You can see that it essentially finds the first scrolling child using DFS.

I slightly enhanced this implementation and assembled a small library as well as an example app. You can find it here: https://github.com/laenger/ViewPagerBottomSheet

Simply add the maven repo url to your build.gradle:

repositories {
    maven { url "https://raw.github.com/laenger/maven-releases/master/releases" }
}

Add the library to the dependencies:

dependencies {
    compile "biz.laenger.android:vpbs:0.0.2"
}

Use ViewPagerBottomSheetBehavior for your bottom sheet view:

app:layout_behavior="@string/view_pager_bottom_sheet_behavior"

Setup any nested ViewPager inside the bottom sheet:

BottomSheetUtils.setupViewPager(bottomSheetViewPager)

(This also works when the ViewPager is the bottom sheet view and for further nested ViewPagers)

sample implementation

Wimbush answered 9/7, 2016 at 11:42 Comment(8)
Great answer. However does not work, if BottomSheetDialogFragment is used as a BottomSheet. Have a look at this thread where such case is described.Quorum
this is an interesting addition indeed! would you mind opening a pull request to my repository to avoid the duplication of common code? github.com/laenger/ViewPagerBottomSheetWimbush
simply awesome.Walford
I am facing this issue in 26.1 too. The method code is unchanged. Is there any better way to do this? If I enable NestedScrolling for the viewPager and for the recyclerviews, then scrolling works in bottomsheet for both recyclerviews. But, I am not sure about any other side effects. I think it'll affect swipe to dismiss behaviorCorrody
Well, for the future reference - could you explain a bit on what your fix was? I have my own custom behavior and cannot use yours, thus i am forced to analyze and copy your work myself.Selfrestraint
Yes, the key changes are visible in Commit 2775715 of the mentioned library.Wimbush
you solved my big issue. Working fine. Thanks a lot @Wimbush !Mohsen
This worked for Bottom Sheet also.But how can I set the state of bottomSheet to expanded? as now it shows an error on initialsing the object of BottomSheetBehavoir. @WimbushCrockery
S
7

I have also been in this situation recently, and I've used the following custom viewpager class instead of the viewpager(on XML), and it worked very well, I think it will help you and others):


import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.viewpager.widget.ViewPager
import java.lang.reflect.Field

class BottomSheetViewPager(context: Context, attrs: AttributeSet?) : ViewPager(context, attrs) {
    constructor(context: Context) : this(context, null)
    private val positionField: Field =
        ViewPager.LayoutParams::class.java.getDeclaredField("position").also {
            it.isAccessible = true
        }

    init {
        addOnPageChangeListener(object : SimpleOnPageChangeListener() {
            override fun onPageSelected(position: Int) {
                requestLayout()
            }
        })
    }

    override fun getChildAt(index: Int): View {
        val stackTrace = Throwable().stackTrace
        val calledFromFindScrollingChild = stackTrace.getOrNull(1)?.let {
            it.className == "com.google.android.material.bottomsheet.BottomSheetBehavior" &&
                    it.methodName == "findScrollingChild"
        }
        if (calledFromFindScrollingChild != true) {
            return super.getChildAt(index)
        }

        val currentView = getCurrentView() ?: return super.getChildAt(index)
        return if (index == 0) {
            currentView
        } else {
            var view = super.getChildAt(index)
            if (view == currentView) {
               view = super.getChildAt(0)
            }
            return view
        }
    }

    private fun getCurrentView(): View? {
        for (i in 0 until childCount) {
            val child = super.getChildAt(i)
            val lp = child.layoutParams as? ViewPager.LayoutParams
            if (lp != null) {
                val position = positionField.getInt(lp)
                if (!lp.isDecor && currentItem == position) {
                    return child
                }
            }
        }
        return null
    }
}

Shaduf answered 18/8, 2019 at 11:32 Comment(5)
An elegant solution and easy to implement. Thank you :)Sallysallyann
You saved my day. So easy to implementSemantic
Thanks, I'm happy to help. StackOverflow has given me so much...Shardashare
Hey @Muhammad your solution works fine but until I bundle the app and minify it, it doesn't work anymore on release. I think the issue is due to the class com.google.android.material.bottomsheet.BottomSheetBehavior is also transformed. so how can I solve it ?Pedagogics
Updates: I just resole the issue by adding this line on proguard-rules.pro file. here: -keep class com.google.android.material.bottomsheet.BottomSheetBehavior Thanks anyway it now works even on release modePedagogics
S
6

This post saved my life: https://medium.com/@hanru.yeh/funny-solution-that-makes-bottomsheetdialog-support-viewpager-with-nestedscrollingchilds-bfdca72235c3

Show my fix for ViewPager inside bottomsheet.

package com.google.android.material.bottomsheet

import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.viewpager.widget.ViewPager
import java.lang.ref.WeakReference


class BottomSheetBehaviorFix<V : View> : BottomSheetBehavior<V>(), ViewPager.OnPageChangeListener {

    override fun onPageScrollStateChanged(state: Int) {}

    override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}

    override fun onPageSelected(position: Int) {
        val container = viewRef?.get() ?: return
        nestedScrollingChildRef = WeakReference(findScrollingChild(container))
    }

    @VisibleForTesting
    override fun findScrollingChild(view: View): View? {
        return if (view is ViewPager) {
            view.focusedChild?.let { findScrollingChild(it) }
        } else {
            super.findScrollingChild(view)
        }
    }
}
Secretive answered 4/1, 2019 at 2:22 Comment(0)
E
6

There is another approach that does not require modifying BottomSheetBehavior but instead leverages the fact that the BottomSheetBehavior only recognizes the first NestedScrollView with NestedScrollingEnabled it finds. So instead of altering this logic inside BottomSheetBehavior, enable and disable the appropriate scroll views. I discovered this approach here: https://imnotyourson.com/cannot-scroll-scrollable-content-inside-viewpager-as-bottomsheet-of-coordinatorlayout/

In my case my BottomSheetBehavior was using a TabLayout with a FragmentPagerAdapter so my FragmentPagerAdapter needed the following code:

@Override
public void setPrimaryItem(@NonNull ViewGroup container, int position, @NonNull Object object) {

        super.setPrimaryItem(container, position, object);

        Fragment f = ((Fragment)object);
        String activeFragmentTag = f.getTag();
        View view = f.getView();

        if (view != null) {
            View nestedView = view.findViewWithTag("nested");               
            if ( nestedView != null && nestedView instanceof NestedScrollView) {
                ((NestedScrollView)nestedView).setNestedScrollingEnabled(true);
            }
        }

        FragmentManager fm = f.getFragmentManager();

        for(Fragment frag : fm.getFragments()) {

            if (frag.getTag() != activeFragmentTag) {
                View v = frag.getView();
                if (v!= null) {

                    View nestedView = v.findViewWithTag("nested");

                    if (nestedView!= null && nestedView instanceof NestedScrollView) {
                        ((NestedScrollView)nestedView).setNestedScrollingEnabled(false);
                    }
                }
            }
        }

        container.requestLayout();
    }

Any nested scroll views in your fragments just need to have the "nested" tag.

Here is a sample Fragment layout file:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.MLeftFragment">

    <androidx.core.widget.NestedScrollView
        android:id="@+id/nestedScrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:tag="nested"
        android:fillViewport="true">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <!-- TODO: Update blank fragment layout -->
            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@string/hello_mool_left_fragment" />      

        </LinearLayout>  

    </androidx.core.widget.NestedScrollView>

</androidx.constraintlayout.widget.ConstraintLayout>
Eras answered 8/1, 2019 at 19:32 Comment(1)
wow man!!! you saved me a lot of time! thank you very much. it solved my problem, cheersGrazier
F
3

Assuming page is a NestedScrollView, I was able to solve the problem by toggling its isNestedScrollingEnabled property depending on whether or not it's the incoming or outgoing page.

val viewPager = findViewById<ViewPager>(R.id.viewPager)

viewPager.setPageTransformer(false) { page, position ->
    if (position == 0.0f) {
        page.isNestedScrollingEnabled = true
    } else if (position % 1 == 0.0f) {
        page.isNestedScrollingEnabled = false
    }
}
Fife answered 17/6, 2020 at 17:30 Comment(0)
S
2

I have the solution for AndroidX, Kotlin. Tested and working on 'com.google.android.material:material:1.1.0-alpha06'.

I also used this: MEDIUM BLOG as a guide.

Here is My ViewPagerBottomSheetBehavior Kotlin Class:

package com.google.android.material.bottomsheet
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.viewpager.widget.ViewPager
import java.lang.ref.WeakReference
class ViewPagerBottomSheetBehavior<V : View>
    : com.google.android.material.bottomsheet.BottomSheetBehavior<V>,
    ViewPager.OnPageChangeListener {

    constructor() : super()
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

    override fun onPageScrollStateChanged(state: Int) {}
    override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {}
    override fun onPageSelected(position: Int) {
        val container = viewRef?.get() ?: return
        nestedScrollingChildRef = WeakReference(findScrollingChild(container))
    }

    @VisibleForTesting
    override fun findScrollingChild(view: View?): View? {
        return if (view is ViewPager) {
            view.focusedChild?.let { findScrollingChild(it) }
        } else {
            super.findScrollingChild(view)
        }
    }
}

The final solutios was adding the super constructors in the Class:

constructor() : super()
constructor(context: Context, attrs: AttributeSet) : super(context, attrs)

Remember, you have to add ViewPagerBottomSheetBehavior Kotlin Class in the next path: Path Class Image reference because, you must override a private method>

@VisibleForTesting
override fun findScrollingChild(view: View?): View? {
    return if (view is ViewPager) {
        view.focusedChild?.let { findScrollingChild(it) }
    } else {
        super.findScrollingChild(view)
    }
}

After that, you can use it as a View attribute, like this>

        <androidx.constraintlayout.widget.ConstraintLayout
          app:layout_behavior="com.google.android.material.bottomsheet.ViewPagerBottomSheetBehavior"
            android:layout_height="match_parent"
            android:layout_width="match_parent">
        <include
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                layout="@layout/you_content_with_a_viewPager_scroll"
        />
    </androidx.constraintlayout.widget.ConstraintLayout>
Shardashare answered 13/6, 2019 at 2:42 Comment(0)
P
2

Looks like all that's required is updating nestedScrollingChildRef appropriately.

Simply setting it to the target parameter in onStartNestedScroll is working for me:

package com.google.android.material.bottomsheet

class ViewPagerBottomSheetBehavior<V : View>(context: Context, attrs: AttributeSet?) : BottomSheetBehavior<V>(context, attrs) {

    override fun onStartNestedScroll(coordinatorLayout: CoordinatorLayout, child: V, directTargetChild: View, target: View, axes: Int, type: Int): Boolean {
        nestedScrollingChildRef = WeakReference(target)
        return super.onStartNestedScroll(coordinatorLayout, child, directTargetChild, target, axes, type)
    }
}
Penurious answered 4/9, 2020 at 10:27 Comment(1)
This worked better than the other solutions in my case due to the complexity and scrolling behavior of the layouts within the tabs of my ViewPager.Educationist
B
1

Base on Hadi's answer in this thread we can use sth like this:

  • I think this is a good solution we can read " (requestDisallowInterceptTouchEvent), Called when a child does not want this parent and its ancestors to intercept touch events with ViewGroup.onInterceptTouchEvent(MotionEvent). " from android doc.

  • I tested several solutions and this is a kinda bug free and lag free solution that works on large lists!

  • this is just the kotlinish way and I changed root view from LinearLayout to ConstraintLayout :

    import android.content.Context
    import android.util.AttributeSet
    import android.view.MotionEvent
    import androidx.constraintlayout.widget.ConstraintLayout
    
    
    class DisallowInterceptView : ConstraintLayout {
        constructor(context: Context) : super(context) {
            requestDisallowInterceptTouchEvent(true)
        }
    
        constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {
            requestDisallowInterceptTouchEvent(true)
        }
    
        constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
            requestDisallowInterceptTouchEvent(true)
        }
    
        override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
            parent.requestDisallowInterceptTouchEvent(true)
            return super.dispatchTouchEvent(ev)
        }
    
        override fun onTouchEvent(event: MotionEvent): Boolean {
            when (event.action) {
                MotionEvent.ACTION_MOVE -> requestDisallowInterceptTouchEvent(true)
                MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> requestDisallowInterceptTouchEvent(false)
            }
            return super.onTouchEvent(event)
        }
    }
    
    

then change the bottom sheet container layout :

<com.vgen.vooop.utils.DisallowInterceptView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"
>
    .
    .
    .

</com.vgen.vooop.utils.DisallowInterceptView>
Biedermeier answered 26/12, 2021 at 11:15 Comment(0)
M
0

You just have to enable scrolling into the view pager as:

ViewCompat.setNestedScrollingEnabled(viewPager2, true);

and if scrolling is still absent add NestedScrollView to all your viewpager child fragments.

This problem exists as bottomsheet only enables scrolling to its first child view and you have to manually enable scrolling for your nested child fragments.

Mishandle answered 12/5, 2022 at 14:3 Comment(0)
M
0

a more elegant and non-invasive solution, wrote as kotlin:

BottomSheetBehaviorExtension.kt

import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.viewpager.widget.ViewPager
import androidx.viewpager.widget.ViewPagerUtils
import com.google.android.material.bottomsheet.BottomSheetBehavior
import java.lang.ref.WeakReference
import java.lang.reflect.Field

private val nestedScrollingChildRef: Field =
    BottomSheetBehavior::class.java.getDeclaredField("nestedScrollingChildRef")

fun BottomSheetBehavior<out ViewGroup>.resetScrollingChild(view: View) {
    // @Nullable WeakReference<View> nestedScrollingChildRef;
    val v = findScrollingChild(view)

    nestedScrollingChildRef.isAccessible = true
    nestedScrollingChildRef.set(this, WeakReference(v))
}

private fun findScrollingChild(view: View?): View? {
    if (view == null || view.visibility != View.VISIBLE) {
        return null
    }
    if (view is ViewPager) {
        // 通过 ViewPagerUtils 找到当前在界面上的页面
        val currentViewPagerChild = ViewPagerUtils.getCurrentView(view)
        val scrollingChild = findScrollingChild(currentViewPagerChild)
        return scrollingChild ?: currentViewPagerChild
    } else if (ViewCompat.isNestedScrollingEnabled(view)) {
        return view
    } else if (view is ViewGroup) {
        var i = 0
        val count = view.childCount
        while (i < count) {
            val scrollingChild = findScrollingChild(view.getChildAt(i))
            if (scrollingChild != null) {
                return scrollingChild
            }
            i++
        }
    }
    return null
}

ViewPagerUtils.java


package androidx.viewpager.widget;


import android.view.View;

public class ViewPagerUtils {
    public static View getCurrentView(ViewPager viewPager) {
        final int currentItem = viewPager.getCurrentItem();
        for (int i = 0; i < viewPager.getChildCount(); i++) {
            final View child = viewPager.getChildAt(i);
            final ViewPager.LayoutParams layoutParams = (ViewPager.LayoutParams) child.getLayoutParams();
            if (!layoutParams.isDecor && currentItem == layoutParams.position) {
                return child;
            }
        }
        return null;
    }
}

How to use:

viewPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
        override fun onPageScrolled(
            position: Int,
            positionOffset: Float,
            positionOffsetPixels: Int
        ) {
        }

        override fun onPageSelected(position: Int) {
            behavior.resetScrollingChild(viewPager)
        }

    override fun onPageScrollStateChanged(state: Int) {

    }
})

Munoz answered 11/9, 2023 at 1:54 Comment(1)
B
-1

I think setting the isNestedScrollingEnabled property in ViewPager to true is the simplest way to solve the issue.

Bellinzona answered 20/8, 2021 at 9:17 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.