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