From BottomSheetBehavior to AnchorSheetBehavior

Marcel Pintó
5 min readMay 19, 2016

Update: this is now a standalone library https://github.com/skimarxall/AnchorSheetBehavior

From BottomSheetBehavior to AnchorSheetBehavior The new Coordinator Layout from the support library gave the developers a big toy to play with. Since then, many new behaviors popped up. Most of them were included in the new design support library. That’s the case of the BottomSheetBehavior, an easy way to make your layout slide from the bottom. They did a good job and with few UI tweaks and couple lines of code you get a panel that slides from the bottom showing some content.

What is this post about then? Well as always we (or designers) want more. What does not BottomSheetBehavior provide?

  • Middle State (Anchor State)
  • Two or more nested scroll views.
  • Probably other stuff.

In this first post, we will take the current BottomSheetBehavior and apply some modifications to archive the first point. We can call it, AnchorSheetBehavior.

Coding

Create a new class called AnchorSheetBehavior and extend… oh, wait! BottomSheetBehavior is a final class…

Well, not nice, but we need to copy & paste the full class. In order to keep this example clean, I will show the modified code.

Add a new state

Let’s call it “Anchor”.

/**
* The bottom sheet is anchor.
*/
public static final int STATE_ANCHOR = 6;


/**
*
@hide
*/
@IntDef({STATE_EXPANDED, STATE_COLLAPSED, STATE_DRAGGING, STATE_SETTLING, STATE_HIDDEN, STATE_ANCHOR})

We define a new state, and we add it into the @IntDef list.

Update child view

Looking at the code we can see that the behavior calculates the offsets and height for the different states during onLayoutChild.

@Override
public boolean onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection) {
// First let the parent lay it out
if (mState != STATE_DRAGGING && mState != STATE_SETTLING) {
if (ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child)) {
ViewCompat.setFitsSystemWindows(child, true);
}
parent.onLayoutChild(child, layoutDirection);
}
// Offset the bottom sheet
mParentHeight = parent.getHeight();
mMinOffset = Math.max(0, mParentHeight - child.getHeight());
mMaxOffset = Math.max(mParentHeight - mPeekHeight, mMinOffset);
mAnchorOffset = (int) Math.max(mParentHeight * mAnchorThreshold, mMinOffset);
if (mState == STATE_EXPANDED) {
ViewCompat.offsetTopAndBottom(child, mMinOffset);
} else if (mState == STATE_ANCHOR) {
ViewCompat.offsetTopAndBottom(child, mAnchorOffset);
}
else if (mHideable && mState == STATE_HIDDEN) {
ViewCompat.offsetTopAndBottom(child, mParentHeight);
} else if (mState == STATE_COLLAPSED) {
ViewCompat.offsetTopAndBottom(child, mMaxOffset);
}
if (mViewDragHelper == null) {
mViewDragHelper = ViewDragHelper.create(parent, mDragCallback);
}
mViewRef = new WeakReference<>(child);
mNestedScrollingChildRef = new WeakReference<>(findScrollingChild(child));
return true;
}

The important part here is the mAnchorOffset. First, we calculate the position of the child in “Anchor” state, or what is the same, the offset from Anchor till the top. Then place the child in the correct place depending on the state.

We use the ViewCompat method to move the view and set the top using the previously calculated offset.

Set state

Modify the setter method and apply the new state.

public final void setState(@State int state) {
if (state == mState) {
return;
}
if (mViewRef == null) {
// The view is not laid out yet; modify mState and let onLayoutChild handle it later
if (state == STATE_COLLAPSED || state == STATE_EXPANDED || state == STATE_ANCHOR ||
(mHideable && state == STATE_HIDDEN)) {
mState = state;
}
return;
}
V child = mViewRef.get();
if (child == null) {
return;
}
int top;
if (state == STATE_COLLAPSED) {
top = mMaxOffset;
View scroll = mNestedScrollingChildRef.get();
if (scroll != null && ViewCompat.canScrollVertically(scroll, -1)) {
scroll.scrollTo(0, 0);
}

} else if (state == STATE_EXPANDED) {
top = mMinOffset;
} else if (state == STATE_ANCHOR) {
top = mAnchorOffset;
}
else if (mHideable && state == STATE_HIDDEN) {
top = mParentHeight;
} else {
throw new IllegalArgumentException("Illegal state argument: " + state);
}
setStateInternal(STATE_SETTLING);
if (mViewDragHelper.smoothSlideViewTo(child, child.getLeft(), top)) {
ViewCompat.postOnAnimation(child, new SettleRunnable(child, state));
}
}

Basically, we set the previously calculated mAnchorOffset as the new top. Then the mViewDragHelper will do the magic.

Control the drag

When the user releases the panel after dragging it, we need to set the proper state.

@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
int top;
@State int targetState;
if (mHideable && shouldHide(releasedChild, yvel)) {
top = mParentHeight;
targetState = STATE_HIDDEN;
} else if (yvel <= 0.f) {
int currentTop = releasedChild.getTop();
if (Math.abs(currentTop - mAnchorOffset) < Math.abs(currentTop - mMinOffset)) {
top = mAnchorOffset;
targetState = STATE_ANCHOR;
}
else if (Math.abs(currentTop - mMinOffset) < Math.abs(currentTop - mMaxOffset)) {
top = mMinOffset;
targetState = STATE_EXPANDED;
} else {
top = mMaxOffset;
targetState = STATE_COLLAPSED;
}
} else {
top = mMaxOffset;
targetState = STATE_COLLAPSED;
}
if (mViewDragHelper.settleCapturedViewAt(releasedChild.getLeft(), top)) {
setStateInternal(STATE_SETTLING);
ViewCompat.postOnAnimation(releasedChild, new SettleRunnable(releasedChild, targetState));
} else {
setStateInternal(targetState);
}
}

With a negative yVel means that the user was moving upwards. In that case, we need to decide where the panel is closest to and settle it there.

Some maths will tell us if we EXPAND, ANCHOR or COLLAPSE it.

Nested scrolling

What happen when the view that uses this behavior has a scrolling view inside?

BottomSheetBehavior does a good job on deciding that, we only need to decide what happens when the nested scroll view is released.

@Override
public void onStopNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target) {
if (child.getTop() == mMinOffset) {
setStateInternal(STATE_EXPANDED);
return;
}
if (target != mNestedScrollingChildRef.get() || !mNestedScrolled) {
return;
}
int top;
int targetState;
if (mHideable && shouldHide(child, getYVelocity())) {
top = mParentHeight;
targetState = STATE_HIDDEN;
} else if (mLastNestedScrollDy >= 0) {
int currentTop = child.getTop();
if (Math.abs(currentTop - mAnchorOffset) < Math.abs(currentTop - mMinOffset)) {
top = mAnchorOffset;
targetState = STATE_ANCHOR;
}
else if (Math.abs(currentTop - mMinOffset) < Math.abs(currentTop - mMaxOffset)) {
top = mMinOffset;
targetState = STATE_EXPANDED;
} else {
top = mMaxOffset;
targetState = STATE_COLLAPSED;
}
} else {
top = mMaxOffset;
targetState = STATE_COLLAPSED;
}
if (mViewDragHelper.smoothSlideViewTo(child, child.getLeft(), top)) {
setStateInternal(STATE_SETTLING);
ViewCompat.postOnAnimation(child, new SettleRunnable(child, targetState));
} else {
setStateInternal(targetState);
}
mNestedScrolled = false;
}

Same logic when the user releases the drag but this time the scrolling direction is the opposite. Again, some math will tell us which is the target state.

Result

That’s it, we wrap the code, create and example demo app and this is the result:

Simple Demo for AnchorSheetBehavior

The result is pretty nice and seems to work good in most of the cases. Obviously needs some polish but it makes the trick.

You can fine a the full example at

Please feel free to express your opinions in the comments.

Find me on: Twitter: @marxallski and Google+

More articles!

--

--