Android ViewPager with RecyclerView works incorrectly inside BottomSheet

15,800

Solution 1

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

Solution 2

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>

Solution 3

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)
        }
    }
}

Solution 4

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
    }
}

Solution 5

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>
Share:
15,800

Related videos on Youtube

Vitaly
Author by

Vitaly

Updated on June 18, 2022

Comments

  • Vitaly
    Vitaly almost 2 years

    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?

  • R. Zagórski
    R. Zagórski over 7 years
    Great answer. However does not work, if BottomSheetDialogFragment is used as a BottomSheet. Have a look at this thread where such case is described.
  • laenger
    laenger over 7 years
    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/ViewPagerBottomSheet
  • eRaisedToX
    eRaisedToX about 7 years
    simply awesome.
  • Manas Chaudhari
    Manas Chaudhari about 6 years
    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 behavior
  • Kirill Starostin
    Kirill Starostin almost 6 years
    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.
  • laenger
    laenger almost 6 years
    Yes, the key changes are visible in Commit 2775715 of the mentioned library.
  • Ivan Karpiuk
    Ivan Karpiuk about 5 years
    wow man!!! you saved me a lot of time! thank you very much. it solved my problem, cheers
  • Ajay
    Ajay over 4 years
    you solved my big issue. Working fine. Thanks a lot @laenger !
  • Qazi Fahim Farhan
    Qazi Fahim Farhan over 4 years
    An elegant solution and easy to implement. Thank you :)
  • ysfcyln
    ysfcyln over 4 years
    You saved my day. So easy to implement
  • Srushti Suvarna
    Srushti Suvarna about 4 years
    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. @laenger
  • markomoreno
    markomoreno over 3 years
    Thanks, I'm happy to help. StackOverflow has given me so much...
  • Erik Pedersen
    Erik Pedersen over 3 years
    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.