From cc3cb343b0fdb6dfbcf99ecbf6c10babe84d81e7 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Fri, 29 Jul 2022 12:18:26 -0600 Subject: [PATCH] playback: use bottomsheetbehavior Use BottomSheetBehavior with the playback sheet. This is the result of two weeks of painful hacking to get a working implementation that did not immediately have a brain aneursym. It also requires me to still vendor BottomSheetBehavior for the time being. However, this greatly reduces technical issues on my end and allows the addition of new playback UI concepts, while still retaining the UI fluidity of prior. --- .gitignore | 3 + CHANGELOG.md | 1 + .../bottomsheet/NeoBottomSheetBehavior.java | 2195 +++++++++++++++++ .../java/org/oxycblt/auxio/MainFragment.kt | 101 +- .../auxio/playback/BottomSheetLayout.kt | 670 ----- .../auxio/playback/PlaybackBarFragment.kt | 14 - .../auxio/playback/PlaybackSheetBehavior.kt | 67 + .../oxycblt/auxio/ui/AuxioSheetBehavior.kt | 64 + .../ui/BottomSheetContentViewBehavior.kt | 128 + .../org/oxycblt/auxio/util/FrameworkUtil.kt | 4 + app/src/main/res/layout/fragment_main.xml | 56 +- app/src/main/res/values/dimens.xml | 2 +- 12 files changed, 2560 insertions(+), 745 deletions(-) create mode 100644 app/src/main/java/com/google/android/material/bottomsheet/NeoBottomSheetBehavior.java delete mode 100644 app/src/main/java/org/oxycblt/auxio/playback/BottomSheetLayout.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/playback/PlaybackSheetBehavior.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/ui/AuxioSheetBehavior.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/ui/BottomSheetContentViewBehavior.kt 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