How to avoid CollapsingToolbarLayout not being snapped or being "wobbly" when scrolling?

Viewed 6286

Background

Suppose you have an app you've created that has a similar UI as the one you can create via the wizard of "scrolling activity", yet you wish the scrolling flags to have snapping, as such:

<android.support.design.widget.CollapsingToolbarLayout ... app:layout_scrollFlags="scroll|exitUntilCollapsed|snap" >

The problem

As it turns out, on many cases it has issues of snapping. Sometimes the UI doesn't snap to top/bottom, making the CollapsingToolbarLayout stay in between.

Sometimes it also tries to snap to one direction, and then decides to snap to the other .

You can see both issues on the attached video here.

What I've tried

I thought it's one of the issues that I got for when I use setNestedScrollingEnabled(false) on a RecyclerView within, so I asked about it here, but then I noticed that even with the solution and without using this command at all and even when using a simple NestedScrollView (as is created by the wizard), I can still notice this behavior.

That's why I decided to report about this as an issue, here.

Sadly, I couldn't find any workaround for those weird bugs here on StackOverflow.

The question

Why does it occur, and more importantly: how can I avoid those issues while still using the behavior it's supposed to have?


EDIT: here's a nice improved Kotlin version of the accepted answer:

class RecyclerViewEx @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) : RecyclerView(context, attrs, defStyle) {
    private var mAppBarTracking: AppBarTracking? = null
    private var mView: View? = null
    private var mTopPos: Int = 0
    private var mLayoutManager: LinearLayoutManager? = null

    interface AppBarTracking {
        fun isAppBarIdle(): Boolean
        fun isAppBarExpanded(): Boolean
    }

    override fun dispatchNestedPreScroll(dx: Int, dy: Int, consumed: IntArray?, offsetInWindow: IntArray?, type: Int): Boolean {
        if (mAppBarTracking == null)
            return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
        if (type == ViewCompat.TYPE_NON_TOUCH && mAppBarTracking!!.isAppBarIdle()
                && isNestedScrollingEnabled) {
            if (dy > 0) {
                if (mAppBarTracking!!.isAppBarExpanded()) {
                    consumed!![1] = dy
                    return true
                }
            } else {
                mTopPos = mLayoutManager!!.findFirstVisibleItemPosition()
                if (mTopPos == 0) {
                    mView = mLayoutManager!!.findViewByPosition(mTopPos)
                    if (-mView!!.top + dy <= 0) {
                        consumed!![1] = dy - mView!!.top
                        return true
                    }
                }
            }
        }
        if (dy < 0 && type == ViewCompat.TYPE_TOUCH && mAppBarTracking!!.isAppBarExpanded()) {
            consumed!![1] = dy
            return true
        }

        val returnValue = super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
        if (offsetInWindow != null && !isNestedScrollingEnabled && offsetInWindow[1] != 0)
            offsetInWindow[1] = 0
        return returnValue
    }

    override fun setLayoutManager(layout: RecyclerView.LayoutManager) {
        super.setLayoutManager(layout)
        mLayoutManager = layoutManager as LinearLayoutManager
    }

    fun setAppBarTracking(appBarTracking: AppBarTracking) {
        mAppBarTracking = appBarTracking
    }

    fun setAppBarTracking(appBarLayout: AppBarLayout) {
        val appBarIdle = AtomicBoolean(true)
        val appBarExpanded = AtomicBoolean()
        appBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener {
            private var mAppBarOffset = Integer.MIN_VALUE

            override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) {
                if (mAppBarOffset == verticalOffset)
                    return
                mAppBarOffset = verticalOffset
                appBarExpanded.set(verticalOffset == 0)
                appBarIdle.set(mAppBarOffset >= 0 || mAppBarOffset <= -appBarLayout.totalScrollRange)
            }
        })
        setAppBarTracking(object : AppBarTracking {
            override fun isAppBarIdle(): Boolean = appBarIdle.get()
            override fun isAppBarExpanded(): Boolean = appBarExpanded.get()
        })
    }

    override fun fling(velocityX: Int, inputVelocityY: Int): Boolean {
        var velocityY = inputVelocityY
        if (mAppBarTracking != null && !mAppBarTracking!!.isAppBarIdle()) {
            val vc = ViewConfiguration.get(context)
            velocityY = if (velocityY < 0) -vc.scaledMinimumFlingVelocity
            else vc.scaledMinimumFlingVelocity
        }

        return super.fling(velocityX, velocityY)
    }
}
4 Answers

Since the issue is still not fixed as of February 2020 (latest material library version is 1.2.0-alpha5) I want to share my solution to the buggy AppBar animation.

The idea is to implmenet custom snapping logic by extending AppBarLayout.Behavior (Kotlin version):

package com.example

import android.content.Context
import android.os.Handler
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.coordinatorlayout.widget.CoordinatorLayout
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams

@Suppress("unused")
class AppBarBehaviorFixed(context: Context?, attrs: AttributeSet?) :
    AppBarLayout.Behavior(context, attrs) {

    private var view: AppBarLayout? = null
    private var snapEnabled = false

    private var isUpdating = false
    private var isScrolling = false
    private var isTouching = false

    private var lastOffset = 0

    private val handler = Handler()

    private val snapAction = Runnable {
        val view = view ?: return@Runnable
        val offset = -lastOffset
        val height = view.run { height - paddingTop - paddingBottom - getChildAt(0).minimumHeight }

        if (offset > 1 && offset < height - 1) view.setExpanded(offset < height / 2)
    }

    private val updateFinishDetector = Runnable {
        isUpdating = false
        scheduleSnapping()
    }

    private fun initView(view: AppBarLayout) {
        if (this.view != null) return

        this.view = view

        // Checking "snap" flag existence (applied through child view) and removing it
        val child = view.getChildAt(0)
        val params = child.layoutParams as LayoutParams
        snapEnabled = params.scrollFlags hasFlag LayoutParams.SCROLL_FLAG_SNAP
        params.scrollFlags = params.scrollFlags removeFlag LayoutParams.SCROLL_FLAG_SNAP
        child.layoutParams = params

        // Listening for offset changes
        view.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { _, offset ->
            lastOffset = offset

            isUpdating = true
            scheduleSnapping()

            handler.removeCallbacks(updateFinishDetector)
            handler.postDelayed(updateFinishDetector, 50L)
        })
    }

    private fun scheduleSnapping() {
        handler.removeCallbacks(snapAction)
        if (snapEnabled && !isUpdating && !isScrolling && !isTouching) {
            handler.postDelayed(snapAction, 50L)
        }
    }

    override fun onLayoutChild(
        parent: CoordinatorLayout,
        abl: AppBarLayout,
        layoutDirection: Int
    ): Boolean {
        initView(abl)
        return super.onLayoutChild(parent, abl, layoutDirection)
    }

    override fun onTouchEvent(
        parent: CoordinatorLayout,
        child: AppBarLayout,
        ev: MotionEvent
    ): Boolean {
        isTouching =
            ev.actionMasked != MotionEvent.ACTION_UP && ev.actionMasked != MotionEvent.ACTION_CANCEL
        scheduleSnapping()
        return super.onTouchEvent(parent, child, ev)
    }

    override fun onStartNestedScroll(
        parent: CoordinatorLayout,
        child: AppBarLayout,
        directTargetChild: View,
        target: View,
        nestedScrollAxes: Int,
        type: Int
    ): Boolean {
        val started = super.onStartNestedScroll(
            parent, child, directTargetChild, target, nestedScrollAxes, type
        )

        if (started) {
            isScrolling = true
            scheduleSnapping()
        }

        return started
    }

    override fun onStopNestedScroll(
        coordinatorLayout: CoordinatorLayout,
        abl: AppBarLayout,
        target: View,
        type: Int
    ) {
        isScrolling = false
        scheduleSnapping()

        super.onStopNestedScroll(coordinatorLayout, abl, target, type)
    }


    private infix fun Int.hasFlag(flag: Int) = flag and this == flag

    private infix fun Int.removeFlag(flag: Int) = this and flag.inv()

}

And now apply this behavior to the AppBarLayout in xml:

<android.support.design.widget.CoordinatorLayout>

    <android.support.design.widget.AppBarLayout
        app:layout_behavior="com.example.AppBarBehaviorFixed">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">

            <!-- Toolbar declaration -->

        </com.google.android.material.appbar.CollapsingToolbarLayout>

    </android.support.design.widget.AppBarLayout>

    <!-- Scrolling view (RecyclerView, NestedScrollView) -->

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

That is still a hack but it seems to work quite well, and it does not require to put dirty code into your activity or extend RecyclerView and NestedScrollView widgets (thanks to @vyndor for this idea).

Related