CoordinatorLayout Behaviors: Change elevation of a View upon scrolling another one
Consider this: You need a button to change its elevation depending on the scroll state of a RecyclerView
(in fact any ScrollingView
): the button (which for various reasons I decided to implement as an ImageView
should have zero elevation when the RecyclerView is at scroll position 0 and 6 dp elevation otherwise.
The way to make this work is by leveraging CoordinatorLayout
and a custom Behavior
.
Behaviors are a quite flexible CoordinatorLayout feature. You can attach behaviors to any direct child of a CoordinatorLayout (or children of ViewGroup
s which define their own default behavior, but I won't dive into this here).
You can set behaviors either in code or in XML. I did the latter:
<androidx.coordinatorlayout.widget.CoordinatorLayout
...>
<ImageView
...
app:layout_anchor="@id/rcv"
app:layout_anchorGravity="top|start"
app:layout_behavior="com.example.ElevateOnScrollBehavior"
android:elevation="@{0}" />
<RecyclerView
...
android:id="@+id/rcv" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
First we are setting the initial elevation to 0 using android:elevation
, you'll note that this is a data binding expression here - more on that at the end of this post.
app:layout_behavior="com.example.ElevateOnScrollBehavior"
defines our elevation modifying behavior.
app:layout_anchor="@id/rcv"
tells the behavior which target view is relevant for it and should report its scroll state.
app:layout_anchorGravity="top|start"
defines the position of the ImageView relative to the RecyclerView
which for that sake is handled like a FrameLayout, that's why we can just define a gravity and need to fake everything else using paddings and margins.
Let’s have a look at the behavior. For our use case we are interested into to functions in the CoordinatorLayout.Behavior
interface: onStartNestedScroll()
and onNestedScroll()
The first is called before the actual scroll happens and allows us to decide whether we are interested in it based on properties like the axis or the scroll target.
The latter is then called every time a subsequent scroll happens and this is where we employ our magic.
class ElevateOnScrollBehavior<V : View> @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : CoordinatorLayout.Behavior<V>(context, attrs) {
override fun onStartNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: V,
directTargetChild: View,
target: View,
axes: Int,
type: Int
): Boolean =
axes == ViewCompat.SCROLL_AXIS_VERTICAL &&
target is ScrollingView
override fun onNestedScroll(
coordinatorLayout: CoordinatorLayout,
child: V,
target: View,
dxConsumed: Int,
dyConsumed: Int,
dxUnconsumed: Int,
dyUnconsumed: Int,
type: Int,
consumed: IntArray
) {
if ((target as ScrollingView).computeVerticalScrollOffset() > 0) {
6.dpToPx(coordinatorLayout.context.resources.displayMetrics).scoped { newElevation ->
if (newElevation != child.elevation) {
child.elevation = newElevation
}
}
} else if (child.elevation != 0f) {
child.setZeroElevation()
}
}
}
As you can see we are only interested in scrolls along the vertical axis and only in those coming from ScrollingView
s as this interface has a method we are going to use in onNestedScroll()
to determine the vertical scroll offset of the target view. Conveniently RecyclerView implements ScrollingView, so we are good to go here.
In onNestedScroll()
we simply check for the vertical scroll offset and if it is bigger than zero we give the View some (fixed for now) elevation, otherwise we set it to zero.
Unfortunately using View.setElevation(0f)
would result in a non-clickable view for some reason. Therefore I am using my custom View.setZeroElevation()
extension function and a custom data binding adapter for android:elevation
. More on that in Zero elevation - zero click handling?.