diff --git a/.gitignore b/.gitignore
index aa3f9683b..5f1da0430 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,6 @@ captures/
.externalNativeBuild
*.iml
.cxx
+
+# Patched material
+app/src/main/com/google/android/material
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13415d442..8a4f2c73f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@ at the cost of longer loading times
#### What's Changed
- Play and skip icons are filled again
- Updated music hashing (Will wipe playback state)
+- Migrated to BottomSheetBehavior
## 2.5.0
diff --git a/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java
new file mode 100644
index 000000000..471f4bad7
--- /dev/null
+++ b/app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java
@@ -0,0 +1,2195 @@
+/*
+ * 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 com.google.android.material.bottomsheet;
+
+import com.google.android.material.R;
+
+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.animation.ValueAnimator.AnimatorUpdateListener;
+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.View.MeasureSpec;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.ViewGroup.MarginLayoutParams;
+import android.view.ViewParent;
+import android.view.accessibility.AccessibilityEvent;
+import androidx.annotation.FloatRange;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.Px;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.StringRes;
+import androidx.annotation.VisibleForTesting;
+import androidx.coordinatorlayout.widget.CoordinatorLayout;
+import androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams;
+import androidx.core.graphics.Insets;
+import androidx.core.math.MathUtils;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+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.internal.ViewUtils;
+import com.google.android.material.internal.ViewUtils.RelativePadding;
+import com.google.android.material.resources.MaterialResources;
+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.
+ *
+ *
To send useful accessibility events, set a title on bottom sheets that are windows or are
+ * window-like. For BottomSheetDialog use {@link BottomSheetDialog#setTitle(int)}, and for
+ * BottomSheetDialogFragment use {@link ViewCompat#setAccessibilityPaneTitle(View, CharSequence)}.
+ */
+public class NeoBottomSheetBehavior extends CoordinatorLayout.Behavior {
+
+ /** Callback for monitoring events about bottom sheets. */
+ public abstract static class BottomSheetCallback {
+
+ /**
+ * 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);
+
+ void onLayout(@NonNull View bottomSheet) {}
+ }
+
+ /** 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 fitToContents 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 {}
+
+ /**
+ * Stable states that can be set by the {@link #setState(int)} method. These includes all the
+ * possible states a bottom sheet can be in when it's settled.
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP)
+ @IntDef({STATE_EXPANDED, STATE_COLLAPSED, STATE_HIDDEN, STATE_HALF_EXPANDED})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface StableState {}
+
+ /**
+ * Peek at the 16:9 ratio keyline of its parent.
+ *
+ * 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 = "BottomSheetBehavior";
+
+ @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 static final int NO_MAX_SIZE = -1;
+
+ 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;
+
+ private MaterialShapeDrawable materialShapeDrawable;
+
+ @Nullable private ColorStateList backgroundTint;
+
+ private int maxWidth = NO_MAX_SIZE;
+
+ private int maxHeight = NO_MAX_SIZE;
+
+ private int gestureInsetBottom;
+ private boolean gestureInsetBottomIgnored;
+ private boolean paddingBottomSystemWindowInsets;
+ private boolean paddingLeftSystemWindowInsets;
+ private boolean paddingRightSystemWindowInsets;
+ private boolean paddingTopSystemWindowInsets;
+ private boolean marginLeftSystemWindowInsets;
+ private boolean marginRightSystemWindowInsets;
+ private boolean marginTopSystemWindowInsets;
+
+ private int insetBottom;
+ private int insetTop;
+
+ /** Default Shape Appearance to be used in bottomsheet */
+ private ShapeAppearanceModel shapeAppearanceModelDefault;
+
+ private boolean isShapeExpanded;
+
+ private final StateSettlingTracker stateSettlingTracker = new StateSettlingTracker();
+
+ @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;
+
+ @State int lastStableState = STATE_COLLAPSED;
+
+ @Nullable ViewDragHelper viewDragHelper;
+
+ private boolean ignoreEvents;
+
+ private int lastNestedScrollDy;
+
+ private boolean nestedScrolled;
+
+ private float hideFriction = HIDE_FRICTION;
+
+ private int childHeight;
+ int parentWidth;
+ int parentHeight;
+
+ @Nullable WeakReference viewRef;
+
+ @Nullable WeakReference nestedScrollingChildRef;
+
+ @NonNull private final ArrayList callbacks = new ArrayList<>();
+
+ @Nullable private VelocityTracker velocityTracker;
+
+ int activePointerId;
+
+ private int initialY;
+
+ boolean touchingScrollingChild;
+
+ @Nullable private Map importantForAccessibilityMap;
+
+ private int expandHalfwayActionId = View.NO_ID;
+
+ public NeoBottomSheetBehavior() {}
+
+ public NeoBottomSheetBehavior(@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);
+ if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_backgroundTint)) {
+ this.backgroundTint = MaterialResources.getColorStateList(
+ context, a, R.styleable.BottomSheetBehavior_Layout_backgroundTint);
+ }
+ if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_shapeAppearance)) {
+ this.shapeAppearanceModelDefault =
+ ShapeAppearanceModel.builder(context, attrs, R.attr.bottomSheetStyle, DEF_STYLE_RES)
+ .build();
+ }
+ createMaterialShapeDrawableIfNeeded(context);
+ createShapeValueAnimator();
+
+ if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
+ this.elevation = a.getDimension(R.styleable.BottomSheetBehavior_Layout_android_elevation, -1);
+ }
+
+ if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_android_maxWidth)) {
+ setMaxWidth(
+ a.getDimensionPixelSize(
+ R.styleable.BottomSheetBehavior_Layout_android_maxWidth, NO_MAX_SIZE));
+ }
+
+ if (a.hasValue(R.styleable.BottomSheetBehavior_Layout_android_maxHeight)) {
+ setMaxHeight(
+ a.getDimensionPixelSize(
+ R.styleable.BottomSheetBehavior_Layout_android_maxHeight, NO_MAX_SIZE));
+ }
+
+ 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));
+ }
+
+ // Reading out if we are handling padding, so we can apply it to the content.
+ paddingBottomSystemWindowInsets =
+ a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingBottomSystemWindowInsets, false);
+ paddingLeftSystemWindowInsets =
+ a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingLeftSystemWindowInsets, false);
+ paddingRightSystemWindowInsets =
+ a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingRightSystemWindowInsets, false);
+ // Setting this to false will prevent the bottomsheet from going below the status bar. Since
+ // this is a breaking change from the old behavior the default is true.
+ paddingTopSystemWindowInsets =
+ a.getBoolean(R.styleable.BottomSheetBehavior_Layout_paddingTopSystemWindowInsets, true);
+ marginLeftSystemWindowInsets =
+ a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginLeftSystemWindowInsets, false);
+ marginRightSystemWindowInsets =
+ a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginRightSystemWindowInsets, false);
+ marginTopSystemWindowInsets =
+ a.getBoolean(R.styleable.BottomSheetBehavior_Layout_marginTopSystemWindowInsets, false);
+
+ 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;
+ this.lastStableState = this.state;
+ } else {
+ this.state = ss.state;
+ this.lastStableState = this.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 onMeasureChild(
+ @NonNull CoordinatorLayout parent,
+ @NonNull V child,
+ int parentWidthMeasureSpec,
+ int widthUsed,
+ int parentHeightMeasureSpec,
+ int heightUsed) {
+ MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
+ int childWidthMeasureSpec =
+ getChildMeasureSpec(
+ parentWidthMeasureSpec,
+ parent.getPaddingLeft()
+ + parent.getPaddingRight()
+ + lp.leftMargin
+ + lp.rightMargin
+ + widthUsed,
+ maxWidth,
+ lp.width);
+ int childHeightMeasureSpec =
+ getChildMeasureSpec(
+ parentHeightMeasureSpec,
+ parent.getPaddingTop()
+ + parent.getPaddingBottom()
+ + lp.topMargin
+ + lp.bottomMargin
+ + heightUsed,
+ maxHeight,
+ lp.height);
+ child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
+ return true; // Child was measured
+ }
+
+ private int getChildMeasureSpec(
+ int parentMeasureSpec, int padding, int maxSize, int childDimension) {
+ int result = ViewGroup.getChildMeasureSpec(parentMeasureSpec, padding, childDimension);
+ if (maxSize == NO_MAX_SIZE) {
+ return result;
+ } else {
+ int mode = MeasureSpec.getMode(result);
+ int size = MeasureSpec.getSize(result);
+ switch (mode) {
+ case MeasureSpec.EXACTLY:
+ return MeasureSpec.makeMeasureSpec(min(size, maxSize), MeasureSpec.EXACTLY);
+ case MeasureSpec.AT_MOST:
+ case MeasureSpec.UNSPECIFIED:
+ default:
+ return MeasureSpec.makeMeasureSpec(
+ size == 0 ? maxSize : min(size, maxSize), MeasureSpec.AT_MOST);
+ }
+ }
+ }
+
+ @Override
+ public boolean onLayoutChild(
+ @NonNull CoordinatorLayout parent, @NonNull final 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);
+ setWindowInsetsListener(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 (materialShapeDrawable != null) {
+ ViewCompat.setBackground(child, materialShapeDrawable);
+ // 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);
+ } else if (backgroundTint != null) {
+ ViewCompat.setBackgroundTintList(child, backgroundTint);
+ }
+ 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);
+ }
+
+ int savedTop = child.getTop();
+ // First let the parent lay it out
+ parent.onLayoutChild(child, layoutDirection);
+ // Offset the bottom sheet
+ parentWidth = parent.getWidth();
+ parentHeight = parent.getHeight();
+ childHeight = child.getHeight();
+ if (parentHeight - childHeight < insetTop) {
+ if (paddingTopSystemWindowInsets) {
+ // If the bottomsheet would land in the middle of the status bar when fully expanded add
+ // extra space to make sure it goes all the way.
+ childHeight = parentHeight;
+ } else {
+ // If we don't want the bottomsheet to go under the status bar we cap its height
+ childHeight = parentHeight - insetTop;
+ }
+ }
+ fitToContentsOffset = max(0, parentHeight - childHeight);
+ calculateHalfExpandedOffset();
+ calculateCollapsedOffset();
+
+ if (state == STATE_EXPANDED) {
+ ViewCompat.offsetTopAndBottom(child, getExpandedOffset());
+ } else if (state == STATE_HALF_EXPANDED) {
+ ViewCompat.offsetTopAndBottom(child, halfExpandedOffset);
+ } else if (hideable && state == STATE_HIDDEN) {
+ ViewCompat.offsetTopAndBottom(child, parentHeight);
+ } else if (state == STATE_COLLAPSED) {
+ ViewCompat.offsetTopAndBottom(child, collapsedOffset);
+ } else if (state == STATE_DRAGGING || state == STATE_SETTLING) {
+ ViewCompat.offsetTopAndBottom(child, savedTop - child.getTop());
+ }
+
+ nestedScrollingChildRef = new WeakReference<>(findScrollingChild(child));
+
+ for (int i = 0; i < callbacks.size(); i++) {
+ callbacks.get(i).onLayout(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 (shouldHandleDraggingWithHelper()) {
+ 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 (shouldHandleDraggingWithHelper() && 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 (isNestedScrollingCheckEnabled() && target != scrollingChild) {
+ return;
+ }
+ int currentTop = child.getTop();
+ int newTop = currentTop - dy;
+ if (dy > 0) { // Upward
+ if (newTop < getExpandedOffset()) {
+ consumed[1] = currentTop - 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);
+ }
+ } else if (dy < 0) { // Downward
+ if (!target.canScrollVertically(-1)) {
+ if (newTop <= collapsedOffset || hideable) {
+ if (!draggable) {
+ // Prevent dragging
+ return;
+ }
+
+ consumed[1] = dy;
+ ViewCompat.offsetTopAndBottom(child, -dy);
+ setStateInternal(STATE_DRAGGING);
+ } else {
+ consumed[1] = currentTop - collapsedOffset;
+ ViewCompat.offsetTopAndBottom(child, -consumed[1]);
+ setStateInternal(STATE_COLLAPSED);
+ }
+ }
+ }
+ 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() == getExpandedOffset()) {
+ setStateInternal(STATE_EXPANDED);
+ return;
+ }
+ if (isNestedScrollingCheckEnabled()
+ && (nestedScrollingChildRef == null
+ || target != nestedScrollingChildRef.get()
+ || !nestedScrolled)) {
+ return;
+ }
+ @StableState int targetState;
+ if (lastNestedScrollDy > 0) {
+ if (fitToContents) {
+ targetState = STATE_EXPANDED;
+ } else {
+ // MODIFICATION: Make nested scrolling respond to shouldSkipHalfExpandedStateWhenDragging
+ int currentTop = child.getTop();
+ if (currentTop < halfExpandedOffset) {
+ targetState = STATE_EXPANDED;
+ } else {
+ if (shouldSkipHalfExpandedStateWhenDragging()) {
+ targetState = STATE_COLLAPSED;
+ } else {
+ targetState = STATE_HALF_EXPANDED;
+ }
+ }
+ }
+ } else if (hideable && shouldHide(child, getYVelocity())) {
+ targetState = STATE_HIDDEN;
+ } else if (lastNestedScrollDy == 0) {
+ int currentTop = child.getTop();
+ if (fitToContents) {
+ if (Math.abs(currentTop - fitToContentsOffset) < Math.abs(currentTop - collapsedOffset)) {
+ targetState = STATE_EXPANDED;
+ } else {
+ targetState = STATE_COLLAPSED;
+ }
+ } else {
+ if (currentTop < halfExpandedOffset) {
+ if (currentTop < Math.abs(currentTop - collapsedOffset)) {
+ targetState = STATE_EXPANDED;
+ } else {
+ if (shouldSkipHalfExpandedStateWhenDragging()) {
+ targetState = STATE_COLLAPSED;
+ } else {
+ targetState = STATE_HALF_EXPANDED;
+ }
+ }
+ } else {
+ // MODIFICATION: Make nested scrolling respond to shouldSkipHalfExpandedStateWhenDragging
+ if (shouldSkipHalfExpandedStateWhenDragging()) {
+ targetState = STATE_COLLAPSED;
+ } else {
+ if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) {
+ targetState = STATE_HALF_EXPANDED;
+ } else {
+ targetState = STATE_COLLAPSED;
+ }
+ }
+ }
+ }
+ } else {
+ if (fitToContents) {
+ targetState = STATE_COLLAPSED;
+ } else {
+ // Settle to nearest height.
+ // MODIFICATION: Make nested scrolling respond to shouldSkipHalfExpandedStateWhenDragging
+ int currentTop = child.getTop();
+ if (shouldSkipHalfExpandedStateWhenDragging()) {
+ targetState = STATE_COLLAPSED;
+ } else {
+ if (Math.abs(currentTop - halfExpandedOffset) < Math.abs(currentTop - collapsedOffset)) {
+ targetState = STATE_HALF_EXPANDED;
+ } else {
+ targetState = STATE_COLLAPSED;
+ }
+ }
+ }
+ }
+ startSettling(child, targetState, 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 (isNestedScrollingCheckEnabled() && 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 maximum width of the bottom sheet. The layout will be at most this dimension wide.
+ * This method should be called before {@link BottomSheetDialog#show()} in order for the width to
+ * be adjusted as expected.
+ *
+ * @param maxWidth The maximum width in pixels to be set
+ * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth
+ * @see #getMaxWidth()
+ */
+ public void setMaxWidth(@Px int maxWidth) {
+ this.maxWidth = maxWidth;
+ }
+
+ /**
+ * Returns the bottom sheet's maximum width, or -1 if no maximum width is set.
+ *
+ * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxWidth
+ * @see #setMaxWidth(int)
+ */
+ @Px
+ public int getMaxWidth() {
+ return maxWidth;
+ }
+
+ /**
+ * Sets the maximum height of the bottom sheet. This method should be called before {@link
+ * BottomSheetDialog#show()} in order for the height to be adjusted as expected.
+ *
+ * @param maxHeight The maximum height in pixels to be set
+ * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight
+ * @see #getMaxHeight()
+ */
+ public void setMaxHeight(@Px int maxHeight) {
+ this.maxHeight = maxHeight;
+ }
+
+ /**
+ * Returns the bottom sheet's maximum height, or -1 if no maximum height is set.
+ *
+ * @attr ref com.google.android.material.R.styleable#BottomSheetBehavior_Layout_android_maxHeight
+ * @see #setMaxHeight(int)
+ */
+ @Px
+ public int getMaxHeight() {
+ return maxHeight;
+ }
+
+ /**
+ * 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) {
+ setState(STATE_COLLAPSED);
+ } 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, fromInclusive = false, toInclusive = false) 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
+ : Math.max(expandedOffset, paddingTopSystemWindowInsets ? 0 : insetTop);
+ }
+
+ // MODIFICATION: Add calculateSlideOffset method
+
+ /**
+ * Calculates the current offset of the bottom sheet.
+ *
+ * This method should be called when the child view is laid out.
+ *
+ * @return The 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. Returns
+ * {@code Float.MIN_VALUE} if the bottom sheet is not laid out.
+ */
+ public float calculateSlideOffset() {
+ if (viewRef == null) {
+ return Float.MIN_VALUE;
+ }
+
+ View bottomSheet = viewRef.get();
+ if (bottomSheet != null) {
+ return calculateSlideOffset(bottomSheet.getTop());
+ }
+
+ return Float.MIN_VALUE;
+ }
+
+ /**
+ * 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 the friction coefficient to hide the bottom sheet, or set it to the next closest
+ * expanded state.
+ *
+ * @param hideFriction The friction coefficient that determines the swipe velocity needed to
+ * hide or set the bottom sheet to the closest expanded state.
+ */
+ public void setHideFriction(float hideFriction) {
+ this.hideFriction = hideFriction;
+ }
+
+ /**
+ * Gets the friction coefficient to hide the bottom sheet, or set it to the next closest
+ * expanded state.
+ *
+ * @return The friction coefficient that determines the swipe velocity needed to hide or set the
+ * bottom sheet to the closest expanded state.
+ */
+ public float getHideFriction() {
+ return this.hideFriction;
+ }
+
+ /**
+ * Sets a callback to be notified of bottom sheet events.
+ *
+ * @param callback The callback to notify when bottom sheet events occur.
+ * @deprecated use {@link #addBottomSheetCallback(BottomSheetCallback)} and {@link
+ * #removeBottomSheetCallback(BottomSheetCallback)} instead
+ */
+ @Deprecated
+ public void setBottomSheetCallback(BottomSheetCallback callback) {
+ Log.w(
+ TAG,
+ "BottomSheetBehavior now supports multiple callbacks. `setBottomSheetCallback()` 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"
+ + " `addBottomSheetCallback()` and `removeBottomSheetCallback()` 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 addBottomSheetCallback(@NonNull BottomSheetCallback callback) {
+ if (!callbacks.contains(callback)) {
+ callbacks.add(callback);
+ }
+ }
+
+ /**
+ * Removes a previously added callback.
+ *
+ * @param callback The callback to remove.
+ */
+ public void removeBottomSheetCallback(@NonNull BottomSheetCallback 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(@StableState int state) {
+ if (state == STATE_DRAGGING || state == STATE_SETTLING) {
+ throw new IllegalArgumentException(
+ "STATE_"
+ + (state == STATE_DRAGGING ? "DRAGGING" : "SETTLING")
+ + " should not be set externally.");
+ }
+ if (!hideable && state == STATE_HIDDEN) {
+ Log.w(TAG, "Cannot set state: " + state);
+ return;
+ }
+ final int finalState;
+ if (state == STATE_HALF_EXPANDED
+ && fitToContents
+ && getTopOffsetForState(state) <= fitToContentsOffset) {
+ // Skip to the expanded state if we would scroll past the height of the contents.
+ finalState = STATE_EXPANDED;
+ } else {
+ finalState = state;
+ }
+ if (viewRef == null || viewRef.get() == null) {
+ // The view is not laid out yet; modify mState and let onLayoutChild handle it later
+ setStateInternal(state);
+ } else {
+ final V child = viewRef.get();
+ runAfterLayout(
+ child,
+ new Runnable() {
+ @Override
+ public void run() {
+ startSettling(child, finalState, false);
+ }
+ });
+ }
+ }
+
+ private void runAfterLayout(V child, Runnable runnable) {
+ if (isLayouting(child)) {
+ child.post(runnable);
+ } else {
+ runnable.run();
+ }
+ }
+
+ private boolean isLayouting(V child) {
+ ViewParent parent = child.getParent();
+ return parent != null && parent.isLayoutRequested() && ViewCompat.isAttachedToWindow(child);
+ }
+
+ /**
+ * Sets whether this bottom sheet should adjust it's position based on the system gesture area on
+ * Android Q and above.
+ *
+ * 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;
+ }
+
+ /**
+ * Gets the current state of the bottom sheet.
+ *
+ * @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED},
+ * {@link #STATE_DRAGGING}, or {@link #STATE_SETTLING}.
+ */
+ @State
+ public int getState() {
+ return state;
+ }
+
+ void setStateInternal(@State int state) {
+ if (this.state == state) {
+ return;
+ }
+ this.state = state;
+ if (state == STATE_COLLAPSED
+ || state == STATE_EXPANDED
+ || state == STATE_HALF_EXPANDED
+ || (hideable && state == STATE_HIDDEN)) {
+ this.lastStableState = 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) + insetBottom;
+ }
+ // Only make sure the peek height is above the gesture insets if we're not applying system
+ // insets.
+ if (!gestureInsetBottomIgnored && !paddingBottomSystemWindowInsets && gestureInsetBottom > 0) {
+ return max(peekHeight, gestureInsetBottom + peekHeightGestureInsetBuffer);
+ }
+ return peekHeight + insetBottom;
+ }
+
+ private void calculateCollapsedOffset() {
+ int peek = calculatePeekHeight();
+
+ if (fitToContents) {
+ collapsedOffset = max(parentHeight - peek, fitToContentsOffset);
+ } else {
+ collapsedOffset = parentHeight - peek;
+ }
+ }
+
+ private void calculateHalfExpandedOffset() {
+ this.halfExpandedOffset = (int) (parentHeight * (1 - halfExpandedRatio));
+ }
+
+ // MODIFICATION: Add calculateSlideOffset method
+
+ private float calculateSlideOffset(int top) {
+ return
+ (top > collapsedOffset || collapsedOffset == getExpandedOffset())
+ ? (float) (collapsedOffset - top) / (parentHeight - collapsedOffset)
+ : (float) (collapsedOffset - top) / (collapsedOffset - getExpandedOffset());
+ }
+
+ 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.getTop() < collapsedOffset) {
+ // It should not hide, but collapse.
+ return false;
+ }
+ int peek = calculatePeekHeight();
+ final float newTop = child.getTop() + yvel * hideFriction;
+ return Math.abs(newTop - 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 boolean shouldHandleDraggingWithHelper() {
+ // If it's not draggable, do not forward events to viewDragHelper; however, if it's already
+ // dragging, let it finish.
+ return viewDragHelper != null && (draggable || state == STATE_DRAGGING);
+ }
+
+ private void createMaterialShapeDrawableIfNeeded(@NonNull Context context) {
+ if (shapeAppearanceModelDefault == null) {
+ return;
+ }
+
+ this.materialShapeDrawable = new MaterialShapeDrawable(shapeAppearanceModelDefault);
+ this.materialShapeDrawable.initializeElevationOverlay(context);
+
+ if (backgroundTint != null) {
+ materialShapeDrawable.setFillColor(backgroundTint);
+ } 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);
+ }
+ }
+
+ MaterialShapeDrawable getMaterialShapeDrawable() {
+ return materialShapeDrawable;
+ }
+
+ private void createShapeValueAnimator() {
+ interpolatorAnimator = ValueAnimator.ofFloat(0f, 1f);
+ interpolatorAnimator.setDuration(CORNER_ANIMATION_DURATION);
+ interpolatorAnimator.addUpdateListener(
+ new AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(@NonNull ValueAnimator animation) {
+ float value = (float) animation.getAnimatedValue();
+ if (materialShapeDrawable != null) {
+ materialShapeDrawable.setInterpolation(value);
+ }
+ }
+ });
+ }
+
+ private void setWindowInsetsListener(@NonNull View child) {
+ // 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.
+ final boolean shouldHandleGestureInsets =
+ VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto;
+
+ // If were not handling insets at all, don't apply the listener.
+ if (!paddingBottomSystemWindowInsets
+ && !paddingLeftSystemWindowInsets
+ && !paddingRightSystemWindowInsets
+ && !marginLeftSystemWindowInsets
+ && !marginRightSystemWindowInsets
+ && !marginTopSystemWindowInsets
+ && !shouldHandleGestureInsets) {
+ return;
+ }
+ ViewUtils.doOnApplyWindowInsets(
+ child,
+ new ViewUtils.OnApplyWindowInsetsListener() {
+ @Override
+ @SuppressWarnings("deprecation") // getSystemWindowInsetBottom is used for adjustResize.
+ public WindowInsetsCompat onApplyWindowInsets(
+ View view, WindowInsetsCompat insets, RelativePadding initialPadding) {
+ Insets systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars());
+ Insets mandatoryGestureInsets =
+ insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures());
+
+ insetTop = systemBarInsets.top;
+
+ boolean isRtl = ViewUtils.isLayoutRtl(view);
+
+ int bottomPadding = view.getPaddingBottom();
+ int leftPadding = view.getPaddingLeft();
+ int rightPadding = view.getPaddingRight();
+
+ if (paddingBottomSystemWindowInsets) {
+ // Intentionally uses getSystemWindowInsetBottom to apply padding properly when
+ // adjustResize is used as the windowSoftInputMode.
+ insetBottom = insets.getSystemWindowInsetBottom();
+ bottomPadding = initialPadding.bottom + insetBottom;
+ }
+
+ if (paddingLeftSystemWindowInsets) {
+ leftPadding = isRtl ? initialPadding.end : initialPadding.start;
+ leftPadding += systemBarInsets.left;
+ }
+
+ if (paddingRightSystemWindowInsets) {
+ rightPadding = isRtl ? initialPadding.start : initialPadding.end;
+ rightPadding += systemBarInsets.right;
+ }
+
+ MarginLayoutParams mlp = (MarginLayoutParams) view.getLayoutParams();
+ boolean marginUpdated = false;
+
+ if (marginLeftSystemWindowInsets && mlp.leftMargin != systemBarInsets.left) {
+ mlp.leftMargin = systemBarInsets.left;
+ marginUpdated = true;
+ }
+
+ if (marginRightSystemWindowInsets && mlp.rightMargin != systemBarInsets.right) {
+ mlp.rightMargin = systemBarInsets.right;
+ marginUpdated = true;
+ }
+
+ if (marginTopSystemWindowInsets && mlp.topMargin != systemBarInsets.top) {
+ mlp.topMargin = systemBarInsets.top;
+ marginUpdated = true;
+ }
+
+ if (marginUpdated) {
+ view.setLayoutParams(mlp);
+ }
+ view.setPadding(leftPadding, view.getPaddingTop(), rightPadding, bottomPadding);
+
+ if (shouldHandleGestureInsets) {
+ gestureInsetBottom = mandatoryGestureInsets.bottom;
+ }
+
+ // Don't update the peek height to be above the navigation bar or gestures if these
+ // flags are off. It means the client is already handling it.
+ if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) {
+ updatePeekHeight(/* animate= */ false);
+ }
+ return insets;
+ }
+ });
+ }
+
+ private float getYVelocity() {
+ if (velocityTracker == null) {
+ return 0;
+ }
+ velocityTracker.computeCurrentVelocity(1000, maximumVelocity);
+ return velocityTracker.getYVelocity(activePointerId);
+ }
+
+ private void startSettling(View child, @StableState int state, boolean isReleasingView) {
+ int top = getTopOffsetForState(state);
+ boolean settling =
+ viewDragHelper != null
+ && (isReleasingView
+ ? viewDragHelper.settleCapturedViewAt(child.getLeft(), top)
+ : viewDragHelper.smoothSlideViewTo(child, child.getLeft(), top));
+ if (settling) {
+ setStateInternal(STATE_SETTLING);
+ // STATE_SETTLING won't animate the material shape, so do that here with the target state.
+ updateDrawableForTargetState(state);
+ stateSettlingTracker.continueSettlingToState(state);
+ } else {
+ setStateInternal(state);
+ }
+ }
+
+ private int getTopOffsetForState(@StableState int state) {
+ switch (state) {
+ case STATE_COLLAPSED:
+ return collapsedOffset;
+ case STATE_EXPANDED:
+ return getExpandedOffset();
+ case STATE_HALF_EXPANDED:
+ return halfExpandedOffset;
+ case STATE_HIDDEN:
+ return parentHeight;
+ default:
+ // Fall through
+ }
+ throw new IllegalArgumentException("Invalid state to get top offset: " + state);
+ }
+
+ private final ViewDragHelper.Callback dragCallback =
+ new ViewDragHelper.Callback() {
+
+ private long viewCapturedMillis;
+
+ @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;
+ }
+ }
+ viewCapturedMillis = System.currentTimeMillis();
+ 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(@State 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.getTop() > (parentHeight + getExpandedOffset()) / 2;
+ }
+
+ @Override
+ public void onViewReleased(@NonNull View releasedChild, float xvel, float yvel) {
+ @State int targetState;
+ if (yvel < 0) { // Moving up
+ if (fitToContents) {
+ targetState = STATE_EXPANDED;
+ } else {
+ int currentTop = releasedChild.getTop();
+ long dragDurationMillis = System.currentTimeMillis() - viewCapturedMillis;
+
+ if (shouldSkipHalfExpandedStateWhenDragging()) {
+ float yPositionPercentage = currentTop * 100f / parentHeight;
+
+ if (shouldExpandOnUpwardDrag(dragDurationMillis, yPositionPercentage)) {
+ targetState = STATE_EXPANDED;
+ } else {
+ targetState = STATE_COLLAPSED;
+ }
+ } else {
+ if (currentTop > halfExpandedOffset) {
+ targetState = STATE_HALF_EXPANDED;
+ } else {
+ 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)) {
+ targetState = STATE_HIDDEN;
+ } else if (fitToContents) {
+ targetState = STATE_EXPANDED;
+ } else if (Math.abs(releasedChild.getTop() - getExpandedOffset())
+ < Math.abs(releasedChild.getTop() - halfExpandedOffset)) {
+ targetState = STATE_EXPANDED;
+ } else {
+ 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 currentTop = releasedChild.getTop();
+ if (fitToContents) {
+ if (Math.abs(currentTop - fitToContentsOffset)
+ < Math.abs(currentTop - collapsedOffset)) {
+ targetState = STATE_EXPANDED;
+ } else {
+ targetState = STATE_COLLAPSED;
+ }
+ } else {
+ if (currentTop < halfExpandedOffset) {
+ if (currentTop < Math.abs(currentTop - collapsedOffset)) {
+ targetState = STATE_EXPANDED;
+ } else {
+ if (shouldSkipHalfExpandedStateWhenDragging()) {
+ targetState = STATE_COLLAPSED;
+ } else {
+ targetState = STATE_HALF_EXPANDED;
+ }
+ }
+ } else {
+ if (Math.abs(currentTop - halfExpandedOffset)
+ < Math.abs(currentTop - collapsedOffset)) {
+ if (shouldSkipHalfExpandedStateWhenDragging()) {
+ targetState = STATE_COLLAPSED;
+ } else {
+ targetState = STATE_HALF_EXPANDED;
+ }
+ } else {
+ targetState = STATE_COLLAPSED;
+ }
+ }
+ }
+ } else { // Moving Down
+ if (fitToContents) {
+ targetState = STATE_COLLAPSED;
+ } else {
+ // Settle to the nearest correct height.
+ int currentTop = releasedChild.getTop();
+ if (Math.abs(currentTop - halfExpandedOffset)
+ < Math.abs(currentTop - collapsedOffset)) {
+ if (shouldSkipHalfExpandedStateWhenDragging()) {
+ targetState = STATE_COLLAPSED;
+ } else {
+ targetState = STATE_HALF_EXPANDED;
+ }
+ } else {
+ targetState = STATE_COLLAPSED;
+ }
+ }
+ }
+ startSettling(releasedChild, targetState, shouldSkipSmoothAnimation());
+ }
+
+ @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()) {
+ // MODIFICATION: Add calculateSlideOffset method
+ float slideOffset = calculateSlideOffset(top);
+ 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 occurring during testing.
+ interpolatorAnimator = null;
+ }
+
+ /**
+ * Checks weather a nested scroll should be enabled. If {@code false} all nested scrolls will be
+ * consumed by the bottomSheet.
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP)
+ public boolean isNestedScrollingCheckEnabled() {
+ return true;
+ }
+
+ /**
+ * Checks weather half expended state should be skipped when drag is ended. If {@code true}, the
+ * bottomSheet will go to the next closest state.
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP)
+ public boolean shouldSkipHalfExpandedStateWhenDragging() {
+ return false;
+ }
+
+ /**
+ * Checks whether an animation should be smooth after the bottomSheet is released after dragging.
+ *
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP)
+ public boolean shouldSkipSmoothAnimation() {
+ return true;
+ }
+
+ /**
+ * Checks whether the bottom sheet should be expanded after it has been released after dragging.
+ *
+ * @param dragDurationMillis how long the bottom sheet was dragged.
+ * @param yPositionPercentage position of the bottom sheet when released after dragging. Lower
+ * values mean that view was released closer to the top of the screen.
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP)
+ public boolean shouldExpandOnUpwardDrag(
+ long dragDurationMillis, @FloatRange(from = 0.0f, to = 100.0f) float yPositionPercentage) {
+ return false;
+ }
+
+ /**
+ * Sets whether this bottom sheet can hide when it is swiped down.
+ *
+ * @param hideable {@code true} to make this bottom sheet hideable.
+ * @hide
+ */
+ @RestrictTo(LIBRARY_GROUP)
+ public void setHideableInternal(boolean hideable) {
+ this.hideable = hideable;
+ }
+
+ /**
+ * Gets the last stable state of the bottom sheet.
+ *
+ * @return One of {@link #STATE_EXPANDED}, {@link #STATE_HALF_EXPANDED}, {@link #STATE_COLLAPSED},
+ * {@link #STATE_HIDDEN}.
+ * @hide
+ */
+ @State
+ @RestrictTo(LIBRARY_GROUP)
+ public int getLastStableState() {
+ return lastStableState;
+ }
+
+ private class StateSettlingTracker {
+ @State private int targetState;
+ private boolean isContinueSettlingRunnablePosted;
+
+ private final Runnable continueSettlingRunnable =
+ new Runnable() {
+ @Override
+ public void run() {
+ isContinueSettlingRunnablePosted = false;
+ if (viewDragHelper != null && viewDragHelper.continueSettling(true)) {
+ continueSettlingToState(targetState);
+ } else if (state == STATE_SETTLING) {
+ setStateInternal(targetState);
+ }
+ // In other cases, settling has been interrupted by certain UX interactions. Do nothing.
+ }
+ };
+
+ void continueSettlingToState(@State int targetState) {
+ if (viewRef == null || viewRef.get() == null) {
+ return;
+ }
+ this.targetState = targetState;
+ if (!isContinueSettlingRunnablePosted) {
+ ViewCompat.postOnAnimation(viewRef.get(), continueSettlingRunnable);
+ isContinueSettlingRunnablePosted = true;
+ }
+ }
+ }
+
+ /** 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 NeoBottomSheetBehavior> 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 NeoBottomSheetBehavior#SAVE_PEEK_HEIGHT}, {@link
+ * NeoBottomSheetBehavior#SAVE_FIT_TO_CONTENTS}, {@link NeoBottomSheetBehavior#SAVE_HIDEABLE}, {@link
+ * NeoBottomSheetBehavior#SAVE_SKIP_COLLAPSED}. It is as if {@link NeoBottomSheetBehavior#SAVE_NONE}
+ * were set.
+ *
+ * @deprecated Use {@link #SavedState(Parcelable, NeoBottomSheetBehavior)} instead.
+ */
+ @Deprecated
+ public SavedState(Parcelable superstate, @State 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 CREATOR =
+ new ClassLoaderCreator() {
+ @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 NeoBottomSheetBehavior} associated with the {@code view}.
+ *
+ * @param view The {@link View} with {@link NeoBottomSheetBehavior}.
+ * @return The {@link NeoBottomSheetBehavior} associated with the {@code view}.
+ */
+ @NonNull
+ @SuppressWarnings("unchecked")
+ public static NeoBottomSheetBehavior 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 NeoBottomSheetBehavior)) {
+ throw new IllegalArgumentException("The view is not associated with BottomSheetBehavior");
+ }
+ return (NeoBottomSheetBehavior) behavior;
+ }
+
+ /**
+ * Sets whether the BottomSheet should update the accessibility status of its {@link
+ * CoordinatorLayout} siblings when expanded.
+ *
+ * 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 ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) && 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;
+ } else if (updateImportantForAccessibilityOnSiblings) {
+ // If the siblings of the bottom sheet have been set to not important for a11y, move the focus
+ // to the bottom sheet when expanded.
+ viewRef.get().sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
+ }
+ }
+
+ 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 (expandHalfwayActionId != View.NO_ID) {
+ ViewCompat.removeAccessibilityAction(child, expandHalfwayActionId);
+ }
+ if (!fitToContents && state != STATE_HALF_EXPANDED) {
+ expandHalfwayActionId =
+ addAccessibilityActionForState(
+ child, R.string.bottomsheet_action_expand_halfway, STATE_HALF_EXPANDED);
+ }
+
+ if (hideable && state != STATE_HIDDEN) {
+ replaceAccessibilityActionForState(
+ child, AccessibilityActionCompat.ACTION_DISMISS, STATE_HIDDEN);
+ }
+
+ switch (state) {
+ case STATE_EXPANDED:
+ {
+ int nextState = fitToContents ? STATE_COLLAPSED : STATE_HALF_EXPANDED;
+ replaceAccessibilityActionForState(
+ child, AccessibilityActionCompat.ACTION_COLLAPSE, nextState);
+ break;
+ }
+ case STATE_HALF_EXPANDED:
+ {
+ replaceAccessibilityActionForState(
+ child, AccessibilityActionCompat.ACTION_COLLAPSE, STATE_COLLAPSED);
+ replaceAccessibilityActionForState(
+ child, AccessibilityActionCompat.ACTION_EXPAND, STATE_EXPANDED);
+ break;
+ }
+ case STATE_COLLAPSED:
+ {
+ int nextState = fitToContents ? STATE_EXPANDED : STATE_HALF_EXPANDED;
+ replaceAccessibilityActionForState(
+ child, AccessibilityActionCompat.ACTION_EXPAND, nextState);
+ break;
+ }
+ default: // fall out
+ }
+ }
+
+ private void replaceAccessibilityActionForState(
+ V child, AccessibilityActionCompat action, @State int state) {
+ ViewCompat.replaceAccessibilityAction(
+ child, action, null, createAccessibilityViewCommandForState(state));
+ }
+
+ private int addAccessibilityActionForState(
+ V child, @StringRes int stringResId, @State int state) {
+ return ViewCompat.addAccessibilityAction(
+ child,
+ child.getResources().getString(stringResId),
+ createAccessibilityViewCommandForState(state));
+ }
+
+ private AccessibilityViewCommand createAccessibilityViewCommandForState(@State final int state) {
+ return new AccessibilityViewCommand() {
+ @Override
+ public boolean perform(@NonNull View view, @Nullable CommandArguments arguments) {
+ setState(state);
+ return true;
+ }
+ };
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
index ac6557bc7..97a7dba43 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
@@ -17,18 +17,23 @@
package org.oxycblt.auxio
-import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
+import android.view.WindowInsets
import androidx.activity.OnBackPressedCallback
+import androidx.core.view.isInvisible
import androidx.fragment.app.activityViewModels
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
+import com.google.android.material.bottomsheet.NeoBottomSheetBehavior
import com.google.android.material.transition.MaterialFadeThrough
+import kotlin.math.max
+import kotlin.math.min
import org.oxycblt.auxio.databinding.FragmentMainBinding
import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.playback.PlaybackSheetBehavior
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.ui.MainNavigationAction
import org.oxycblt.auxio.ui.NavigationViewModel
@@ -36,6 +41,7 @@ import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collect
import org.oxycblt.auxio.util.collectImmediately
+import org.oxycblt.auxio.util.coordinatorLayoutBehavior
/**
* A wrapper around the home fragment that shows the playback fragment and controls the more
@@ -46,6 +52,7 @@ class MainFragment : ViewBindingFragment() {
private val playbackModel: PlaybackViewModel by androidActivityViewModels()
private val navModel: NavigationViewModel by activityViewModels()
private var callback: DynamicBackPressedCallback? = null
+ private var lastInsets: WindowInsets? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -61,19 +68,25 @@ class MainFragment : ViewBindingFragment() {
.onBackPressedDispatcher.addCallback(
viewLifecycleOwner, DynamicBackPressedCallback().also { callback = it })
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
- // Auxio's layout completely breaks down when it's window is resized too small,
- // but for some insane reason google decided to cripple the window APIs one could use
- // to limit it's size. So, we just have our own special layout that is shown whenever
- // the screen is too small because of course we have to.
- if (requireActivity().isInMultiWindowMode) {
- val config = resources.configuration
- if (config.screenHeightDp < 250 || config.screenWidthDp < 250) {
- binding.layoutTooSmall.visibility = View.VISIBLE
- }
- }
+ binding.root.setOnApplyWindowInsetsListener { v, insets ->
+ lastInsets = insets
+ insets
}
+ val playbackSheetBehavior =
+ binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
+
+ playbackSheetBehavior.addBottomSheetCallback(
+ object : NeoBottomSheetBehavior.BottomSheetCallback() {
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {
+ handleSheetTransitions()
+ }
+
+ override fun onStateChanged(bottomSheet: View, newState: Int) {}
+ })
+
+ binding.root.post { handleSheetTransitions() }
+
// --- VIEWMODEL SETUP ---
collect(navModel.mainNavigationAction, ::handleMainNavigation)
@@ -91,13 +104,51 @@ class MainFragment : ViewBindingFragment() {
callback?.isEnabled = false
}
+ private fun handleSheetTransitions() {
+ val binding = requireBinding()
+ val playbackSheetBehavior =
+ binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
+
+ val playbackRatio = max(playbackSheetBehavior.calculateSlideOffset(), 0f)
+ val queueRatio = 0f
+
+ val outRatio = 1 - playbackRatio
+ val halfOutRatio = min(playbackRatio * 2, 1f)
+ val halfInPlaybackRatio = max(playbackRatio - 0.5f, 0f) * 2
+ val halfOutQueueRatio = min(queueRatio * 2, 1f)
+ val halfInQueueRatio = max(queueRatio - 0.5f, 0f) * 2
+
+ playbackSheetBehavior.sheetBackgroundDrawable.alpha = (outRatio * 255).toInt()
+ binding.playbackSheet.translationZ = 3f * outRatio
+ binding.playbackPanelFragment.alpha = min(halfInPlaybackRatio, 1 - halfOutQueueRatio)
+ // binding.queueRecycler.alpha = max(queueOffset, 0f)
+
+ binding.exploreNavHost.apply {
+ alpha = outRatio
+ isInvisible = alpha == 0f
+ }
+
+ binding.playbackBarFragment.apply {
+ alpha = max(1 - halfOutRatio, halfInQueueRatio)
+ lastInsets?.let { translationY = it.systemWindowInsetTop * halfOutRatio }
+ }
+ }
+
private fun handleMainNavigation(action: MainNavigationAction?) {
if (action == null) return
val binding = requireBinding()
when (action) {
- is MainNavigationAction.Expand -> binding.bottomSheetLayout.expand()
- is MainNavigationAction.Collapse -> binding.bottomSheetLayout.collapse()
+ is MainNavigationAction.Expand -> {
+ val playbackSheetBehavior =
+ binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
+ playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_EXPANDED
+ }
+ is MainNavigationAction.Collapse -> {
+ val playbackSheetBehavior =
+ binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
+ playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
+ }
is MainNavigationAction.Settings ->
findNavController().navigate(MainFragmentDirections.actionShowSettings())
is MainNavigationAction.About ->
@@ -112,18 +163,24 @@ class MainFragment : ViewBindingFragment() {
private fun handleExploreNavigation(item: Music?) {
if (item != null) {
- requireBinding().bottomSheetLayout.collapse()
+ val binding = requireBinding()
+ val playbackSheetBehavior =
+ binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
+
+ if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
+ playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
+ }
}
}
private fun updateSong(song: Song?) {
val binding = requireBinding()
+ val playbackSheetBehavior =
+ binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
if (song != null) {
- binding.bottomSheetLayout.isDraggable = true
- binding.bottomSheetLayout.show()
+ playbackSheetBehavior.unsideSafe()
} else {
- binding.bottomSheetLayout.isDraggable = false
- binding.bottomSheetLayout.hide()
+ playbackSheetBehavior.hideSafe()
}
}
@@ -136,7 +193,11 @@ class MainFragment : ViewBindingFragment() {
inner class DynamicBackPressedCallback : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
val binding = requireBinding()
- if (!binding.bottomSheetLayout.collapse()) {
+ val playbackSheetBehavior =
+ binding.playbackSheet.coordinatorLayoutBehavior as PlaybackSheetBehavior
+ if (playbackSheetBehavior.state == NeoBottomSheetBehavior.STATE_EXPANDED) {
+ playbackSheetBehavior.state = NeoBottomSheetBehavior.STATE_COLLAPSED
+ } else {
val navController = binding.exploreNavHost.findNavController()
if (navController.currentDestination?.id ==
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/BottomSheetLayout.kt b/app/src/main/java/org/oxycblt/auxio/playback/BottomSheetLayout.kt
deleted file mode 100644
index 0c754d70e..000000000
--- a/app/src/main/java/org/oxycblt/auxio/playback/BottomSheetLayout.kt
+++ /dev/null
@@ -1,670 +0,0 @@
-/*
- * Copyright (c) 2022 Auxio Project
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.oxycblt.auxio.playback
-
-import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Rect
-import android.graphics.drawable.LayerDrawable
-import android.os.Bundle
-import android.os.Parcelable
-import android.util.AttributeSet
-import android.view.Gravity
-import android.view.MotionEvent
-import android.view.View
-import android.view.ViewGroup
-import android.view.WindowInsets
-import android.view.accessibility.AccessibilityEvent
-import android.widget.FrameLayout
-import androidx.core.view.isInvisible
-import androidx.customview.widget.ViewDragHelper
-import com.google.android.material.shape.MaterialShapeDrawable
-import java.lang.reflect.Field
-import kotlin.math.abs
-import kotlin.math.max
-import kotlin.math.min
-import org.oxycblt.auxio.BuildConfig
-import org.oxycblt.auxio.R
-import org.oxycblt.auxio.util.disableDropShadowCompat
-import org.oxycblt.auxio.util.getAttrColorSafe
-import org.oxycblt.auxio.util.getDimenSafe
-import org.oxycblt.auxio.util.isUnder
-import org.oxycblt.auxio.util.lazyReflectedField
-import org.oxycblt.auxio.util.logD
-import org.oxycblt.auxio.util.pxOfDp
-import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
-import org.oxycblt.auxio.util.stateList
-import org.oxycblt.auxio.util.systemBarInsetsCompat
-
-/**
- * A layout that *properly* handles bottom sheet functionality.
- *
- * BottomSheetBehavior has a multitude of shortcomings based that make it a non-starter for Auxio,
- * such as:
- * - God-awful edge-to-edge support
- * - Does not allow other content to adapt
- * - Extreme jank
- * - Terrible APIs that you have to use just to make the UX tolerable
- * - Inexplicable layout and measuring inconsistencies
- * - Reliance on CoordinatorLayout, which is just a terrible component in general and everyone
- * responsible for creating it should be publicly shamed
- *
- * So, I decided to make my own implementation. With blackjack, and referential humor.
- *
- * The actual internals of this view are based off of a blend of Hai Zhang's PersistentBarLayout and
- * Umano's SlidingUpPanelLayout, albeit heavily minified to remove extraneous use cases and updated
- * to support the latest SDK level and androidx tools.
- *
- * What is hilarious is that Google now hates CoordinatorLayout and it's behavior implementations as
- * much as I do. Just look at all the new boring layout implementations they are introducing like
- * SlidingPaneLayout. It's almost like re-inventing the layout process but buggier and without
- * access to other children in the ViewGroup was a bad idea. Whoa.
- *
- * **Note:** If you want to adapt this layout into your own app. Good luck. This layout has been
- * reduced to Auxio's use case in particular and is really hard to understand since it has a ton of
- * state and view magic. I tried my best to document it, but it's probably not the most friendly or
- * extendable. You have been warned.
- *
- * @author OxygenCobalt (With help from Umano and Hai Zhang)
- */
-class BottomSheetLayout
-@JvmOverloads
-constructor(context: Context, attrs: AttributeSet? = null, defStyle: Int = 0) :
- ViewGroup(context, attrs, defStyle) {
- private enum class State {
- EXPANDED,
- COLLAPSED,
- HIDDEN,
- DRAGGING
- }
-
- // Core views [obtained when layout is inflated]
- private lateinit var contentView: View
- private lateinit var barView: View
- private lateinit var panelView: View
-
- private val elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
-
- // We have to define the background before the bottom sheet declaration as otherwise it wont
- // work
- private val sheetBackground =
- MaterialShapeDrawable.createWithElevationOverlay(context).apply {
- fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
- elevation = context.pxOfDp(elevationNormal).toFloat()
- }
-
- private val sheetView =
- FrameLayout(context).apply {
- id = R.id.bottom_sheet_view
-
- isClickable = true
- isFocusable = false
- isFocusableInTouchMode = false
-
- // The way we fade out the elevation overlay is not by actually reducing the
- // elevation but by fading out the background drawable itself. To be safe,
- // we apply this background drawable to a layer list with another colorSurface
- // shape drawable, just in case weird things happen if background drawable is
- // completely transparent.
- val fallbackBackground =
- MaterialShapeDrawable().apply {
- fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
- }
-
- background = LayerDrawable(arrayOf(fallbackBackground, sheetBackground))
-
- disableDropShadowCompat()
- }
-
- /** The drag helper that animates and dispatches drag events to the bottom sheet. */
- private val dragHelper =
- ViewDragHelper.create(this, DragHelperCallback()).apply {
- minVelocity = MIN_FLING_VEL * resources.displayMetrics.density
- }
-
- /**
- * The current window insets. Important since this layout must play a long with Auxio's
- * edge-to-edge functionality.
- */
- private var lastInsets: WindowInsets? = null
-
- /** The current bottom sheet state. Can be [State.DRAGGING] */
- private var state = INIT_SHEET_STATE
-
- /** The last bottom sheet state before a drag event began. */
- private var lastIdleState = INIT_SHEET_STATE
-
- /** The range of pixels that the bottom sheet can drag through */
- private var sheetRange = 0
-
- /**
- * The relative offset of this bottom sheet as a percentage of [sheetRange]. A value of 1 means
- * a fully expanded sheet. A value of 0 means a collapsed sheet. A value below 0 means a hidden
- * sheet.
- */
- private var sheetOffset = 0f
-
- // Miscellaneous touch things
- private var initMotionX = 0f
- private var initMotionY = 0f
- private val tRect = Rect()
-
- var isDraggable = false
-
- init {
- setWillNotDraw(false)
- }
-
- // / --- CONTROL METHODS ---
-
- /**
- * Show the bottom sheet, only if it's hidden.
- * @return if the sheet was shown
- */
- fun show(): Boolean {
- if (state == State.HIDDEN) {
- applyState(State.COLLAPSED)
- return true
- }
-
- return false
- }
-
- /**
- * Expand the bottom sheet if it is currently collapsed.
- * @return If the sheet was expanded
- */
- fun expand(): Boolean {
- if (state == State.COLLAPSED) {
- applyState(State.EXPANDED)
- return true
- }
-
- return false
- }
-
- /**
- * Collapse the sheet if it is currently expanded.
- * @return If the sheet was collapsed
- */
- fun collapse(): Boolean {
- if (state == State.EXPANDED) {
- applyState(State.COLLAPSED)
- return true
- }
-
- return false
- }
-
- /**
- * Hide the sheet if it is not hidden.
- * @return If the sheet was hidden
- */
- fun hide(): Boolean {
- if (state != State.HIDDEN) {
- applyState(State.HIDDEN)
- return true
- }
-
- return false
- }
-
- private fun applyState(newState: State) {
- logD("Applying bottom sheet state $newState")
-
- // Dragging events are really complex and we don't want to mess up the state
- // while we are in one.
- if (newState == this.state) {
- return
- }
-
- if (!isLaidOut) {
- // Not laid out, just apply the state and let the measure + layout steps apply it for
- // us.
- setSheetStateInternal(newState)
- } else {
- // We are laid out. In this case we actually animate to our desired target.
- when (newState) {
- State.COLLAPSED -> smoothSlideTo(0f)
- State.EXPANDED -> smoothSlideTo(1.0f)
- State.HIDDEN -> smoothSlideTo(calculateSheetOffset(measuredHeight))
- else -> {}
- }
- }
- }
-
- override fun onFinishInflate() {
- super.onFinishInflate()
-
- contentView = getChildAt(0) // Child 1 is assumed to be the content
- barView = getChildAt(1) // Child 2 is assumed to be the bar used when collapsed
- panelView = getChildAt(2) // Child 3 is assumed to be the panel used when expanded
-
- // We actually move the bar and panel views into a container so that they have consistent
- // behavior when be manipulate layouts later.
- removeView(barView)
- removeView(panelView)
-
- sheetView.apply {
- addView(
- barView,
- FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
- .apply { gravity = Gravity.TOP })
-
- addView(
- panelView,
- FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
- .apply { gravity = Gravity.CENTER })
- }
-
- addView(sheetView)
- }
-
- override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
- super.onMeasure(widthMeasureSpec, heightMeasureSpec)
-
- // Sanity check. The last thing I want to deal with is this view being WRAP_CONTENT.
- val widthMode = MeasureSpec.getMode(widthMeasureSpec)
- val heightMode = MeasureSpec.getMode(heightMeasureSpec)
-
- check(widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
- "This view must be MATCH_PARENT"
- }
-
- val widthSize = MeasureSpec.getSize(widthMeasureSpec)
- val heightSize = MeasureSpec.getSize(heightMeasureSpec)
- setMeasuredDimension(widthSize, heightSize)
-
- // First measure our actual bottom sheet. We need to do this first to determine our
- // range and offset values.
- val sheetWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
- val sheetHeightSpec = MeasureSpec.makeMeasureSpec(measuredHeight, MeasureSpec.EXACTLY)
- sheetView.measure(sheetWidthSpec, sheetHeightSpec)
-
- sheetRange = measuredHeight - barView.measuredHeight
-
- if (!isLaidOut) {
- logD("Doing initial bottom sheet layout")
-
- // This is our first layout, so make sure we know what offset we should work with
- // before we measure our content
- sheetOffset =
- when (state) {
- State.EXPANDED -> 1f
- State.HIDDEN -> calculateSheetOffset(measuredHeight)
- else -> 0f
- }
-
- updateBottomSheetTransition()
- }
-
- applyContentWindowInsets()
- measureContent()
- }
-
- private fun measureContent() {
- // We need to find out how much the panel should affect the view.
- // When the panel is in it's bar form, we shorten the content view. If it's being expanded,
- // we keep the same height and just overlay the panel.
- val barHeightAdjusted = measuredHeight - (calculateSheetTopPosition(min(sheetOffset, 0f)))
-
- // Note that these views will always be a fixed MATCH_PARENT. This is intentional,
- // as it reduces the logic we have to deal with regarding WRAP_CONTENT views.
- val contentWidthSpec = MeasureSpec.makeMeasureSpec(measuredWidth, MeasureSpec.EXACTLY)
- val contentHeightSpec =
- MeasureSpec.makeMeasureSpec(measuredHeight - barHeightAdjusted, MeasureSpec.EXACTLY)
-
- contentView.measure(contentWidthSpec, contentHeightSpec)
- }
-
- override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
- // Figure out where our panel should be and lay it out there.
- val panelTop = calculateSheetTopPosition(sheetOffset)
- sheetView.layout(0, panelTop, sheetView.measuredWidth, sheetView.measuredHeight + panelTop)
- layoutContent()
- }
-
- private fun layoutContent() {
- // We already did our magic while measuring. No need to do anything here.
- contentView.layout(0, 0, contentView.measuredWidth, contentView.measuredHeight)
- }
-
- override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
- val save = canvas.save()
-
- // Drawing views that are under the bottom sheet is inefficient, clip the canvas
- // so that doesn't occur.
- if (child == contentView) {
- canvas.getClipBounds(tRect)
- tRect.bottom = tRect.bottom.coerceAtMost(sheetView.top)
- canvas.clipRect(tRect)
- }
-
- return super.drawChild(canvas, child, drawingTime).also { canvas.restoreToCount(save) }
- }
-
- override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets {
- // One issue with handling a bottom bar with edge-to-edge is that if you want to
- // apply window insets to a view, those insets will cause incorrect spacing if the
- // bottom navigation is consumed by a bar. To fix this, we modify the bottom insets
- // to reflect the presence of the bottom sheet [at least in it's collapsed state]
- sheetView.dispatchApplyWindowInsets(insets)
- lastInsets = insets
- applyContentWindowInsets()
- return insets
- }
-
- /**
- * Apply window insets to the content views in this layouts. This is done separately as at times
- * we want to re-inset the content views but not re-inset the bar view.
- */
- private fun applyContentWindowInsets() {
- val insets = lastInsets
- if (insets != null) {
- contentView.dispatchApplyWindowInsets(adjustInsets(insets))
- }
- }
-
- /** Adjust window insets to line up with the bottom sheet */
- private fun adjustInsets(insets: WindowInsets): WindowInsets {
- // We kind of do a reverse-measure to figure out how we should inset this view.
- // Find how much space is lost by the panel and then combine that with the
- // bottom inset to find how much space we should apply.
- // There is a slight shortcoming to this. If the playback bar has a height of
- // zero (usually due to delays with fragment inflation), then it is assumed to
- // not apply any window insets at all, which results in scroll desynchronization on
- // certain views. This is considered tolerable as the other options are to convert
- // the playback fragments to views, which is not nice.
- val bars = insets.systemBarInsetsCompat
- val consumedByPanel = calculateSheetTopPosition(sheetOffset) - measuredHeight
- val adjustedBottomInset = (consumedByPanel + bars.bottom).coerceAtLeast(0)
- return insets.replaceSystemBarInsetsCompat(
- bars.left, bars.top, bars.right, adjustedBottomInset)
- }
-
- override fun onSaveInstanceState(): Parcelable =
- Bundle().apply {
- putParcelable("superState", super.onSaveInstanceState())
- putSerializable(
- KEY_SHEET_STATE,
- if (state != State.DRAGGING) {
- state
- } else {
- lastIdleState
- })
- }
-
- override fun onRestoreInstanceState(savedState: Parcelable) {
- if (savedState is Bundle) {
- this.state = savedState.getSerializable(KEY_SHEET_STATE) as? State ?: INIT_SHEET_STATE
- super.onRestoreInstanceState(savedState.getParcelable("superState"))
- } else {
- super.onRestoreInstanceState(savedState)
- }
- }
-
- @Suppress("Redundant")
- override fun performClick(): Boolean {
- return super.performClick()
- }
-
- override fun onTouchEvent(ev: MotionEvent): Boolean {
- performClick()
-
- return if (!isDraggable) {
- super.onTouchEvent(ev)
- } else
- try {
- dragHelper.processTouchEvent(ev)
- true
- } catch (ex: Exception) {
- // Ignore the pointer out of range exception
- false
- }
- }
-
- override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
- if (!isDraggable) {
- return super.onInterceptTouchEvent(ev)
- }
-
- when (ev.actionMasked) {
- MotionEvent.ACTION_DOWN -> {
- initMotionX = ev.x
- initMotionY = ev.y
-
- if (!sheetView.isUnder(ev.x, ev.y)) {
- // Pointer is not on our view, do not intercept this event
- dragHelper.cancel()
- return false
- }
- }
- MotionEvent.ACTION_MOVE -> {
- val adx = abs(ev.x - initMotionX)
- val ady = abs(ev.y - initMotionY)
-
- val pointerUnder = sheetView.isUnder(ev.x, ev.y)
- val motionUnder = sheetView.isUnder(initMotionX, initMotionY)
-
- if (!(pointerUnder || motionUnder) || ady > dragHelper.touchSlop && adx > ady) {
- // Pointer has moved beyond our control, do not intercept this event
- dragHelper.cancel()
- return false
- }
- }
- MotionEvent.ACTION_CANCEL,
- MotionEvent.ACTION_UP ->
- if (dragHelper.isDragging) {
- // Stopped pressing while we were dragging, let the drag helper handle it
- dragHelper.processTouchEvent(ev)
- return true
- }
- }
-
- return dragHelper.shouldInterceptTouchEvent(ev)
- }
-
- override fun computeScroll() {
- // Make sure that we continue settling as we scroll
- if (dragHelper.continueSettling(true)) {
- postInvalidateOnAnimation()
- }
- }
-
- private val ViewDragHelper.isDragging: Boolean
- get() {
- // We can't grab the drag state outside of a callback, but that's stupid and I don't
- // want to vendor ViewDragHelper so I just do reflection instead.
- val state =
- try {
- VIEW_DRAG_HELPER_STATE_FIELD.get(this)
- } catch (e: Exception) {
- ViewDragHelper.STATE_IDLE
- }
-
- return state == ViewDragHelper.STATE_DRAGGING
- }
-
- private fun setSheetStateInternal(newState: State) {
- if (this.state == newState) {
- return
- }
-
- logD("New state: $newState")
- this.state = newState
-
- // TODO: Improve accessibility by:
- // 1. Adding a (non-visible) handle. Material components now technically does have
- // this, but it relies on the busted BottomSheetBehavior.
- // 2. Adding the controls that BottomSheetBehavior defines
- sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)
- }
-
- /**
- * Do the nice view animations that occur whenever we slide up the bottom sheet. The way we
- * transition is largely inspired by Android 12's notification panel, with the compact view
- * fading out completely before the panel view fades in.
- */
- private fun updateBottomSheetTransition() {
- val ratio = max(sheetOffset, 0f)
-
- val outRatio = 1 - ratio
- val halfOutRatio = min(ratio * 2, 1f)
- val halfInRatio = max(ratio - 0.5f, 0f) * 2
-
- contentView.apply {
- alpha = outRatio
- isInvisible = alpha == 0f
- }
-
- // Slowly reduce the elevation of the bottom sheet as we slide up, eventually resulting in a
- // neutral color instead of an elevated one when fully expanded.
- sheetBackground.alpha = (outRatio * 255).toInt()
- sheetView.translationZ = elevationNormal * outRatio
-
- // Fade out our bar view as we slide up
- barView.apply {
- alpha = min(1 - halfOutRatio, 1f)
- isInvisible = alpha == 0f
-
- // When edge-to-edge is enabled, we want to make the bar move along with the top
- // window insets as it goes upwards. Do this by progressively modifying the y
- // translation with a fraction of the said inset.
- lastInsets?.let { insets ->
- val bars = insets.systemBarInsetsCompat
- translationY = bars.top * halfOutRatio
- }
- }
-
- // Fade in our panel as we slide up
- panelView.apply {
- alpha = halfInRatio
- isInvisible = alpha == 0f
- }
- }
-
- private fun calculateSheetTopPosition(sheetOffset: Float): Int =
- measuredHeight - barView.measuredHeight - (sheetOffset * sheetRange).toInt()
-
- private fun calculateSheetOffset(top: Int): Float =
- (calculateSheetTopPosition(0f) - top).toFloat() / sheetRange
-
- private fun smoothSlideTo(offset: Float) {
- logD("Smooth sliding to $offset")
-
- if (dragHelper.smoothSlideViewTo(
- sheetView, sheetView.left, calculateSheetTopPosition(offset))) {
- // Slide has started, begin animating
- postInvalidateOnAnimation()
- }
- }
-
- private inner class DragHelperCallback : ViewDragHelper.Callback() {
- // Only capture on a fully shown panel view
- override fun tryCaptureView(child: View, pointerId: Int) =
- child === sheetView && sheetOffset >= 0
-
- override fun onViewDragStateChanged(dragState: Int) {
- when (dragState) {
- ViewDragHelper.STATE_DRAGGING -> {
- if (!isDraggable) {
- return
- }
-
- // We're dragging, so we need to update our state accordingly
- if (this@BottomSheetLayout.state != State.DRAGGING) {
- lastIdleState = this@BottomSheetLayout.state
- }
-
- setSheetStateInternal(State.DRAGGING)
- }
- ViewDragHelper.STATE_IDLE -> {
- sheetOffset = calculateSheetOffset(sheetView.top)
-
- val newState =
- when {
- sheetOffset == 1f -> State.EXPANDED
- sheetOffset == 0f -> State.COLLAPSED
- sheetOffset < 0f -> State.HIDDEN
- else -> State.EXPANDED
- }
-
- setSheetStateInternal(newState)
- }
- }
- }
-
- override fun onViewCaptured(capturedChild: View, activePointerId: Int) {}
-
- override fun onViewPositionChanged(
- changedView: View,
- left: Int,
- top: Int,
- dx: Int,
- dy: Int
- ) {
- // Update our sheet offset using the new top value
- sheetOffset = calculateSheetOffset(top)
- if (sheetOffset < 0) {
- // If we are hiding/showing the sheet, make sure we relayout our content too.
- applyContentWindowInsets()
- measureContent()
- layoutContent()
- }
-
- updateBottomSheetTransition()
- invalidate()
- }
-
- override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
- val newOffset =
- when {
- // Swipe Up -> Expand to top
- yvel < 0 -> 1f
- // Swipe down -> Collapse to bottom
- yvel > 0 -> 0f
- // No velocity, far enough from middle to expand to top
- sheetOffset >= 0.5f -> 1f
- // Collapse to bottom
- else -> 0f
- }
-
- dragHelper.settleCapturedViewAt(
- releasedChild.left, calculateSheetTopPosition(newOffset))
-
- invalidate()
- }
-
- override fun getViewVerticalDragRange(child: View) = sheetRange
-
- override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
- val collapsedTop = calculateSheetTopPosition(0f)
- val expandedTop = calculateSheetTopPosition(1.0f)
- return top.coerceAtLeast(expandedTop).coerceAtMost(collapsedTop)
- }
- }
-
- companion object {
- private val INIT_SHEET_STATE = State.HIDDEN
- private val VIEW_DRAG_HELPER_STATE_FIELD: Field by
- lazyReflectedField(ViewDragHelper::class, "mDragState")
-
- private const val MIN_FLING_VEL = 400
- private const val KEY_SHEET_STATE = BuildConfig.APPLICATION_ID + ".key.BOTTOM_SHEET_STATE"
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt
index cba4a4532..6fa4da716 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarFragment.kt
@@ -19,7 +19,6 @@ package org.oxycblt.auxio.playback
import android.os.Bundle
import android.view.LayoutInflater
-import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import kotlin.math.max
import org.oxycblt.auxio.R
@@ -31,8 +30,6 @@ import org.oxycblt.auxio.ui.fragment.ViewBindingFragment
import org.oxycblt.auxio.util.androidActivityViewModels
import org.oxycblt.auxio.util.collectImmediately
import org.oxycblt.auxio.util.getColorStateListSafe
-import org.oxycblt.auxio.util.systemBarInsetsCompat
-import org.oxycblt.auxio.util.systemGestureInsetsCompat
import org.oxycblt.auxio.util.textSafe
/**
@@ -58,17 +55,6 @@ class PlaybackBarFragment : ViewBindingFragment() {
playbackModel.song.value?.let(navModel::exploreNavigateTo)
true
}
-
- setOnApplyWindowInsetsListener { view, insets ->
- // Since we swipe up this view, we need to make sure it does not collide with
- // any gesture events. So, apply the system gesture insets if present as long
- // as they are *larger* than the bar insets. This is to resolve issues where
- // the gesture insets are not sane on OEM devices.
- val bars = insets.systemBarInsetsCompat
- val gestures = insets.systemGestureInsetsCompat
- view.updatePadding(bottom = max(bars.bottom, gestures.bottom))
- insets
- }
}
// Load the track color in manually as it's unclear whether the track actually supports
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSheetBehavior.kt
new file mode 100644
index 000000000..6cedefe30
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackSheetBehavior.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2022 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.playback
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowInsets
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import org.oxycblt.auxio.ui.AuxioSheetBehavior
+import org.oxycblt.auxio.util.logD
+
+class PlaybackSheetBehavior(context: Context, attributeSet: AttributeSet?) :
+ AuxioSheetBehavior(context, attributeSet) {
+ private var lastInsets: WindowInsets? = null
+
+ // Hack around issue where the playback sheet will try to intercept nested scrolling events
+ // before the queue sheet.
+ override fun onInterceptTouchEvent(
+ parent: CoordinatorLayout,
+ child: V,
+ event: MotionEvent
+ ): Boolean = super.onInterceptTouchEvent(parent, child, event) && state != STATE_EXPANDED
+
+ override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
+ val success = super.onLayoutChild(parent, child, layoutDirection)
+
+ (child as ViewGroup).apply {
+ setOnApplyWindowInsetsListener { v, insets ->
+ lastInsets = insets
+ peekHeight = getChildAt(0).measuredHeight + insets.systemGestureInsets.bottom
+ insets
+ }
+ }
+
+ return success
+ }
+
+ fun hideSafe() {
+ isDraggable = false
+ isHideable = true
+ state = STATE_HIDDEN
+ }
+
+ fun unsideSafe() {
+ isHideable = false
+ isDraggable = true
+ logD(state)
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/AuxioSheetBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/AuxioSheetBehavior.kt
new file mode 100644
index 000000000..f54d241a7
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/ui/AuxioSheetBehavior.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2022 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.ui
+
+import android.content.Context
+import android.graphics.drawable.ColorDrawable
+import android.graphics.drawable.LayerDrawable
+import android.util.AttributeSet
+import android.view.View
+import android.view.ViewGroup
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import com.google.android.material.bottomsheet.NeoBottomSheetBehavior
+import com.google.android.material.shape.MaterialShapeDrawable
+import org.oxycblt.auxio.R
+import org.oxycblt.auxio.util.*
+
+abstract class AuxioSheetBehavior(context: Context, attributeSet: AttributeSet?) :
+ NeoBottomSheetBehavior(context, attributeSet) {
+ private var elevationNormal = context.getDimenSafe(R.dimen.elevation_normal)
+ val sheetBackgroundDrawable =
+ MaterialShapeDrawable.createWithElevationOverlay(context).apply {
+ fillColor = context.getAttrColorSafe(R.attr.colorSurface).stateList
+ elevation = context.pxOfDp(elevationNormal).toFloat()
+ }
+
+ init {
+ isFitToContents = false
+ }
+
+ override fun shouldSkipHalfExpandedStateWhenDragging() = true
+ override fun shouldExpandOnUpwardDrag(dragDurationMillis: Long, yPositionPercentage: Float) =
+ true
+
+ override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
+ val success = super.onLayoutChild(parent, child, layoutDirection)
+
+ (child as ViewGroup).apply {
+ background =
+ LayerDrawable(
+ arrayOf(
+ ColorDrawable(context.getAttrColorSafe(R.attr.colorSurface)),
+ sheetBackgroundDrawable))
+
+ disableDropShadowCompat()
+ }
+
+ return success
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentViewBehavior.kt b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentViewBehavior.kt
new file mode 100644
index 000000000..4562df62e
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentViewBehavior.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2022 Auxio Project
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.auxio.ui
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.View
+import android.view.WindowInsets
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import com.google.android.material.bottomsheet.NeoBottomSheetBehavior
+import kotlin.math.abs
+import org.oxycblt.auxio.util.replaceSystemBarInsetsCompat
+import org.oxycblt.auxio.util.systemBarInsetsCompat
+
+class BottomSheetContentViewBehavior(context: Context, attributeSet: AttributeSet?) :
+ CoordinatorLayout.Behavior(context, attributeSet) {
+ private var lastInsets: WindowInsets? = null
+ private var dep: View? = null
+ private var setup: Boolean = false
+
+ override fun onMeasureChild(
+ parent: CoordinatorLayout,
+ child: V,
+ parentWidthMeasureSpec: Int,
+ widthUsed: Int,
+ parentHeightMeasureSpec: Int,
+ heightUsed: Int
+ ): Boolean {
+ return measureContent(parent, child, dep ?: return false)
+ }
+
+ override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean {
+ super.onLayoutChild(parent, child, layoutDirection)
+ child.layout(0, 0, child.measuredWidth, child.measuredHeight)
+
+ if (!setup) {
+ child.setOnApplyWindowInsetsListener { _, insets ->
+ lastInsets = insets
+
+ val dep = dep ?: return@setOnApplyWindowInsetsListener insets
+
+ val bars = insets.systemBarInsetsCompat
+ val behavior =
+ (dep.layoutParams as CoordinatorLayout.LayoutParams).behavior
+ as NeoBottomSheetBehavior
+
+ val offset = behavior.calculateSlideOffset()
+ if (behavior.peekHeight < 0 || offset == Float.MIN_VALUE) {
+ return@setOnApplyWindowInsetsListener insets
+ }
+
+ val adjustedBottomInset =
+ (bars.bottom - behavior.calculateConsumedByBar()).coerceAtLeast(0)
+
+ insets.replaceSystemBarInsetsCompat(
+ bars.left, bars.top, bars.right, adjustedBottomInset)
+ }
+
+ setup = true
+ }
+
+ return true
+ }
+
+ private fun measureContent(parent: View, child: View, dep: View): Boolean {
+ val behavior =
+ (dep.layoutParams as CoordinatorLayout.LayoutParams).behavior as NeoBottomSheetBehavior
+
+ val offset = behavior.calculateSlideOffset()
+ if (behavior.peekHeight < 0 || offset == Float.MIN_VALUE) {
+ return false
+ }
+
+ val contentWidthSpec =
+ View.MeasureSpec.makeMeasureSpec(parent.measuredWidth, View.MeasureSpec.EXACTLY)
+ val contentHeightSpec =
+ View.MeasureSpec.makeMeasureSpec(
+ parent.measuredHeight - behavior.calculateConsumedByBar(), View.MeasureSpec.EXACTLY)
+
+ child.measure(contentWidthSpec, contentHeightSpec)
+
+ return true
+ }
+
+ private fun NeoBottomSheetBehavior<*>.calculateConsumedByBar(): Int {
+ val offset = calculateSlideOffset()
+ return if (offset >= 0) {
+ peekHeight
+ } else {
+ (peekHeight * (1 - abs(offset))).toInt()
+ }
+ }
+
+ override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean {
+ if ((dependency.layoutParams as CoordinatorLayout.LayoutParams).behavior
+ is NeoBottomSheetBehavior) {
+ dep = dependency
+ return true
+ }
+
+ return false
+ }
+
+ override fun onDependentViewChanged(
+ parent: CoordinatorLayout,
+ child: V,
+ dependency: View
+ ): Boolean {
+ lastInsets?.let(child::dispatchApplyWindowInsets)
+ return measureContent(parent, child, dependency) &&
+ onLayoutChild(parent, child, parent.layoutDirection)
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt
index b56383d76..900a5e1fd 100644
--- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt
+++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt
@@ -32,6 +32,7 @@ import android.widget.TextView
import androidx.activity.viewModels
import androidx.annotation.ColorRes
import androidx.appcompat.app.AppCompatActivity
+import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.drawable.DrawableCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -153,6 +154,9 @@ fun RecyclerView.applySpans(shouldBeFullWidth: ((Int) -> Boolean)? = null) {
val RecyclerView.canScroll: Boolean
get() = computeVerticalScrollRange() > height
+val View.coordinatorLayoutBehavior: CoordinatorLayout.Behavior<*>?
+ get() = (layoutParams as CoordinatorLayout.LayoutParams).behavior
+
/** Converts this color to a single-color [ColorStateList]. */
val @receiver:ColorRes Int.stateList
get() = ColorStateList.valueOf(this)
diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml
index 13c92f21a..a5c1436cf 100644
--- a/app/src/main/res/layout/fragment_main.xml
+++ b/app/src/main/res/layout/fragment_main.xml
@@ -1,23 +1,26 @@
-
-
+ android:layout_height="match_parent"
+ app:layout_behavior="org.oxycblt.auxio.ui.BottomSheetContentViewBehavior"
+ app:navGraph="@navigation/nav_explore"
+ tools:layout="@layout/fragment_home" />
-
+
-
-
-
-
-
-
-
-
-
+
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 2e869be8c..1b8759fa1 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -39,7 +39,7 @@
2dp
- 4dp
+ 3dp
78dp
64dp