Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 36 You must be signed in to star a gist
  • Fork 10 You must be signed in to fork a gist
  • Save jayrambhia/3860832353ccd22c8ce5478ef7ef63e4 to your computer and use it in GitHub Desktop.
Save jayrambhia/3860832353ccd22c8ce5478ef7ef63e4 to your computer and use it in GitHub Desktop.
Android Top Sheet Implementation

TopSheetDialog

Widget equivalent to Android's BottomSheet with top-to-bottom transition. This implementation reuses the Bottom Sheet implementation. With the same Behavior+CoordinatorLayout strategy, the implementation makes necessary changes to BottomSheetBehavior and creates a new behavior called TopSheetBehavior.

Similar to the behavior, I have also replicated BottomSheetDialog class and created TopSheetDialog class.

Usage

TopSheetDialog(context, <Your top-sheet theme>).apply {
  // Required to have the top-down animation when the app starts showing / dismissin it.
  window?.attributes?.windowAnimations = R.style.TopSheet_DialogAnimation 
  setContentView(<Your view>, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
}.show()

TopSheetFragment?

I have not tried it with a fragment. Feel free to take the code and make necessary changes to make it work.

<style name="TopSheet_DialogAnimation">
  <item name="android:windowEnterAnimation">@anim/slide_out_from_top</item>
  <item name="android:windowExitAnimation">@anim/slide_back_to_top</item>
</style>

slide_out_from_top

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:duration="@android:integer/config_shortAnimTime"
        android:fromYDelta="-100%"
        android:interpolator="@android:anim/accelerate_interpolator"
        android:toYDelta="0%">
    </translate>
</set>

slide_back_to_top

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <translate
        android:duration="@android:integer/config_shortAnimTime"
        android:fromYDelta="0%"
        android:interpolator="@android:anim/accelerate_interpolator"
        android:toYDelta="-100%">
    </translate>
</set>
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2015 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinator"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<View
android:id="@+id/touch_outside"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:focusable="false"
android:importantForAccessibility="no"
android:soundEffectsEnabled="false"
tools:ignore="UnusedAttribute"/>
<FrameLayout
android:id="@+id/design_bottom_sheet"
style="?attr/bottomSheetStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal|top"
app:layout_behavior="com.fenchtose.reflog.widgets.topsheet.TopSheetBehavior"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</FrameLayout>
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.taskito.topsheet;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import static java.lang.Math.max;
import static java.lang.Math.min;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.TypedArray;
import android.os.Build;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.util.Log;
import android.util.TypedValue;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.ViewParent;
import androidx.annotation.FloatRange;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams;
import androidx.core.math.MathUtils;
import androidx.core.view.ViewCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
import androidx.core.view.accessibility.AccessibilityViewCommand;
import androidx.customview.view.AbsSavedState;
import androidx.customview.widget.ViewDragHelper;
import com.google.android.material.R;
import com.google.android.material.shape.MaterialShapeDrawable;
import com.google.android.material.shape.ShapeAppearanceModel;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
/**
* An interaction behavior plugin for a child view of {@link CoordinatorLayout} to make it work as a
* bottom sheet.
*/
public class TopSheetBehavior<V extends View> extends CoordinatorLayout.Behavior<V> {
/** Callback for monitoring events about bottom sheets. */
public abstract static class SheetCallback {
/**
* Called when the bottom sheet changes its state.
*
* @param bottomSheet The bottom sheet view.
* @param newState The new state. This will be one of {@link #STATE_DRAGGING}, {@link
* #STATE_SETTLING}, {@link #STATE_EXPANDED}, {@link #STATE_COLLAPSED}, {@link
* #STATE_HIDDEN}, or {@link #STATE_HALF_EXPANDED}.
*/
public abstract void onStateChanged(@NonNull View bottomSheet, @State int newState);
/**
* Called when the bottom sheet is being dragged.
*
* @param bottomSheet The bottom sheet view.
* @param slideOffset The new offset of this bottom sheet within [-1,1] range. Offset increases
* as this bottom sheet is moving upward. From 0 to 1 the sheet is between collapsed and
* expanded states and from -1 to 0 it is between hidden and collapsed states.
*/
public abstract void onSlide(@NonNull View bottomSheet, float slideOffset);
}
/** The bottom sheet is dragging. */
public static final int STATE_DRAGGING = 1;
/** The bottom sheet is settling. */
public static final int STATE_SETTLING = 2;
/** The bottom sheet is expanded. */
public static final int STATE_EXPANDED = 3;
/** The bottom sheet is collapsed. */
public static final int STATE_COLLAPSED = 4;
/** The bottom sheet is hidden. */
public static final int STATE_HIDDEN = 5;
/** The bottom sheet is half-expanded (used when mFitToContents is false). */
public static final int STATE_HALF_EXPANDED = 6;
/** @hide */
@RestrictTo(LIBRARY_GROUP)
@IntDef({
STATE_EXPANDED,
STATE_COLLAPSED,
STATE_DRAGGING,
STATE_SETTLING,
STATE_HIDDEN,
STATE_HALF_EXPANDED
})
@Retention(RetentionPolicy.SOURCE)
public @interface State {}
/**
* Peek at the 16:9 ratio keyline of its parent.
*
* <p>This can be used as a parameter for {@link #setPeekHeight(int)}. {@link #getPeekHeight()}
* will return this when the value is set.
*/
public static final int PEEK_HEIGHT_AUTO = -1;
/** This flag will preserve the peekHeight int value on configuration change. */
public static final int SAVE_PEEK_HEIGHT = 0x1;
/** This flag will preserve the fitToContents boolean value on configuration change. */
public static final int SAVE_FIT_TO_CONTENTS = 1 << 1;
/** This flag will preserve the hideable boolean value on configuration change. */
public static final int SAVE_HIDEABLE = 1 << 2;
/** This flag will preserve the skipCollapsed boolean value on configuration change. */
public static final int SAVE_SKIP_COLLAPSED = 1 << 3;
/** This flag will preserve all aforementioned values on configuration change. */
public static final int SAVE_ALL = -1;
/**
* This flag will not preserve the aforementioned values set at runtime if the view is destroyed
* and recreated. The only value preserved will be the positional state, e.g. collapsed, hidden,
* expanded, etc. This is the default behavior.
*/
public static final int SAVE_NONE = 0;
/** @hide */
@RestrictTo(LIBRARY_GROUP)
@IntDef(
flag = true,
value = {
SAVE_PEEK_HEIGHT,
SAVE_FIT_TO_CONTENTS,
SAVE_HIDEABLE,
SAVE_SKIP_COLLAPSED,
SAVE_ALL,
SAVE_NONE,
})
@Retention(RetentionPolicy.SOURCE)
public @interface SaveFlags {}
private static final String TAG = "TopSheetBehavior2";
@SaveFlags private int saveFlags = SAVE_NONE;
private static final int SIGNIFICANT_VEL_THRESHOLD = 500;
private static final float HIDE_THRESHOLD = 0.5f;
private static final float HIDE_FRICTION = 0.1f;
private static final int CORNER_ANIMATION_DURATION = 500;
private boolean fitToContents = true;
private boolean updateImportantForAccessibilityOnSiblings = false;
private float maximumVelocity;
/** Peek height set by the user. */
private int peekHeight;
/** Whether or not to use automatic peek height. */
private boolean peekHeightAuto;
/** Minimum peek height permitted. */
private int peekHeightMin;
/** Peek height gesture inset buffer to ensure enough swipeable space. */
private int peekHeightGestureInsetBuffer;
/** True if Behavior has a non-null value for the @shapeAppearance attribute */
private boolean shapeThemingEnabled;
private MaterialShapeDrawable materialShapeDrawable;
private int gestureInsetBottom;
private boolean gestureInsetBottomIgnored;
/** Default Shape Appearance to be used in bottomsheet */
private ShapeAppearanceModel shapeAppearanceModelDefault;
private boolean isShapeExpanded;
private SettleRunnable settleRunnable = null;
@Nullable private ValueAnimator interpolatorAnimator;
private static final int DEF_STYLE_RES = R.style.Widget_Design_BottomSheet_Modal;
int expandedOffset;
int fitToContentsOffset;
int halfExpandedOffset;
float halfExpandedRatio = 0.5f;
int collapsedOffset;
float elevation = -1;
boolean hideable;
private boolean skipCollapsed;
private boolean draggable = true;
@State int state = STATE_COLLAPSED;
@Nullable ViewDragHelper viewDragHelper;
private boolean ignoreEvents;
private int lastNestedScrollDy;
private boolean nestedScrolled;
private int childHeight;
int parentWidth;
int parentHeight;
@Nullable WeakReference<V> viewRef;
@Nullable WeakReference<View> nestedScrollingChildRef;
@NonNull private final ArrayList<SheetCallback> callbacks = new ArrayList<>();
@Nullable private VelocityTracker velocityTracker;
int activePointerId;
private int initialY;
boolean touchingScrollingChild;
@Nullable private Map<View, Integer> importantForAccessibilityMap;
public TopSheetBehavior() {}
public TopSheetBehavior(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
peekHeightGestureInsetBuffer =
context.getResources().getDimensionPixelSize(R.dimen.mtrl_min_touch_target_size);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.BottomSheetBehavior_Layout);
this.shapeThemingEnabled = a.hasValue(R.styleable.BottomSheetBehavior_Layout_shapeAppearance);
boolean hasBackgroundTint = a.hasValue(R.styleable.BottomSheetBehavior_Layout_backgroundTint);
if (hasBackgroundTint) {
ColorStateList bottomSheetColor =
TopSheetUtils.INSTANCE.getColorStateList(
context, a, R.styleable.BottomSheetBehavior_Layout_backgroundTint);
createMaterialShapeDrawable(context, attrs, hasBackgroundTint, bottomSheetColor);
} else {
createMaterialShapeDrawable(context, attrs, hasBackgroundTint);
}
createShapeValueAnimator();
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
this.elevation = a.getDimension(R.styleable.BottomSheetBehavior_Layout_android_elevation, -1);
}
TypedValue value = a.peekValue(R.styleable.BottomSheetBehavior_Layout_behavior_peekHeight);
if (value != null && value.data == PEEK_HEIGHT_AUTO) {
setPeekHeight(value.data);
} else {
setPeekHeight(
a.getDimensionPixelSize(
R.styleable.BottomSheetBehavior_Layout_behavior_peekHeight, PEEK_HEIGHT_AUTO));
}
setHideable(a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_hideable, false));
setGestureInsetBottomIgnored(
a.getBoolean(R.styleable.BottomSheetBehavior_Layout_gestureInsetBottomIgnored, false));
setFitToContents(
a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_fitToContents, true));
setSkipCollapsed(
a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_skipCollapsed, false));
setDraggable(a.getBoolean(R.styleable.BottomSheetBehavior_Layout_behavior_draggable, true));
setSaveFlags(a.getInt(R.styleable.BottomSheetBehavior_Layout_behavior_saveFlags, SAVE_NONE));
setHalfExpandedRatio(
a.getFloat(R.styleable.BottomSheetBehavior_Layout_behavior_halfExpandedRatio, 0.5f));
value = a.peekValue(R.styleable.BottomSheetBehavior_Layout_behavior_expandedOffset);
if (value != null && value.type == TypedValue.TYPE_FIRST_INT) {
setExpandedOffset(value.data);
} else {
setExpandedOffset(
a.getDimensionPixelOffset(
R.styleable.BottomSheetBehavior_Layout_behavior_expandedOffset, 0));
}
a.recycle();
ViewConfiguration configuration = ViewConfiguration.get(context);
maximumVelocity = configuration.getScaledMaximumFlingVelocity();
}
@NonNull
@Override
public Parcelable onSaveInstanceState(@NonNull CoordinatorLayout parent, @NonNull V child) {
return new SavedState(super.onSaveInstanceState(parent, child), this);
}
@Override
public void onRestoreInstanceState(
@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull Parcelable state) {
SavedState ss = (SavedState) state;
super.onRestoreInstanceState(parent, child, ss.getSuperState());
// Restore Optional State values designated by saveFlags
restoreOptionalState(ss);
// Intermediate states are restored as collapsed state
if (ss.state == STATE_DRAGGING || ss.state == STATE_SETTLING) {
this.state = STATE_COLLAPSED;
} else {
this.state = ss.state;
}
}
@Override
public void onAttachedToLayoutParams(@NonNull LayoutParams layoutParams) {
super.onAttachedToLayoutParams(layoutParams);
// These may already be null, but just be safe, explicitly assign them. This lets us know the
// first time we layout with this behavior by checking (viewRef == null).
viewRef = null;
viewDragHelper = null;
}
@Override
public void onDetachedFromLayoutParams() {
super.onDetachedFromLayoutParams();
// Release references so we don't run unnecessary codepaths while not attached to a view.
viewRef = null;
viewDragHelper = null;
}
@Override
public boolean onLayoutChild(
@NonNull CoordinatorLayout parent, @NonNull V child, int layoutDirection) {
if (ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child)) {
child.setFitsSystemWindows(true);
}
if (viewRef == null) {
// First layout with this behavior.
peekHeightMin =
parent.getResources().getDimensionPixelSize(R.dimen.design_bottom_sheet_peek_height_min);
setSystemGestureInsets(child);
viewRef = new WeakReference<>(child);
// Only set MaterialShapeDrawable as background if shapeTheming is enabled, otherwise will
// default to android:background declared in styles or layout.
if (shapeThemingEnabled && materialShapeDrawable != null) {
ViewCompat.setBackground(child, materialShapeDrawable);
}
// Set elevation on MaterialShapeDrawable
if (materialShapeDrawable != null) {
// Use elevation attr if set on bottomsheet; otherwise, use elevation of child view.
materialShapeDrawable.setElevation(
elevation == -1 ? ViewCompat.getElevation(child) : elevation);
// Update the material shape based on initial state.
isShapeExpanded = state == STATE_EXPANDED;
materialShapeDrawable.setInterpolation(isShapeExpanded ? 0f : 1f);
}
updateAccessibilityActions();
if (ViewCompat.getImportantForAccessibility(child)
== ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
ViewCompat.setImportantForAccessibility(child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
}
}
if (viewDragHelper == null) {
viewDragHelper = ViewDragHelper.create(parent, dragCallback);
}
// First let the parent lay it out
parent.onLayoutChild(child, layoutDirection);
// Offset the bottom sheet
parentWidth = parent.getWidth();
parentHeight = parent.getHeight();
childHeight = child.getHeight();
fitToContentsOffset = max(0, childHeight);
calculateHalfExpandedOffset();
calculateCollapsedOffset();
if (state == STATE_EXPANDED) {
ViewCompat.offsetTopAndBottom(child, 0);
} else if (state == STATE_HALF_EXPANDED) {
ViewCompat.offsetTopAndBottom(child, min(halfExpandedOffset - childHeight, 0));
} else if (hideable && state == STATE_HIDDEN) {
ViewCompat.offsetTopAndBottom(child, -childHeight);
} else if (state == STATE_COLLAPSED) {
ViewCompat.offsetTopAndBottom(child, min(collapsedOffset - childHeight, 0));
} else if (state == STATE_DRAGGING || state == STATE_SETTLING) {
int savedTop = child.getTop();
ViewCompat.offsetTopAndBottom(child, savedTop - child.getTop());
}
nestedScrollingChildRef = new WeakReference<>(findScrollingChild(child));
return true;
}
@Override
public boolean onInterceptTouchEvent(
@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) {
if (!child.isShown() || !draggable) {
ignoreEvents = true;
return false;
}
int action = event.getActionMasked();
// Record the velocity
if (action == MotionEvent.ACTION_DOWN) {
reset();
}
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain();
}
velocityTracker.addMovement(event);
switch (action) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
touchingScrollingChild = false;
activePointerId = MotionEvent.INVALID_POINTER_ID;
// Reset the ignore flag
if (ignoreEvents) {
ignoreEvents = false;
return false;
}
break;
case MotionEvent.ACTION_DOWN:
int initialX = (int) event.getX();
initialY = (int) event.getY();
// Only intercept nested scrolling events here if the view not being moved by the
// ViewDragHelper.
if (state != STATE_SETTLING) {
View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null;
if (scroll != null && parent.isPointInChildBounds(scroll, initialX, initialY)) {
activePointerId = event.getPointerId(event.getActionIndex());
touchingScrollingChild = true;
}
}
ignoreEvents =
activePointerId == MotionEvent.INVALID_POINTER_ID
&& !parent.isPointInChildBounds(child, initialX, initialY);
break;
default: // fall out
}
if (!ignoreEvents
&& viewDragHelper != null
&& viewDragHelper.shouldInterceptTouchEvent(event)) {
return true;
}
// We have to handle cases that the ViewDragHelper does not capture the bottom sheet because
// it is not the top most view of its parent. This is not necessary when the touch event is
// happening over the scrolling content as nested scrolling logic handles that case.
View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null;
return action == MotionEvent.ACTION_MOVE
&& scroll != null
&& !ignoreEvents
&& state != STATE_DRAGGING
&& !parent.isPointInChildBounds(scroll, (int) event.getX(), (int) event.getY())
&& viewDragHelper != null
&& Math.abs(initialY - event.getY()) > viewDragHelper.getTouchSlop();
}
@Override
public boolean onTouchEvent(
@NonNull CoordinatorLayout parent, @NonNull V child, @NonNull MotionEvent event) {
if (!child.isShown()) {
return false;
}
int action = event.getActionMasked();
if (state == STATE_DRAGGING && action == MotionEvent.ACTION_DOWN) {
return true;
}
if (viewDragHelper != null) {
viewDragHelper.processTouchEvent(event);
}
// Record the velocity
if (action == MotionEvent.ACTION_DOWN) {
reset();
}
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain();
}
velocityTracker.addMovement(event);
// The ViewDragHelper tries to capture only the top-most View. We have to explicitly tell it
// to capture the bottom sheet in case it is not captured and the touch slop is passed.
if (viewDragHelper != null && action == MotionEvent.ACTION_MOVE && !ignoreEvents) {
if (Math.abs(initialY - event.getY()) > viewDragHelper.getTouchSlop()) {
viewDragHelper.captureChildView(child, event.getPointerId(event.getActionIndex()));
}
}
return !ignoreEvents;
}
@Override
public boolean onStartNestedScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View directTargetChild,
@NonNull View target,
int axes,
int type) {
lastNestedScrollDy = 0;
nestedScrolled = false;
return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
@Override
public void onNestedPreScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View target,
int dx,
int dy,
@NonNull int[] consumed,
int type) {
if (type == ViewCompat.TYPE_NON_TOUCH) {
// Ignore fling here. The ViewDragHelper handles it.
return;
}
View scrollingChild = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null;
if (target != scrollingChild) {
return;
}
int currentBottom = child.getBottom();
int newBottom = currentBottom - dy;
if (dy > 0) { // Upward - Collapsing the top sheet!
if (!target.canScrollVertically(1)) {
if (newBottom >= collapsedOffset || hideable) {
if (!draggable) {
// Prevent dragging
return;
}
consumed[1] = dy;
ViewCompat.offsetTopAndBottom(child, -dy);
setStateInternal(STATE_DRAGGING);
} else {
consumed[1] = currentBottom - collapsedOffset;
ViewCompat.offsetTopAndBottom(child, -consumed[1]);
setStateInternal(STATE_COLLAPSED);
}
}
} else if (dy < 0) { // Downward
if (newBottom > getExpandedOffset()) {
consumed[1] = currentBottom - getExpandedOffset();
ViewCompat.offsetTopAndBottom(child, -consumed[1]);
setStateInternal(STATE_EXPANDED);
} else {
if (!draggable) {
// Prevent dragging
return;
}
consumed[1] = dy;
ViewCompat.offsetTopAndBottom(child, -dy);
setStateInternal(STATE_DRAGGING);
}
}
dispatchOnSlide(child.getTop());
lastNestedScrollDy = dy;
nestedScrolled = true;
}
@Override
public void onStopNestedScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View target,
int type) {
if (child.getTop() == 0) {
setStateInternal(STATE_EXPANDED);
return;
}
if (nestedScrollingChildRef == null
|| target != nestedScrollingChildRef.get()
|| !nestedScrolled) {
return;
}
int bottom = childHeight;
int targetState;
if (lastNestedScrollDy < 0) { // Down - towards expanding.
if (fitToContents) {
bottom = fitToContentsOffset;
targetState = STATE_EXPANDED;
} else {
int currentBottom = child.getBottom();
if (currentBottom < halfExpandedOffset) {
bottom = halfExpandedOffset;
targetState = STATE_HALF_EXPANDED;
} else {
bottom = expandedOffset;
targetState = STATE_EXPANDED;
}
}
} else if (hideable && shouldHide(child, getYVelocity())) {
bottom = 0;
targetState = STATE_HIDDEN;
} else if (lastNestedScrollDy == 0) {
int currentBottom = child.getBottom();
if (fitToContents) {
if (Math.abs(currentBottom - fitToContentsOffset) > Math.abs(currentBottom - collapsedOffset)) {
bottom = fitToContentsOffset;
targetState = STATE_EXPANDED;
} else {
bottom = collapsedOffset;
targetState = STATE_COLLAPSED;
}
} else {
if (currentBottom > halfExpandedOffset) {
if (currentBottom > Math.abs(currentBottom - collapsedOffset)) {
bottom = expandedOffset;
targetState = STATE_EXPANDED;
} else {
bottom = halfExpandedOffset;
targetState = STATE_HALF_EXPANDED;
}
} else {
if (Math.abs(currentBottom - halfExpandedOffset) > Math.abs(currentBottom - collapsedOffset)) {
bottom = halfExpandedOffset;
targetState = STATE_HALF_EXPANDED;
} else {
bottom = collapsedOffset;
targetState = STATE_COLLAPSED;
}
}
}
} else { // Up - towards collapsing
if (fitToContents) {
bottom = collapsedOffset;
targetState = STATE_COLLAPSED;
} else {
// Settle to nearest height.
int currentBottom = child.getBottom();
if (Math.abs(currentBottom - halfExpandedOffset) > Math.abs(currentBottom - collapsedOffset)) {
bottom = halfExpandedOffset;
targetState = STATE_HALF_EXPANDED;
} else {
bottom = collapsedOffset;
targetState = STATE_COLLAPSED;
}
}
}
startSettlingAnimation(child, targetState, bottom - childHeight, false);
nestedScrolled = false;
}
@Override
public void onNestedScroll(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View target,
int dxConsumed,
int dyConsumed,
int dxUnconsumed,
int dyUnconsumed,
int type,
@NonNull int[] consumed) {
// Overridden to prevent the default consumption of the entire scroll distance.
}
@Override
public boolean onNestedPreFling(
@NonNull CoordinatorLayout coordinatorLayout,
@NonNull V child,
@NonNull View target,
float velocityX,
float velocityY) {
if (nestedScrollingChildRef != null) {
return target == nestedScrollingChildRef.get()
&& (state != STATE_EXPANDED
|| super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY));
} else {
return false;
}
}
/**
* @return whether the height of the expanded sheet is determined by the height of its contents,
* or if it is expanded in two stages (half the height of the parent container, full height of
* parent container).
*/
public boolean isFitToContents() {
return fitToContents;
}
/**
* Sets whether the height of the expanded sheet is determined by the height of its contents, or
* if it is expanded in two stages (half the height of the parent container, full height of parent
* container). Default value is true.
*
* @param fitToContents whether or not to fit the expanded sheet to its contents.
*/
public void setFitToContents(boolean fitToContents) {
if (this.fitToContents == fitToContents) {
return;
}
this.fitToContents = fitToContents;
// If sheet is already laid out, recalculate the collapsed offset based on new setting.
// Otherwise, let onLayoutChild handle this later.
if (viewRef != null) {
calculateCollapsedOffset();
}
// Fix incorrect expanded settings depending on whether or not we are fitting sheet to contents.
setStateInternal((this.fitToContents && state == STATE_HALF_EXPANDED) ? STATE_EXPANDED : state);
updateAccessibilityActions();
}
/**
* Sets the height of the bottom sheet when it is collapsed.
*
* @param peekHeight The height of the collapsed bottom sheet in pixels, or {@link
* #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline.
* @attr ref
* com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight
*/
public void setPeekHeight(int peekHeight) {
setPeekHeight(peekHeight, false);
}
/**
* Sets the height of the bottom sheet when it is collapsed while optionally animating between the
* old height and the new height.
*
* @param peekHeight The height of the collapsed bottom sheet in pixels, or {@link
* #PEEK_HEIGHT_AUTO} to configure the sheet to peek automatically at 16:9 ratio keyline.
* @param animate Whether to animate between the old height and the new height.
* @attr ref
* com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight
*/
public final void setPeekHeight(int peekHeight, boolean animate) {
boolean layout = false;
if (peekHeight == PEEK_HEIGHT_AUTO) {
if (!peekHeightAuto) {
peekHeightAuto = true;
layout = true;
}
} else if (peekHeightAuto || this.peekHeight != peekHeight) {
peekHeightAuto = false;
this.peekHeight = max(0, peekHeight);
layout = true;
}
// If sheet is already laid out, recalculate the collapsed offset based on new setting.
// Otherwise, let onLayoutChild handle this later.
if (layout) {
updatePeekHeight(animate);
}
}
private void updatePeekHeight(boolean animate) {
if (viewRef != null) {
calculateCollapsedOffset();
if (state == STATE_COLLAPSED) {
V view = viewRef.get();
if (view != null) {
if (animate) {
settleToStatePendingLayout(state);
} else {
view.requestLayout();
}
}
}
}
}
/**
* Gets the height of the bottom sheet when it is collapsed.
*
* @return The height of the collapsed bottom sheet in pixels, or {@link #PEEK_HEIGHT_AUTO} if the
* sheet is configured to peek automatically at 16:9 ratio keyline
* @attr ref
* com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_peekHeight
*/
public int getPeekHeight() {
return peekHeightAuto ? PEEK_HEIGHT_AUTO : peekHeight;
}
/**
* Determines the height of the BottomSheet in the {@link #STATE_HALF_EXPANDED} state. The
* material guidelines recommended a value of 0.5, which results in the sheet filling half of the
* parent. The height of the BottomSheet will be smaller as this ratio is decreased and taller as
* it is increased. The default value is 0.5.
*
* @param ratio a float between 0 and 1, representing the {@link #STATE_HALF_EXPANDED} ratio.
* @attr ref
* com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio
*/
public void setHalfExpandedRatio(@FloatRange(from = 0.0f, to = 1.0f) float ratio) {
if ((ratio <= 0) || (ratio >= 1)) {
throw new IllegalArgumentException("ratio must be a float value between 0 and 1");
}
this.halfExpandedRatio = ratio;
// If sheet is already laid out, recalculate the half expanded offset based on new setting.
// Otherwise, let onLayoutChild handle this later.
if (viewRef != null) {
calculateHalfExpandedOffset();
}
}
/**
* Gets the ratio for the height of the BottomSheet in the {@link #STATE_HALF_EXPANDED} state.
*
* @attr ref
* com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_halfExpandedRatio
*/
@FloatRange(from = 0.0f, to = 1.0f)
public float getHalfExpandedRatio() {
return halfExpandedRatio;
}
/**
* Determines the top offset of the BottomSheet in the {@link #STATE_EXPANDED} state when
* fitsToContent is false. The default value is 0, which results in the sheet matching the
* parent's top.
*
* @param offset an integer value greater than equal to 0, representing the {@link
* #STATE_EXPANDED} offset. Value must not exceed the offset in the half expanded state.
* @attr ref
* com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset
*/
public void setExpandedOffset(int offset) {
if (offset < 0) {
throw new IllegalArgumentException("offset must be greater than or equal to 0");
}
this.expandedOffset = offset;
}
/**
* Returns the current expanded offset. If {@code fitToContents} is true, it will automatically
* pick the offset depending on the height of the content.
*
* @attr ref
* com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_expandedOffset
*/
public int getExpandedOffset() {
return fitToContents ? fitToContentsOffset : expandedOffset;
}
/**
* Sets whether this bottom sheet can hide when it is swiped down.
*
* @param hideable {@code true} to make this bottom sheet hideable.
* @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable
*/
public void setHideable(boolean hideable) {
if (this.hideable != hideable) {
this.hideable = hideable;
if (!hideable && state == STATE_HIDDEN) {
// Lift up to collapsed state
setState(STATE_COLLAPSED);
}
updateAccessibilityActions();
}
}
/**
* Gets whether this bottom sheet can hide when it is swiped down.
*
* @return {@code true} if this bottom sheet can hide.
* @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_hideable
*/
public boolean isHideable() {
return hideable;
}
/**
* Sets whether this bottom sheet should skip the collapsed state when it is being hidden after it
* is expanded once. Setting this to true has no effect unless the sheet is hideable.
*
* @param skipCollapsed True if the bottom sheet should skip the collapsed state.
* @attr ref
* com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed
*/
public void setSkipCollapsed(boolean skipCollapsed) {
this.skipCollapsed = skipCollapsed;
}
/**
* Sets whether this bottom sheet should skip the collapsed state when it is being hidden after it
* is expanded once.
*
* @return Whether the bottom sheet should skip the collapsed state.
* @attr ref
* com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_skipCollapsed
*/
public boolean getSkipCollapsed() {
return skipCollapsed;
}
/**
* Sets whether this bottom sheet is can be collapsed/expanded by dragging. Note: When disabling
* dragging, an app will require to implement a custom way to expand/collapse the bottom sheet
*
* @param draggable {@code false} to prevent dragging the sheet to collapse and expand
* @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_draggable
*/
public void setDraggable(boolean draggable) {
this.draggable = draggable;
}
public boolean isDraggable() {
return draggable;
}
/**
* Sets save flags to be preserved in bottomsheet on configuration change.
*
* @param flags bitwise int of {@link #SAVE_PEEK_HEIGHT}, {@link #SAVE_FIT_TO_CONTENTS}, {@link
* #SAVE_HIDEABLE}, {@link #SAVE_SKIP_COLLAPSED}, {@link #SAVE_ALL} and {@link #SAVE_NONE}.
* @see #getSaveFlags()
* @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags
*/
public void setSaveFlags(@SaveFlags int flags) {
this.saveFlags = flags;
}
/**
* Returns the save flags.
*
* @see #setSaveFlags(int)
* @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_behavior_saveFlags
*/
@SaveFlags
public int getSaveFlags() {
return this.saveFlags;
}
/**
* Sets a callback to be notified of bottom sheet events.
*
* @param callback The callback to notify when bottom sheet events occur.
* @deprecated use {@link #addTopSheetCallback(SheetCallback)} and {@link
* #removeTopSheetCallback(SheetCallback)} instead
*/
@Deprecated
public void setTopSheetCallback(SheetCallback callback) {
Log.w(
TAG,
"TopSheetBehavior now supports multiple callbacks. `setTopSheetCallback()` removes"
+ " all existing callbacks, including ones set internally by library authors, which"
+ " may result in unintended behavior. This may change in the future. Please use"
+ " `addTopSheetCallback()` and `removeTopSheetCallback()` instead to set your"
+ " own callbacks.");
callbacks.clear();
if (callback != null) {
callbacks.add(callback);
}
}
/**
* Adds a callback to be notified of bottom sheet events.
*
* @param callback The callback to notify when bottom sheet events occur.
*/
public void addTopSheetCallback(@NonNull SheetCallback callback) {
if (!callbacks.contains(callback)) {
callbacks.add(callback);
}
}
/**
* Removes a previously added callback.
*
* @param callback The callback to remove.
*/
public void removeTopSheetCallback(@NonNull SheetCallback callback) {
callbacks.remove(callback);
}
/**
* Sets the state of the bottom sheet. The bottom sheet will transition to that state with
* animation.
*
* @param state One of {@link #STATE_COLLAPSED}, {@link #STATE_EXPANDED}, {@link #STATE_HIDDEN},
* or {@link #STATE_HALF_EXPANDED}.
*/
public void setState(@State int state) {
if (state == this.state) {
return;
}
if (viewRef == 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_HALF_EXPANDED
|| (hideable && state == STATE_HIDDEN)) {
this.state = state;
}
return;
}
settleToStatePendingLayout(state);
}
/**
* Sets whether this bottom sheet should adjust it's position based on the system gesture area on
* Android Q and above.
*
* <p>Note: the bottom sheet will only adjust it's position if it would be unable to be scrolled
* upwards because the peekHeight is less than the gesture inset margins,(because that would cause
* a gesture conflict), gesture navigation is enabled, and this {@code ignoreGestureInsetBottom}
* flag is false.
*/
public void setGestureInsetBottomIgnored(boolean gestureInsetBottomIgnored) {
this.gestureInsetBottomIgnored = gestureInsetBottomIgnored;
}
/**
* Returns whether this bottom sheet should adjust it's position based on the system gesture area.
*/
public boolean isGestureInsetBottomIgnored() {
return gestureInsetBottomIgnored;
}
private void settleToStatePendingLayout(@State int state) {
final V child = viewRef.get();
if (child == null) {
return;
}
// Start the animation; wait until a pending layout if there is one.
ViewParent parent = child.getParent();
if (parent != null && parent.isLayoutRequested() && ViewCompat.isAttachedToWindow(child)) {
final int finalState = state;
child.post(
new Runnable() {
@Override
public void run() {
settleToState(child, finalState);
}
});
} else {
settleToState(child, state);
}
}
/**
* Gets the current state of the bottom sheet.
*
* @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED},
* {@link #STATE_DRAGGING}, {@link #STATE_SETTLING}, or {@link #STATE_HALF_EXPANDED}.
*/
@State
public int getState() {
return state;
}
void setStateInternal(@State int state) {
if (this.state == state) {
return;
}
this.state = state;
if (viewRef == null) {
return;
}
View bottomSheet = viewRef.get();
if (bottomSheet == null) {
return;
}
if (state == STATE_EXPANDED) {
updateImportantForAccessibility(true);
} else if (state == STATE_HALF_EXPANDED || state == STATE_HIDDEN || state == STATE_COLLAPSED) {
updateImportantForAccessibility(false);
}
updateDrawableForTargetState(state);
for (int i = 0; i < callbacks.size(); i++) {
callbacks.get(i).onStateChanged(bottomSheet, state);
}
updateAccessibilityActions();
}
private void updateDrawableForTargetState(@State int state) {
if (state == STATE_SETTLING) {
// Special case: we want to know which state we're settling to, so wait for another call.
return;
}
boolean expand = state == STATE_EXPANDED;
if (isShapeExpanded != expand) {
isShapeExpanded = expand;
if (materialShapeDrawable != null && interpolatorAnimator != null) {
if (interpolatorAnimator.isRunning()) {
interpolatorAnimator.reverse();
} else {
float to = expand ? 0f : 1f;
float from = 1f - to;
interpolatorAnimator.setFloatValues(from, to);
interpolatorAnimator.start();
}
}
}
}
private int calculatePeekHeight() {
if (peekHeightAuto) {
int desiredHeight = max(peekHeightMin, parentHeight - parentWidth * 9 / 16);
return min(desiredHeight, childHeight);
}
if (!gestureInsetBottomIgnored && gestureInsetBottom > 0) {
return max(peekHeight, gestureInsetBottom + peekHeightGestureInsetBuffer);
}
return peekHeight;
}
private void calculateCollapsedOffset() {
int peek = calculatePeekHeight();
if (fitToContents) {
collapsedOffset = min(peek, fitToContentsOffset);
} else {
collapsedOffset = peek;
}
collapsedOffset = min(collapsedOffset, childHeight);
}
private void calculateHalfExpandedOffset() {
this.halfExpandedOffset = (int) (childHeight * halfExpandedRatio);
}
private void reset() {
activePointerId = ViewDragHelper.INVALID_POINTER;
if (velocityTracker != null) {
velocityTracker.recycle();
velocityTracker = null;
}
}
private void restoreOptionalState(@NonNull SavedState ss) {
if (this.saveFlags == SAVE_NONE) {
return;
}
if (this.saveFlags == SAVE_ALL || (this.saveFlags & SAVE_PEEK_HEIGHT) == SAVE_PEEK_HEIGHT) {
this.peekHeight = ss.peekHeight;
}
if (this.saveFlags == SAVE_ALL
|| (this.saveFlags & SAVE_FIT_TO_CONTENTS) == SAVE_FIT_TO_CONTENTS) {
this.fitToContents = ss.fitToContents;
}
if (this.saveFlags == SAVE_ALL || (this.saveFlags & SAVE_HIDEABLE) == SAVE_HIDEABLE) {
this.hideable = ss.hideable;
}
if (this.saveFlags == SAVE_ALL
|| (this.saveFlags & SAVE_SKIP_COLLAPSED) == SAVE_SKIP_COLLAPSED) {
this.skipCollapsed = ss.skipCollapsed;
}
}
boolean shouldHide(@NonNull View child, float yvel) {
if (skipCollapsed) {
return true;
}
if (child.getBottom() > collapsedOffset) {
// It should not hide, but collapse.
return false;
}
int peek = calculatePeekHeight();
final float newBottom = child.getTop() + yvel * HIDE_FRICTION;
return Math.abs(newBottom - collapsedOffset) / (float) peek > HIDE_THRESHOLD;
}
@Nullable
@VisibleForTesting
View findScrollingChild(View view) {
if (ViewCompat.isNestedScrollingEnabled(view)) {
return view;
}
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0, count = group.getChildCount(); i < count; i++) {
View scrollingChild = findScrollingChild(group.getChildAt(i));
if (scrollingChild != null) {
return scrollingChild;
}
}
}
return null;
}
private void createMaterialShapeDrawable(
@NonNull Context context, AttributeSet attrs, boolean hasBackgroundTint) {
this.createMaterialShapeDrawable(context, attrs, hasBackgroundTint, null);
}
private void createMaterialShapeDrawable(
@NonNull Context context,
AttributeSet attrs,
boolean hasBackgroundTint,
@Nullable ColorStateList bottomSheetColor) {
if (this.shapeThemingEnabled) {
this.shapeAppearanceModelDefault =
ShapeAppearanceModel.builder(context, attrs, R.attr.bottomSheetStyle, DEF_STYLE_RES)
.build();
this.materialShapeDrawable = new MaterialShapeDrawable(shapeAppearanceModelDefault);
this.materialShapeDrawable.initializeElevationOverlay(context);
if (hasBackgroundTint && bottomSheetColor != null) {
materialShapeDrawable.setFillColor(bottomSheetColor);
} else {
// If the tint isn't set, use the theme default background color.
TypedValue defaultColor = new TypedValue();
context.getTheme().resolveAttribute(android.R.attr.colorBackground, defaultColor, true);
materialShapeDrawable.setTint(defaultColor.data);
}
}
}
private void createShapeValueAnimator() {
interpolatorAnimator = ValueAnimator.ofFloat(0f, 1f);
interpolatorAnimator.setDuration(CORNER_ANIMATION_DURATION);
interpolatorAnimator.addUpdateListener(
new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(@NonNull ValueAnimator animation) {
float value = (float) animation.getAnimatedValue();
if (materialShapeDrawable != null) {
materialShapeDrawable.setInterpolation(value);
}
}
});
}
/**
* Ensure the peek height is at least as large as the bottom gesture inset size so that the sheet
* can always be dragged, but only when the inset is required by the system.
*/
private void setSystemGestureInsets(@NonNull View child) {
if (VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto) {
/*ViewUtils.doOnApplyWindowInsets(
child,
new ViewUtils.OnApplyWindowInsetsListener() {
@Override
public WindowInsetsCompat onApplyWindowInsets(
View view, WindowInsetsCompat insets, RelativePadding initialPadding) {
gestureInsetBottom = insets.getMandatorySystemGestureInsets().bottom;
updatePeekHeight(*//* animate= *//* false);
return insets;
}
});*/
}
}
private float getYVelocity() {
if (velocityTracker == null) {
return 0;
}
velocityTracker.computeCurrentVelocity(1000, maximumVelocity);
return velocityTracker.getYVelocity(activePointerId);
}
void settleToState(@NonNull View child, int state) {
int bottom;
if (state == STATE_COLLAPSED) {
bottom = collapsedOffset;
} else if (state == STATE_HALF_EXPANDED) {
bottom = halfExpandedOffset;
if (fitToContents && bottom >= fitToContentsOffset) {
// Skip to the expanded state if we would scroll past the height of the contents.
state = STATE_EXPANDED;
bottom = fitToContentsOffset;
}
} else if (state == STATE_EXPANDED) {
bottom = getExpandedOffset();
} else if (hideable && state == STATE_HIDDEN) {
bottom = 0;
} else {
throw new IllegalArgumentException("Illegal state argument: " + state);
}
startSettlingAnimation(child, state, bottom - childHeight, false);
}
void startSettlingAnimation(View child, int state, int top, boolean settleFromViewDragHelper) {
boolean startedSettling =
viewDragHelper != null
&& (settleFromViewDragHelper
? viewDragHelper.settleCapturedViewAt(child.getLeft(), top)
: viewDragHelper.smoothSlideViewTo(child, child.getLeft(), top));
if (startedSettling) {
setStateInternal(STATE_SETTLING);
// STATE_SETTLING won't animate the material shape, so do that here with the target state.
updateDrawableForTargetState(state);
if (settleRunnable == null) {
// If the singleton SettleRunnable instance has not been instantiated, create it.
settleRunnable = new SettleRunnable(child, state);
}
// If the SettleRunnable has not been posted, post it with the correct state.
if (settleRunnable.isPosted == false) {
settleRunnable.targetState = state;
ViewCompat.postOnAnimation(child, settleRunnable);
settleRunnable.isPosted = true;
} else {
// Otherwise, if it has been posted, just update the target state.
settleRunnable.targetState = state;
}
} else {
setStateInternal(state);
}
}
private final ViewDragHelper.Callback dragCallback =
new ViewDragHelper.Callback() {
@Override
public boolean tryCaptureView(@NonNull View child, int pointerId) {
if (state == STATE_DRAGGING) {
return false;
}
if (touchingScrollingChild) {
return false;
}
if (state == STATE_EXPANDED && activePointerId == pointerId) {
View scroll = nestedScrollingChildRef != null ? nestedScrollingChildRef.get() : null;
if (scroll != null && scroll.canScrollVertically(-1)) {
// Let the content scroll up
return false;
}
}
return viewRef != null && viewRef.get() == child;
}
@Override
public void onViewPositionChanged(
@NonNull View changedView, int left, int top, int dx, int dy) {
dispatchOnSlide(top);
}
@Override
public void onViewDragStateChanged(int state) {
if (state == ViewDragHelper.STATE_DRAGGING && draggable) {
setStateInternal(STATE_DRAGGING);
}
}
private boolean releasedLow(@NonNull View child) {
// Needs to be at least half way to the bottom.
return child.getBottom() < childHeight/2;
}
@Override
public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
int bottom;
@State int targetState;
if (yvel > 0) { // Moving Down
if (fitToContents) {
bottom = childHeight;
targetState = STATE_EXPANDED;
} else {
int currentBottom = releasedChild.getBottom();
if (currentBottom < halfExpandedOffset) {
bottom = halfExpandedOffset;
targetState = STATE_HALF_EXPANDED;
} else {
bottom = expandedOffset;
targetState = STATE_EXPANDED;
}
}
} else if (hideable && shouldHide(releasedChild, yvel)) {
// Hide if the view was either released low or it was a significant vertical swipe
// otherwise settle to closest expanded state.
if ((Math.abs(xvel) < Math.abs(yvel) && yvel > SIGNIFICANT_VEL_THRESHOLD)
|| releasedLow(releasedChild)) {
bottom = 0;
targetState = STATE_HIDDEN;
} else if (fitToContents) {
bottom = fitToContentsOffset;
targetState = STATE_EXPANDED;
} else if (Math.abs(releasedChild.getTop() - expandedOffset)
< Math.abs(releasedChild.getTop() - halfExpandedOffset)) {
bottom = expandedOffset;
targetState = STATE_EXPANDED;
} else {
bottom = halfExpandedOffset;
targetState = STATE_HALF_EXPANDED;
}
} else if (yvel == 0.f || Math.abs(xvel) > Math.abs(yvel)) {
// If the Y velocity is 0 or the swipe was mostly horizontal indicated by the X velocity
// being greater than the Y velocity, settle to the nearest correct height.
int currentBottom = releasedChild.getBottom();
if (fitToContents) {
if (Math.abs(currentBottom - fitToContentsOffset)
> Math.abs(currentBottom - collapsedOffset)) {
bottom = fitToContentsOffset;
targetState = STATE_EXPANDED;
} else {
bottom = collapsedOffset;
targetState = STATE_COLLAPSED;
}
} else {
if (currentBottom > halfExpandedOffset) {
if (currentBottom > Math.abs(currentBottom - collapsedOffset)) {
bottom = expandedOffset;
targetState = STATE_EXPANDED;
} else {
bottom = halfExpandedOffset;
targetState = STATE_HALF_EXPANDED;
}
} else {
if (Math.abs(currentBottom - halfExpandedOffset)
> Math.abs(currentBottom - collapsedOffset)) {
bottom = halfExpandedOffset;
targetState = STATE_HALF_EXPANDED;
} else {
bottom = collapsedOffset;
targetState = STATE_COLLAPSED;
}
}
}
} else { // Moving Up
if (fitToContents) {
bottom = collapsedOffset;
targetState = STATE_COLLAPSED;
} else {
// Settle to the nearest correct height.
int currentBottom = releasedChild.getBottom();
if (Math.abs(currentBottom - halfExpandedOffset)
> Math.abs(currentBottom - collapsedOffset)) {
bottom = halfExpandedOffset;
targetState = STATE_HALF_EXPANDED;
} else {
bottom = collapsedOffset;
targetState = STATE_COLLAPSED;
}
}
}
startSettlingAnimation(releasedChild, targetState, bottom - childHeight, true);
}
@Override
public int clampViewPositionVertical(@NonNull View child, int top, int dy) {
return MathUtils.clamp(
top, getExpandedOffset(), hideable ? parentHeight : collapsedOffset);
}
@Override
public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
return child.getLeft();
}
@Override
public int getViewVerticalDragRange(@NonNull View child) {
if (hideable) {
return parentHeight;
} else {
return collapsedOffset;
}
}
};
void dispatchOnSlide(int top) {
View bottomSheet = viewRef.get();
if (bottomSheet != null && !callbacks.isEmpty()) {
float slideOffset =
(top > collapsedOffset || collapsedOffset == getExpandedOffset())
? (float) (collapsedOffset - top) / (parentHeight - collapsedOffset)
: (float) (collapsedOffset - top) / (collapsedOffset - getExpandedOffset());
for (int i = 0; i < callbacks.size(); i++) {
callbacks.get(i).onSlide(bottomSheet, slideOffset);
}
}
}
@VisibleForTesting
int getPeekHeightMin() {
return peekHeightMin;
}
/**
* Disables the shaped corner {@link ShapeAppearanceModel} interpolation transition animations.
* Will have no effect unless the sheet utilizes a {@link MaterialShapeDrawable} with set shape
* theming properties. Only For use in UI testing.
*
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@VisibleForTesting
public void disableShapeAnimations() {
// Sets the shape value animator to null, prevents animations from occuring during testing.
interpolatorAnimator = null;
}
private class SettleRunnable implements Runnable {
private final View view;
private boolean isPosted;
@State int targetState;
SettleRunnable(View view, @State int targetState) {
this.view = view;
this.targetState = targetState;
}
@Override
public void run() {
if (viewDragHelper != null && viewDragHelper.continueSettling(true)) {
ViewCompat.postOnAnimation(view, this);
} else {
setStateInternal(targetState);
}
this.isPosted = false;
}
}
/** State persisted across instances */
protected static class SavedState extends AbsSavedState {
@State final int state;
int peekHeight;
boolean fitToContents;
boolean hideable;
boolean skipCollapsed;
public SavedState(@NonNull Parcel source) {
this(source, null);
}
public SavedState(@NonNull Parcel source, ClassLoader loader) {
super(source, loader);
//noinspection ResourceType
state = source.readInt();
peekHeight = source.readInt();
fitToContents = source.readInt() == 1;
hideable = source.readInt() == 1;
skipCollapsed = source.readInt() == 1;
}
public SavedState(Parcelable superState, @NonNull TopSheetBehavior<?> behavior) {
super(superState);
this.state = behavior.state;
this.peekHeight = behavior.peekHeight;
this.fitToContents = behavior.fitToContents;
this.hideable = behavior.hideable;
this.skipCollapsed = behavior.skipCollapsed;
}
/**
* This constructor does not respect flags: {@link TopSheetBehavior#SAVE_PEEK_HEIGHT}, {@link
* TopSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link TopSheetBehavior#SAVE_HIDEABLE}, {@link
* TopSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link TopSheetBehavior#SAVE_NONE}
* were set.
*
* @deprecated Use {@link #SavedState(Parcelable, TopSheetBehavior)} instead.
*/
@Deprecated
public SavedState(Parcelable superstate, int state) {
super(superstate);
this.state = state;
}
@Override
public void writeToParcel(@NonNull Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeInt(state);
out.writeInt(peekHeight);
out.writeInt(fitToContents ? 1 : 0);
out.writeInt(hideable ? 1 : 0);
out.writeInt(skipCollapsed ? 1 : 0);
}
public static final Creator<SavedState> CREATOR =
new ClassLoaderCreator<SavedState>() {
@NonNull
@Override
public SavedState createFromParcel(@NonNull Parcel in, ClassLoader loader) {
return new SavedState(in, loader);
}
@Nullable
@Override
public SavedState createFromParcel(@NonNull Parcel in) {
return new SavedState(in, null);
}
@NonNull
@Override
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}
/**
* A utility function to get the {@link TopSheetBehavior} associated with the {@code view}.
*
* @param view The {@link View} with {@link TopSheetBehavior}.
* @return The {@link TopSheetBehavior} associated with the {@code view}.
*/
@NonNull
@SuppressWarnings("unchecked")
public static <V extends View> TopSheetBehavior<V> from(@NonNull V view) {
ViewGroup.LayoutParams params = view.getLayoutParams();
if (!(params instanceof CoordinatorLayout.LayoutParams)) {
throw new IllegalArgumentException("The view is not a child of CoordinatorLayout");
}
CoordinatorLayout.Behavior<?> behavior =
((CoordinatorLayout.LayoutParams) params).getBehavior();
if (!(behavior instanceof TopSheetBehavior)) {
throw new IllegalArgumentException("The view is not associated with BottomSheetBehavior");
}
return (TopSheetBehavior<V>) behavior;
}
/**
* Sets whether the BottomSheet should update the accessibility status of its {@link
* CoordinatorLayout} siblings when expanded.
*
* <p>Set this to true if the expanded state of the sheet blocks access to siblings (e.g., when
* the sheet expands over the full screen).
*/
public void setUpdateImportantForAccessibilityOnSiblings(
boolean updateImportantForAccessibilityOnSiblings) {
this.updateImportantForAccessibilityOnSiblings = updateImportantForAccessibilityOnSiblings;
}
private void updateImportantForAccessibility(boolean expanded) {
if (viewRef == null) {
return;
}
ViewParent viewParent = viewRef.get().getParent();
if (!(viewParent instanceof CoordinatorLayout)) {
return;
}
CoordinatorLayout parent = (CoordinatorLayout) viewParent;
final int childCount = parent.getChildCount();
if (expanded) {
if (importantForAccessibilityMap == null) {
importantForAccessibilityMap = new HashMap<>(childCount);
} else {
// The important for accessibility values of the child views have been saved already.
return;
}
}
for (int i = 0; i < childCount; i++) {
final View child = parent.getChildAt(i);
if (child == viewRef.get()) {
continue;
}
if (expanded) {
// Saves the important for accessibility value of the child view.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
importantForAccessibilityMap.put(child, child.getImportantForAccessibility());
}
if (updateImportantForAccessibilityOnSiblings) {
ViewCompat.setImportantForAccessibility(
child, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
}
} else {
if (updateImportantForAccessibilityOnSiblings
&& importantForAccessibilityMap != null
&& importantForAccessibilityMap.containsKey(child)) {
// Restores the original important for accessibility value of the child view.
ViewCompat.setImportantForAccessibility(child, importantForAccessibilityMap.get(child));
}
}
}
if (!expanded) {
importantForAccessibilityMap = null;
}
}
private void updateAccessibilityActions() {
if (viewRef == null) {
return;
}
V child = viewRef.get();
if (child == null) {
return;
}
ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_COLLAPSE);
ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_EXPAND);
ViewCompat.removeAccessibilityAction(child, AccessibilityNodeInfoCompat.ACTION_DISMISS);
if (hideable && state != STATE_HIDDEN) {
addAccessibilityActionForState(child, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN);
}
switch (state) {
case STATE_EXPANDED:
{
int nextState = fitToContents ? STATE_COLLAPSED : STATE_HALF_EXPANDED;
addAccessibilityActionForState(
child, AccessibilityActionCompat.ACTION_COLLAPSE, nextState);
break;
}
case STATE_HALF_EXPANDED:
{
addAccessibilityActionForState(
child, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED);
addAccessibilityActionForState(
child, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED);
break;
}
case STATE_COLLAPSED:
{
int nextState = fitToContents ? STATE_EXPANDED : STATE_HALF_EXPANDED;
addAccessibilityActionForState(child, AccessibilityActionCompat.ACTION_EXPAND, nextState);
break;
}
default: // fall out
}
}
private void addAccessibilityActionForState(
V child, AccessibilityActionCompat action, final int state) {
ViewCompat.replaceAccessibilityAction(
child,
action,
null,
new AccessibilityViewCommand() {
@Override
public boolean perform(@NonNull View view, @Nullable CommandArguments arguments) {
setState(state);
return true;
}
});
}
}
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.taskito.topsheet;
import com.google.android.material.R;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Build;
import android.os.Bundle;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StyleRes;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.appcompat.app.AppCompatDialog;
import android.util.TypedValue;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.widget.FrameLayout;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
/** Base class for {@link android.app.Dialog}s styled as a bottom sheet. */
public class TopSheetDialog extends AppCompatDialog {
private TopSheetBehavior<FrameLayout> behavior;
private FrameLayout container;
boolean dismissWithAnimation;
boolean cancelable = true;
private boolean canceledOnTouchOutside = true;
private boolean canceledOnTouchOutsideSet;
public TopSheetDialog(@NonNull Context context) {
this(context, 0);
}
public TopSheetDialog(@NonNull Context context, @StyleRes int theme) {
super(context, getThemeResId(context, theme));
// We hide the title bar for any style configuration. Otherwise, there will be a gap
// above the bottom sheet when it is expanded.
supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
}
protected TopSheetDialog(
@NonNull Context context, boolean cancelable, OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
this.cancelable = cancelable;
}
@Override
public void setContentView(@LayoutRes int layoutResId) {
super.setContentView(wrapInBottomSheet(layoutResId, null, null));
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Window window = getWindow();
if (window != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
}
window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
}
}
@Override
public void setContentView(View view) {
super.setContentView(wrapInBottomSheet(0, view, null));
}
@Override
public void setContentView(View view, ViewGroup.LayoutParams params) {
super.setContentView(wrapInBottomSheet(0, view, params));
}
@Override
public void setCancelable(boolean cancelable) {
super.setCancelable(cancelable);
if (this.cancelable != cancelable) {
this.cancelable = cancelable;
if (behavior != null) {
behavior.setHideable(cancelable);
}
}
}
@Override
protected void onStart() {
super.onStart();
if (behavior != null && behavior.getState() == TopSheetBehavior.STATE_HIDDEN) {
behavior.setState(TopSheetBehavior.STATE_COLLAPSED);
}
}
@Override
public void cancel() {
TopSheetBehavior<FrameLayout> behavior = getBehavior();
if (!dismissWithAnimation || behavior.getState() == TopSheetBehavior.STATE_HIDDEN) {
super.cancel();
} else {
behavior.setState(TopSheetBehavior.STATE_HIDDEN);
}
}
@Override
public void setCanceledOnTouchOutside(boolean cancel) {
super.setCanceledOnTouchOutside(cancel);
if (cancel && !cancelable) {
cancelable = true;
}
canceledOnTouchOutside = cancel;
canceledOnTouchOutsideSet = true;
}
@NonNull
public TopSheetBehavior<FrameLayout> getBehavior() {
if (behavior == null) {
// The content hasn't been set, so the behavior doesn't exist yet. Let's create it.
ensureContainerAndBehavior();
}
return behavior;
}
/**
* Set to perform the swipe down animation when dismissing instead of the window animation for the
* dialog.
*
* @param dismissWithAnimation True if swipe down animation should be used when dismissing.
*/
public void setDismissWithAnimation(boolean dismissWithAnimation) {
this.dismissWithAnimation = dismissWithAnimation;
}
/**
* Returns if dismissing will perform the swipe down animation on the bottom sheet, rather than
* the window animation for the dialog.
*/
public boolean getDismissWithAnimation() {
return dismissWithAnimation;
}
/** Creates the container layout which must exist to find the behavior */
private FrameLayout ensureContainerAndBehavior() {
if (container == null) {
container =
(FrameLayout) View.inflate(getContext(), com.fenchtose.reflog.R.layout.design_top_sheet_dialog, null);
FrameLayout bottomSheet = (FrameLayout) container.findViewById(R.id.design_bottom_sheet);
behavior = TopSheetBehavior.from(bottomSheet);
behavior.addTopSheetCallback(topSheetCallback);
behavior.setHideable(cancelable);
}
return container;
}
private View wrapInBottomSheet(
int layoutResId, @Nullable View view, @Nullable ViewGroup.LayoutParams params) {
ensureContainerAndBehavior();
CoordinatorLayout coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator);
if (layoutResId != 0 && view == null) {
view = getLayoutInflater().inflate(layoutResId, coordinator, false);
}
FrameLayout bottomSheet = (FrameLayout) container.findViewById(R.id.design_bottom_sheet);
bottomSheet.removeAllViews();
if (params == null) {
bottomSheet.addView(view);
} else {
bottomSheet.addView(view, params);
}
// We treat the CoordinatorLayout as outside the dialog though it is technically inside
coordinator
.findViewById(R.id.touch_outside)
.setOnClickListener(
new View.OnClickListener() {
@Override
public void onClick(View view) {
if (cancelable && isShowing() && shouldWindowCloseOnTouchOutside()) {
cancel();
}
}
});
// Handle accessibility events
ViewCompat.setAccessibilityDelegate(
bottomSheet,
new AccessibilityDelegateCompat() {
@Override
public void onInitializeAccessibilityNodeInfo(
View host, @NonNull AccessibilityNodeInfoCompat info) {
super.onInitializeAccessibilityNodeInfo(host, info);
if (cancelable) {
info.addAction(AccessibilityNodeInfoCompat.ACTION_DISMISS);
info.setDismissable(true);
} else {
info.setDismissable(false);
}
}
@Override
public boolean performAccessibilityAction(View host, int action, Bundle args) {
if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS && cancelable) {
cancel();
return true;
}
return super.performAccessibilityAction(host, action, args);
}
});
bottomSheet.setOnTouchListener(
new View.OnTouchListener() {
@Override
public boolean onTouch(View view, MotionEvent event) {
// Consume the event and prevent it from falling through
return true;
}
});
return container;
}
boolean shouldWindowCloseOnTouchOutside() {
if (!canceledOnTouchOutsideSet) {
TypedArray a =
getContext()
.obtainStyledAttributes(new int[] {android.R.attr.windowCloseOnTouchOutside});
canceledOnTouchOutside = a.getBoolean(0, true);
a.recycle();
canceledOnTouchOutsideSet = true;
}
return canceledOnTouchOutside;
}
private static int getThemeResId(@NonNull Context context, int themeId) {
if (themeId == 0) {
// If the provided theme is 0, then retrieve the dialogTheme from our theme
TypedValue outValue = new TypedValue();
if (context.getTheme().resolveAttribute(R.attr.bottomSheetDialogTheme, outValue, true)) {
themeId = outValue.resourceId;
} else {
// bottomSheetDialogTheme is not provided; we default to our light theme
themeId = R.style.Theme_Design_Light_BottomSheetDialog;
}
}
return themeId;
}
void removeDefaultCallback() {
behavior.removeTopSheetCallback(topSheetCallback);
}
@NonNull
private final TopSheetBehavior.SheetCallback topSheetCallback =
new TopSheetBehavior.SheetCallback() {
@Override
public void onStateChanged(
@NonNull View topSheet, @TopSheetBehavior.State int newState) {
if (newState == TopSheetBehavior.STATE_HIDDEN) {
cancel();
}
}
@Override
public void onSlide(@NonNull View topSheet, float slideOffset) {}
};
}
package io.taskito.topsheet
import android.content.Context
import android.content.res.ColorStateList
import android.content.res.TypedArray
import androidx.annotation.StyleableRes
import androidx.appcompat.content.res.AppCompatResources
object TopSheetUtils {
/**
* Returns the [ColorStateList] from the given [TypedArray] attributes. The resource
* can include themeable attributes, regardless of API level.
*/
fun getColorStateList(
context: Context, attributes: TypedArray, @StyleableRes index: Int
): ColorStateList? {
if (attributes.hasValue(index)) {
val resourceId = attributes.getResourceId(index, 0)
if (resourceId != 0) {
val value = AppCompatResources.getColorStateList(context, resourceId)
if (value != null) {
return value
}
}
}
return attributes.getColorStateList(index)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment