As you might have guessed from the title, this article will be about CoordinatorLayout.Behavior and how to customize it to your needs. To make it easier to grasp, I’ll use a feature I had to recently implement in the Picnic app as an example — since it required coordinating different views and modifying their pre-existing behaviors.

Before we start, I strongly suggest you take a couple of minutes to read this article, by Ian Lake, for a thorough description of everything you can do with this component. If you want to skip it for now, just remember that CoordinatorLayout is a regular FrameLayout that intercepts touch events and delegates them to its children’s Behaviors , which means it’s up to them to decide what to do with gestures such as scrolls, drags, and flings.

The goal

The feature to implement — the message center — is relatively straight forward. There we display relevant pieces of information to the user, e.g. a card informing about an order being delivered or a voucher code they can share with their friends for a discount the next time they order. It will sit on top of their store and be accessible via tap/drag, as you can see on the video below.

Prototype of the MessageCenterView

Here are the requirements for it:

1. When tapped, the message center expands to show the first card completely.

2. If the user drags it to open and releases it half-way across, the message center snaps to the scroll direction.

3. If it is dragged further up, it won’t snap, but rather stay at the position it was left by the user.

4. If the user interacts with the store by scrolling, the message center should be hidden so it’s not in the way.

Default Behaviors

Seems pretty easy, right? I create a MessageCenterView — which extends a NestedScrollView — and add it to the store layout file.

<android.support.design.widget.CoordinatorLayout <android.support.v4.view.ViewPager /> <android.support.design.widget.AppBarLayout>

<android.support.design.widget.CollapsingToolbarLayout>

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

<include layout=”@layout/include_toolbar”/>

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

</android.support.design.widget.AppBarLayout> <com.picnic.android.ui.widget.MessageCenterView

app:layout_behavior=”@string/bottom_sheet_behavior” /> </android.support.design.widget.CoordinatorLayout>

However, the default BottomSheetBehavior fails to address most of the requirements.

First of all, it doesn’t allow intermediary positions; either it is collapsed or fully-expanded to the parent’s height, and it will always snap to the direction of scroll — so this is a blocker for requirement 3. Secondly, it is not affected by scrolling the store.

An additional unexpected problem was that opening the message center by dragging caused the CollapsingToolbarLayout to collapse, which feels completely glitchy.

So it’s time to work on our solution to address these issues.

Creating your custom Behavior

Disclaimer: the code below is written in Kotlin, but should be pretty understandable.

First thing's first, we should make sure that our custom Behavior can open to a predefined position between the BottomSheet peek height and the parent’s height, which we will call the anchor point — hence our custom behavior will be called AnchoredBottomSheetBehavior . Ideally we should be able to extend the BottomSheetBehavior and only change what we need, but most of the methods/parameters we need are either private or package protected. So, I went with the ugly but effective solution of copying the whole file and adding it to the project.

In order to include this anchor point, I inspired myself in the implementation of peekHeight . I added a anchorHeight and a related state called State.ANCHORED which will allow us to snap to it when the message center is tapped. I used the new parameter on the onLayoutChildMethod as follows:

override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {

…

minOffset = Math.max(0, parentHeight — child.height)

maxOffset = Math.max(parentHeight — peekHeight, minOffset)

anchorOffset = if (anchorHeight > 0) parentHeight — anchorHeight else minOffset



if (_state == State.EXPANDED) {

ViewCompat.offsetTopAndBottom(child, minOffset)

} else if (_state == State.ANCHORED) {

ViewCompat.offsetTopAndBottom(child, anchorOffset)

} else if (isHideable && _state == State.HIDDEN) {

ViewCompat.offsetTopAndBottom(child, parentHeight)

} else if (_state == State.COLLAPSED) {

ViewCompat.offsetTopAndBottom(child, maxOffset)

} else if (_state == State.DRAGGING || _state == State.SETTLING) {

ViewCompat.offsetTopAndBottom(child, savedTop — child.top)

}

…

}

A bit of explaining is due, of course. Each offset variable represents the difference in height between all the elements involved. minOffset is the difference between parent and child height, maxOffset is the difference between parent and peek height, and finally anchorOffset is the difference between parent and anchor height. If there’s no anchorHeight , it just defaults to expanding to the whole parent.

Also if the initial state is State.ANCHORED , we open the view to the anchorHeight .

That takes care of the initial state, but we still need to handle the user dragging, which is done in the startSettlingAnimation called by the setState method. So when we use bottomSheetBehavior.state = State.ANCHORED it also executes:

private fun startSettlingAnimation(child: View, state: State) {

val top = when (state) {

State.ANCHORED -> anchorOffset

State.EXPANDED -> minOffset

State.COLLAPSED -> maxOffset

State.HIDDEN -> if (isHideable) parentHeight else throw IllegalArgumentException(“Illegal state argument: “ + state)

else -> throw IllegalArgumentException(“Illegal state argument: “ + state)

}



setStateInternal(State.SETTLING)

if (viewDragHelper?.smoothSlideViewTo(child, child.left, top) == true) {

ViewCompat.postOnAnimation(child, SettleRunnable(child, state))

}

}

As you can see, it will smoothly slide the view to the anchorOffset, which allows us to call setState when the user taps the message center and opens it to the right position. Requirement 1 ✅

Next on the task list, handling the scenarios when the user stops dragging the message center. If you recall, we should snap to the drag direction when below the anchorHeight and leave it where it was left otherwise. The best place to do this is the onStopNestedScroll method, called whenever the user stops interacting with the children of the CoordinatorLayout . Here's what we do:

override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: V, target: View) {

// lastNestedScrollDy is positive when scrolling up …

val currentTop = child.top

val isBelowAnchorPoint = Math.abs(currentTop — maxOffset) < Math.abs(anchorOffset — maxOffset)

val (top, targetState) = when {

lastNestedScrollDy > 0 && isBelowAnchorPoint -> anchorOffset to State.ANCHORED

lastNestedScrollDy > 0 -> child.top to State.DRAGGING

isHideable && shouldHide(child, yVelocity) -> parentHeight to State.HIDDEN

isBelowAnchorPoint -> maxOffset to State.COLLAPSED

else -> currentTop to State.DRAGGING

} if (viewDragHelper?.smoothSlideViewTo(child, child.left, top) == true) {

setStateInternal(State.SETTLING)

ViewCompat.postOnAnimation(child, SettleRunnable(child, targetState))

} else {

setStateInternal(targetState)

}

}

We decide what is the final state based on the position of the BottomSheet , defined by currentTop , and the drag direction, defined by lastNestedScrollDy . Depending on their values, we might snap to a certain state or just leave the state as State.DRAGGING so the user can freely release the BottomSheet . This however is far from smooth and prevents the user from properly flinging the BottomSheet , since we only care about the position of the view and not the gesture speed.

Luckily BottomSheetBehavior already uses a VelocityTrackerCompat to determine the velocity with which the user scrolled. Taking that into consideration before defining the final view position after dragging, we can update our onStopNestedScroll .

private val dragFriction = 0.3f

private val yVelocity: Float

get() {

velocityTracker?.computeCurrentVelocity(1000, maximumVelocity)

return VelocityTrackerCompat.getYVelocity(velocityTracker, activePointerId)

} override fun onStopNestedScroll(coordinatorLayout: CoordinatorLayout, child: V, target: View) {

…

val currentTop = child.top

val isBelowAnchorPoint = Math.abs(currentTop — maxOffset) < Math.abs(anchorOffset — maxOffset)

val maxUpFling = Math.max(child.top + yVelocity * dragFriction, minOffset.toFloat()).toInt()

val maxDownFling = Math.min(child.top + yVelocity * dragFriction, anchorOffset.toFloat()).toInt() val (top, targetState) = when {

lastNestedScrollDy > 0 && isBelowAnchorPoint -> anchorOffset to State.ANCHORED

lastNestedScrollDy > 0 -> maxUpFling to State.DRAGGING

isHideable && shouldHide(child, yVelocity) -> parentHeight to State.HIDDEN

isBelowAnchorPoint -> maxOffset to State.COLLAPSED

else -> maxDownFling to State.DRAGGING

}

…

}

We multiply our drag velocity by an empirically defined dragFriction and add that to the final position of the MessageCenterView . This gives the user the impression of fluid movement and handles flinging quite nicely. By experimenting with the dragFriction we're able to make the dragging stiffer or looser. And with that, we're done with requirement 2 and 3 ✅

Coordinating with the ViewPager

The final step is hiding the MessageCenterView once the store is scrolled down and presenting it when the store is scrolled up. This is similar to how a AppBarLayout with app:layout_scrollFlags="scroll|enterAlways" works. The basic idea is that when a gesture is initiated by the ViewPager , the MessageCenterView should respond appropriately.

To achieve it, we need to modify the onNestedPreScroll method, which is called whenever the user starts to interact with any of the children of the CoordinatorLayout — if the gesture was accepted in onStartNestedScroll . We'll talk about this one shortly. If the user scrolls down ( dy > 0 ), the Behavior should change its state to State.HIDDEN and to State.COLLAPSED in the case of an upward scroll.

Here's the final result:

override fun onNestedPreScroll(coordinatorLayout: CoordinatorLayout, child: V, target: View, dx: Int, dy: Int, consumed: IntArray) {

if (target is ViewPager) {

isHideable = true

val currentTop = child.top

val newTop = currentTop + dy

if (dy > 0) {

if (newTop >= parentHeight) {

consumed[1] = currentTop — parentHeight

ViewCompat.offsetTopAndBottom(child, -consumed[1])

setStateInternal(State.HIDDEN)

} else if (newTop >= maxOffset) {

consumed[1] = dy

ViewCompat.offsetTopAndBottom(child, dy)

setStateInternal(State.DRAGGING)

}

} else if (dy < 0) {

if (newTop > maxOffset) {

consumed[1] = dy

ViewCompat.offsetTopAndBottom(child, dy)

setStateInternal(State.DRAGGING)

} else {

consumed[1] = currentTop — maxOffset

ViewCompat.offsetTopAndBottom(child, -consumed[1])

setStateInternal(State.COLLAPSED)

}

}

} else {

…

}

}

We need to make the MessageCenterView hideable otherwise setStateInternal(State.HIDDEN) would throw an exception. We reset that value when the user interacts directly with the MessageCenterView . Then, we change the view position with ViewCompat.offsetTopAndBottom based on the constraints previously defined and notify the parent CoordinatorLayout about how many pixel were consumed in this iteration by setting consumed[1] . The work's done for this view.

Icing on the cake

We’re almost done, if you've got this far. Remember we ran across an issue with the AppBarLayout where it would hide itself when the MessageCenterView was opened via dragging?

Now that we've learned a lot from writing the AnchoredBottomSheetBehavior it should be easy to add another behavior for that particular issue.

That's exactly what we're going to do.

class RestrictToScrollingTargetBehavior : AppBarLayout.Behavior {



override fun onStartNestedScroll(parent: CoordinatorLayout?, child: AppBarLayout?, directTargetChild: View?, target: View?, nestedScrollAxes: Int): Boolean {

return super.onStartNestedScroll(parent, child, directTargetChild, target, nestedScrollAxes) && hasScrollingBehavior(target)

}



fun hasScrollingBehavior(target: View?) : Boolean {

val layoutParams = target?.layoutParams as? CoordinatorLayout.LayoutParams

return if (layoutParams?.behavior is AppBarLayout.ScrollingViewBehavior) {

true

} else {

target !is CoordinatorLayout && hasScrollingBehavior(target?.parent as? View)

}

}

}

The important part is that this will only return true , i.e. the CoordinatorLayout will delegate touch events to it, if target implements the ScrollingViewBehavior . As a result, the AppBarLayout will completely ignore any event initiated by touching our MessageCenterView . Pretty neat, right?

Wrapping up

I hope that this article gives you enough insight on how a Behavior works and how you can create your custom one to achieve whatever you want.

It's an amazing tool that makes it so much easier to handle complex layouts transformations without linking each view to the next one. Just keep in mind to not go crazy with it and end up trying to be over-generic. Make it specific for your use case and iterate only if necessary.