Search code examples
androidandroid-layoutandroid-recyclerviewandroid-viewandroid-viewpager2

Item's shadow inside a ViewPager2 is clipped


I have an activity that has a NavHostFragment. This NavHostFragment will host three fragments, two of which are FragmentA and FragmentB. Inside FragmentB, I have a ViewPager2 which has two pages: PageA and PageB, both are actually constructed from one fragment, FragmentC. Inside each PageA and PageB, I have one RecyclerView.

Here's the layout XML for FragmentB.

<?xml version="1.0" encoding="utf-8"?>
<layout 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">

    <data>

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:padding="@dimen/keyline_4"
        android:clipChildren="false"
        android:clipToPadding="false"
        tools:context=".ui.NavigationActivity">

        ...

        <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/frag_course_view_pager"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:layout_marginTop="@dimen/keyline_4"
            android:clipChildren="false"
            ... />

        <com.google.android.material.tabs.TabLayout
            android:id="@+id/frag_course_tablayout"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/keyline_4"
            ... />


    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

As you can see, I've set the fragment's root layout's clipChildren to false. I've also set the ViewPager2's clipChildren to false. Here's the layout XML for PageA and PageB (i.e. FragmentC).

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="viewmodel"
            type="com.mobile.tugasakhir.viewmodels.course.CourseTabViewModel" />
    </data>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/frag_course_tab_rv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingBottom="@dimen/offset_bottom_nav_bar_padding"
        android:clipChildren="false"
        android:clipToPadding="false"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

</layout>

As you can see, it only has RecyclerView and I've set clipChildren to false. The inflated XML layout for the ViewHolder of the RV is the following.

<?xml version="1.0" encoding="utf-8"?>
<layout
    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">

    <data>
        ...
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/component_course_container"
        android:layout_width="match_parent"
        android:layout_height="@dimen/course_card_height"
        android:background="@drawable/drawable_rounded_rect"
        android:backgroundTint="?attr/colorSurface"
        android:elevation="@dimen/elevation_0">
        ...

        ...

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

The clipped portion is the shadow from the elevation of the above ViewHolder's XML layout. As I've set all the clipChildren attributes from all parents to false, the shadow shouldn't have been clipped, yet it still is. Why is this happening? How can I prevent it from being clipped without changing the padding/margin?

Note: I also have a RecyclerView inside FragmentA, but the difference is that the RecyclerView inside FragmentA is not nested within a ViewPager2. Following the methods (setting all the parents' clipChildren to false) on FragmentA allows the RecyclerView's items to show their shadows.

Here's the image of the problem.

Problem


Update

Using the Layout Inspector, it seems like inside ViewPager2, there are more ViewGroups (marked with the red rectangle). My RecyclerView with its items clipped is marked with the green rectangle. Here's what the Layout Inspector shows.

Layout Inspector

As can be seen, inside ViewPager2, there is a ViewPager2$RecyclerViewImpl and inside it, there's a FrameLayout (I did not create these ViewGroups). It turns out that these two have clipChildren set to true even when the ViewPager2's clipChildren is set to false. I can target the ViewPager2$RecyclerViewImpl inside my FragmentB like so.

(viewPager.getChildAt(0) as ViewGroup).clipChildren = false

I then tried targetting the FrameLayout using a similar method.

((viewPager.getChildAt(0) as ViewGroup).getChildAt(0) as ViewGroup).clipChildren = false

However, I got an error saying that the second getChildAt(0) returns null. In my Layout Inspector, it clearly shows that there's a FrameLayout before my RecyclerView. This FrameLayout has its clipChildren set to true. I'm pretty sure that I have to set the FrameLayout's clipChildren to false in order for shadows to not be clipped, but I may be wrong.

Here are screenshots showing that I managed to set the clipChildren of RecyclerViewImpl to false and failed to set the clipChildren of FrameLayout to false respectively.

Success

Failure

Or is there a better way to unclip the shadows?

I obscured the layout preview for private reason; this causes the layout preview to be a white box.


Update 2

For those who would like to view the problem directly, simply run the app that I provided in this Github link. Use the Layout Inspector to see what I'm seeing.


Solution

  • This answer directly explains how we can target the clipChildren of the auto-generated problematic FrameLayout to false; thus unclipping the items' shadows . If you don't mind modifying your current XML layout, check out @Martin Marconcini's equally awesome answer (using a CardView to draw the shadow that is not clipped) to also solve this problem.


    As suspected, the mentioned FrameLayout is what's causing the children to be clipped. The FrameLayout is created by the class FragmentStateAdapter that extends the class RecyclerView.Adapter<FragmentViewHolder>. From what I've gathered, this FragmentViewHolder basically works similar to the normal RecyclerView's view holder. This FragmentViewHolder's itemView, for now, always returns a FrameLayout (which is a ViewGroup). The fact that the view holder will always return a ViewGroup seems unlikely to change even in the future.


    Dependency androidx.viewpager2:viewpager2:1.0.0

    If you're using the dependency above, you can see the FrameLayout being created inside the onCreateViewHolder function of the FragmentStateAdapter class (line 160).

    @NonNull
    @Override
    public final FragmentViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return FragmentViewHolder.create(parent);
    }
    

    The FragmentViewHolder.create(parent) method will always create a FrameLayout view holder. This will be passed as a parameter (holder) in the onBindViewHolder of FragmentStateAdapter. Method declaration for FragmentViewHolder.create is as follows.

    @NonNull static FragmentViewHolder create(@NonNull ViewGroup parent) {
        FrameLayout container = new FrameLayout(parent.getContext());
        container.setLayoutParams(
                new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.MATCH_PARENT));
        container.setId(ViewCompat.generateViewId());
        container.setSaveEnabled(false);
        return new FragmentViewHolder(container);
    }
    

    Setting the clipChildren attribute to false

    As we now know that the holder will be passed as a parameter in the onBindViewHolder method of FragmentStateAdapter, we can override onBindViewHolder (inside your own adapter class that extends FragmentStateAdapter), set the holder.itemView's clipChildren attribute to false and voila, the item inside the RecyclerView will no longer be clipped.

    The final code to my adapter class is as follows.

    class PagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
        override fun getItemCount(): Int = 1
    
        override fun createFragment(position: Int): Fragment {
            return PageFragment().apply {
                arguments = Bundle()
            }
        }
    
        // Setting its clipChildren to false
        override fun onBindViewHolder(
            holder: FragmentViewHolder,
            position: Int,
            payloads: MutableList<Any>
        ) {
            (holder.itemView as ViewGroup).clipChildren = false
            super.onBindViewHolder(holder, position, payloads)
        }
    }
    

    Other parent layouts above the hierarchy that have bounding boxes smaller than the needed area for the shadow also need their clipChildren set to false (namely, the RecyclerViewImpl parent layout generated automatically by ViewPager2 [as mentioned in the question]).


    Update

    I've updated the previous Github code so that it includes the final solution to set clipChildren of both RecyclerViewImpl and FrameLayout (of the ViewPager2) to false in MainFragment.kt and PagerAdapter.kt. Here's the link: link to working example