Android: RecyclerView is not scrolling within CoordinatorLayout

572 Views Asked by At

I have custom LinearLayout which height can be set to maximum or minimum based on content it holds. But if there is RecyclerView inside this LinearLayout, onMeasure is still returning 0 when I call adapter.notifyDataSetChange() in order to refresh RecyclerView content.

There is like 300 items in RecyclerView but onMeasure for parent of RecyclerView is still measuring 0 height. I need to cap height of RecyclerView to certain value which is set on view initialization.

Custom LinearLayout

class LinearLayoutWithVariableHeight: LinearLayout {
    companion object {
        var WITHOUT_HEIGHT_VALUE = -1
    }

    private var maxHeight = WITHOUT_HEIGHT_VALUE
    private var minHeight = WITHOUT_HEIGHT_VALUE

    constructor(context: Context) : super(context) {}
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {}
    constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) {}

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        var measuredHeight = heightMeasureSpec
        val currHeight = getCurrContentHeight()
        try {
            val heightSize: Int
            App.log("LinearLayoutWithMaxHeight: onMeasure curr_height: $currHeight, max: $maxHeight")
            if (maxHeight != WITHOUT_HEIGHT_VALUE && currHeight > maxHeight) {
                App.log("LinearLayoutWithMaxHeight: onMeasure set xy max height")
                heightSize = maxHeight
            } else if (minHeight != WITHOUT_HEIGHT_VALUE && currHeight < minHeight){
                App.log("LinearLayoutWithMaxHeight: onMeasure set xy min height")
                heightSize = min(minHeight, maxHeight)
            } else {
                heightSize = currHeight
            }

            App.log("LinearLayoutWithMaxHeight: onMeasure heightSize: $heightSize")
            measuredHeight = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.AT_MOST)
            layoutParams.height = heightSize
        } catch (e: Exception) {

        } finally {
            App.log("LinearLayoutWithMaxHeight: onMeasure final: $measuredHeight")
            super.onMeasure(widthMeasureSpec, measuredHeight)
        }
    }

    private fun getCurrContentHeight(): Int{
        var height = 0
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            val h = child.measuredHeight
            App.log("LinearLayoutWithMaxHeight: getCurrChildHeight: $h")
            if (h > height) height = h
        }

        return height
    }

    fun setMaxHeight(maxHeight: Int) {
        this.maxHeight = maxHeight
    }

    fun setMinHeight(minHeight: Int) {
        this.minHeight = minHeight
    }
}

Update: I tried to make something similar to RecyclerView instead of its parent, it worked well but RecyclerView is not scrollable now:

class RecyclerViewWithMaxHeight: RecyclerView {
    companion object {
        var WITHOUT_HEIGHT_VALUE = -1
    }

    private var maxHeight = WITHOUT_HEIGHT_VALUE

    constructor(context: Context) : super(context) {}
    constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) {}
    constructor(context: Context, attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) {}

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        App.log("RecyclerViewWithMaxHeight: onMeasure")
        super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST))
    }

    fun setMaxHeight(maxHeight: Int) {
        this.maxHeight = maxHeight
    }
}

main_content_layout.xml:

<RelativeLayout
        android:orientation="vertical"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:clipToPadding="false"
        android:clipChildren="false"
        android:background="@color/content"
        app:layout_insetEdge="bottom"
        app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">

        <RelativeLayout
            android:id="@+id/dialog_top"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <LinearLayout
                android:id="@+id/dialog_top_corners"
                android:layout_width="match_parent"
                android:layout_height="20dp"
                android:orientation="horizontal"
                android:elevation="0dp"
                android:translationZ="@dimen/padding_small"
                android:background="@drawable/bg_bottomsheet_top">

            </LinearLayout>

        </RelativeLayout>

        <RelativeLayout
            android:id="@+id/dialog_main_content"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_below="@id/dialog_top">

            <LinearLayout
                android:id="@+id/dialog_title_content"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:background="@color/content"
                android:orientation="vertical">

            </LinearLayout>

            <LinearLayout
                android:id="@+id/dialog_content"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_below="@id/dialog_title_content"
                android:orientation="vertical">

                <com.app.components.RecyclerViewWithMaxHeight
                    android:id="@+id/list"
                    android:clipToPadding="false"
                    android:clipChildren="false"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    tools:listitem="@layout/test_item"/>

            </LinearLayout>

            <LinearLayout
                android:id="@+id/buttonLayout"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignBottom="@id/dialog_content"
                android:layout_gravity="center_horizontal"
                android:gravity="center_horizontal"
                android:layout_centerHorizontal="true"
                android:orientation="vertical">

            </LinearLayout>

        </RelativeLayout>

    </RelativeLayout>

UPDATE 2:

So if I use only this layout above, It is scrolling perfectly. But as this layout is part of bottomsheet which is part of another layout, I have to wrap it with CoordinatorLayout and some parent(in this case FrameLayout). If I do it, then scrolling of RecyclerView is not working. But I'm not sure why it is happening. I need to have this scrolling inside BottomSheet which is logically part of CoordinatorLayout because in other case I can't use BottomSheetBehavior.

<androidx.coordinatorlayout.widget.CoordinatorLayout
        android:id="@+id/bottomContentContainer"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:clipChildren="false">
        ... some other content anchored to top of content below
        <FrameLayout
            android:id="@+id/bottomSheetContainer"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:layout_insetEdge="bottom"
            app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">

            ... main_content_layout.xml

        </FrameLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

I've tried to add app:layout_behavior="@string/appbar_scrolling_view_behavior" into my RecyclerView layout but same result - can't scroll it.

Layou expectation: enter image description here

UPDATE 3 As I'm going now with non-NestedScrollView version using single RecyclerView inside CoordinatorLayout, I found some issue with touch events.

I can scroll RecyclerView if I start to drag it right on boundaries between RecyclerView and non-scrollable View above. I have to put my finger there specifically to start scrolling. If I do it in the middle of RecyclerView for example, it won't scroll.

Added small onTouchListener and indeed its not firing at all if RecyclerView is under CoordinatorLayout.

list.setOnTouchListener { view, motionEvent ->
                App.log("ListOnTouch -> motionEvent: $motionEvent")
                true
            }

I'm not sure why CoordinatorLayout is blocking touch events of my RecyclerView and why its not blocked right on boundaries between list and other View above.

I've tried to add android:descendantFocusability="afterDescendants" to CoordinatorLayout top-most View (In my case FrameLayout) but its not helping at all.

UPDATE 4

Since I possibly found an issue which is causing this weird behavior here is some insight to it.

As I mentioned I'm using this CoordinatorLayout to have BottomsheetBehavior attached to its top-most child.

As I found out CoordinatorLayout is not supporting multiple scrollable children within its top-most child because BottomsheetBehavior is working only with 1st scroll child in View hierarchy.

I have horizontal RecyclerView on top and then vertical one below it.

I made this custom BottomsheetBehavior which should handle focus on each RecyclerView inside CoordinatorLayout.

And its finally working, but I have an issue with scrolling behavior in it:

  • If I touch it, it always scrolls my RecyclerView to top and start from there
  • If I scroll and lift my finger from touchscreen, it stops scrolling (there is not that generic fast-scroll effect).

I need to solve these issues and it should work correctly.

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

    private var activeRecyclerView: RecyclerView? = null
    private var coordinatorHeight = 0
    override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
        val lp = child.layoutParams as CoordinatorLayout.LayoutParams
        if (lp.height == CoordinatorLayout.LayoutParams.MATCH_PARENT) {
            child.layout(0, parent.height - peekHeight, parent.width, parent.height)
        } else {
            super.onLayoutChild(parent, child, layoutDirection)
        }

        // get the height of the CoordinatorLayout
        App.log("MultiScrollBottomsheetBehavior: onLayoutChild: parent: ${parent.height}")
        coordinatorHeight = parent.height

        return true
    }

    override fun onInterceptTouchEvent(
        parent: CoordinatorLayout,
        child: V,
        event: MotionEvent
    ): Boolean {
        if (event.action == MotionEvent.ACTION_DOWN) {
            // Find the topmost RecyclerView that is below the touch point
            activeRecyclerView = findChildRecyclerView(parent, event.x.toInt(), event.y.toInt())
        }
        return super.onInterceptTouchEvent(parent, child, event)
    }

    override fun onTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean {
        // Forward touch events to the topmost RecyclerView
        return activeRecyclerView?.onTouchEvent(event) ?: super.onTouchEvent(parent, child, event)
    }

    override fun onNestedPreScroll(
        coordinatorLayout: CoordinatorLayout, child: V,
        target: View, dx: Int, dy: Int, consumed: IntArray,
        type: Int
    ) {
        if (type == ViewCompat.TYPE_TOUCH && state == STATE_EXPANDED) {
            // Forward scroll events to the topmost RecyclerView
            activeRecyclerView?.let {
                if (dy > 0 && !it.canScrollVertically(1)) {
                    // If the RecyclerView is scrolled to the bottom and the user tries to scroll further down,
                    // let the default behavior handle the event
                    super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
                } else {
                    // Otherwise, forward the event to the RecyclerView
                    it.scrollBy(0, dy)
                    consumed[1] = dy
                }
            }
        } else {
            super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
        }
    }

    /**
     * Recursively searches the [parent] view hierarchy for a [RecyclerView] that contains the given [x] and [y] coordinates.
     */
    private fun findChildRecyclerView(parent: ViewGroup, x: Int, y: Int): RecyclerView? {
        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            if (child is RecyclerView && isPointInsideView(x, y, child)) {
                return child
            } else if (child is ViewGroup) {
                val recyclerView = findChildRecyclerView(child, x, y)
                if (recyclerView != null) {
                    return recyclerView
                }
            }
        }
        return null
    }

    /**
     * Returns true if the given [x] and [y] coordinates are inside the bounds of the [view].
     */
    private fun isPointInsideView(x: Int, y: Int, view: View): Boolean {
        val location = IntArray(2)
        view.getLocationOnScreen(location)
        val viewX = location[0]
        val viewY = location[1]
        return (x >= viewX && x <= viewX + view.width && y >= viewY && y <= viewY + view.height)
    }
}

And I just add it to my top-most parent like this:

<FrameLayout
                android:id="@+id/bottomSheetContainer"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:layout_insetEdge="bottom"
                app:layout_behavior="com.project.utils.MultiScrollRecyclerViewBottomsheetBehavior">

            </FrameLayout>
1

There are 1 best solutions below

4
Ghazanfar Ateeb On

I have stuck with a similar issue but with CollapsingToolbar though maybe the same thing might get applied in it too. Try to do the following changes in your main_content_layout.xml

  1. Add NestedScrollBar having this property app:layout_behavior="@string/appbar_scrolling_view_behavior" having inner parent layout of your choice.
  2. Add this property android:nestedScrollingEnabled="false" in your RecyclerView which is inside the Scrollable container for proper scrolling.