/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /* * 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 org.mozilla.fenix.quickactionsheet import android.animation.ValueAnimator import android.content.Context import android.os.Parcel import android.os.Parcelable import android.util.AttributeSet import android.view.MotionEvent import android.view.VelocityTracker import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import android.view.accessibility.AccessibilityEvent import androidx.annotation.IntDef import androidx.annotation.RestrictTo import androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP import androidx.annotation.VisibleForTesting import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams import androidx.core.math.MathUtils import androidx.core.view.ViewCompat import androidx.customview.widget.ViewDragHelper import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import mozilla.components.browser.toolbar.BrowserToolbar import org.mozilla.fenix.R import java.lang.ref.WeakReference import kotlin.math.abs import kotlin.math.max /** * An interaction behavior plugin for a child view of [CoordinatorLayout] to make it work as a * bottom sheet. This custom behavior is for non-modal bottom sheets that should not block accessibility * access to the rest of the screen controls. */ @Suppress("TooManyFunctions", "ComplexMethod", "LargeClass") open class QuickActionSheetBehavior(context: Context, attrs: AttributeSet) : CoordinatorLayout.Behavior(context, attrs) { /** * Save flags to be preserved in bottomsheet on configuration change. * * @param flags bitwise int of [.SAVE_PEEK_HEIGHT], [.SAVE_FIT_TO_CONTENTS], * [.SAVE_HIDEABLE], [.SAVE_SKIP_COLLAPSED], [.SAVE_ALL] and * [.SAVE_NONE]. * @see .getSaveFlags * @attr ref com.google.android.material.R.styleable#QuickActionSheetBehavior_Layout_behavior_saveFlags */ var saveFlags = SAVE_NONE private var fitToContents = true private val maximumVelocity: Float /** Peek height set by the user. */ private var peekHeight: Int = 0 /** Whether or not to use automatic peek height. */ private var peekHeightAuto: Boolean = false /** Minimum peek height permitted. */ @get:VisibleForTesting internal var peekHeightMin: Int = 0 private set /** True if Behavior has a non-null value for the @shapeAppearance attribute */ // private val shapeThemingEnabled: Boolean private var materialShapeDrawable: MaterialShapeDrawable? = null /** Default Shape Appearance to be used in bottomsheet */ private var shapeAppearanceModelDefault: ShapeAppearanceModel? = null private var interpolatorAnimator: ValueAnimator? = null internal var expandedOffset: Int = 0 internal var fitToContentsOffset: Int = 0 internal var halfExpandedOffset: Int = 0 internal var halfExpandedRatio = HALF_EXPANDED_RATIO_DEFAULT internal var collapsedOffset: Int = 0 internal var elevation = -1f internal var hideable: Boolean = false /** * 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#QuickActionSheetBehavior_Layout_behavior_skipCollapsed */ var skipCollapsed: Boolean = false @State var state get() = internalState set(value) { @State val previousState = this.internalState if (value == this.internalState) { return } if (viewRef == null) { // The view is not laid out yet; modify mState and let onLayoutChild handle it later @Suppress("ComplexCondition") if ((value == STATE_COLLAPSED || value == STATE_EXPANDED || value == STATE_HALF_EXPANDED || (hideable && value == STATE_HIDDEN)) ) { this.internalState = value } return } startSettlingAnimationPendingLayout(value) updateDrawableOnStateChange(value, previousState) } @State internal var internalState = STATE_COLLAPSED internal var viewDragHelper: ViewDragHelper? = null private var ignoreEvents: Boolean = false private var lastNestedScrollDy: Int = 0 private var nestedScrolled: Boolean = false internal var parentWidth: Int = 0 internal var parentHeight: Int = 0 internal var viewRef: WeakReference? = null internal var nestedScrollingChildRef: WeakReference? = null private var callback: QuickActionSheetCallback? = null private var velocityTracker: VelocityTracker? = null internal var activePointerId: Int = 0 private var initialY: Int = 0 internal var touchingScrollingChild: Boolean = false /** * 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. */ // If sheet is already laid out, recalculate the collapsed offset based on new setting. // Otherwise, let onLayoutChild handle this later. // Fix incorrect expanded settings depending on whether or not we are fitting sheet to contents. var isFitToContents: Boolean get() = fitToContents set(fitToContents) { if (this.fitToContents == fitToContents) { return } this.fitToContents = fitToContents if (viewRef != null) { calculateCollapsedOffset() } setStateInternal( if (this.fitToContents && internalState == STATE_HALF_EXPANDED) STATE_EXPANDED else internalState ) } /** * Sets whether this bottom sheet can hide when it is swiped down. * * @param hideable `true` to make this bottom sheet hideable. * @attr ref com.google.android.material.R.styleable#QuickActionSheetBehavior_Layout_behavior_hideable */ // Lift up to collapsed state var isHideable: Boolean get() = hideable set(hideable) { if (this.hideable != hideable) { this.hideable = hideable if (!hideable && internalState == STATE_HIDDEN) { state = STATE_COLLAPSED } } } private val yVelocity: Float get() { if (velocityTracker == null) { return 0f } velocityTracker!!.computeCurrentVelocity(PIXELS_PER_SECOND_IN_MS, maximumVelocity) return velocityTracker!!.getYVelocity(activePointerId) } private val dragCallback = object : ViewDragHelper.Callback() { @Suppress("ReturnCount") override fun tryCaptureView(child: View, pointerId: Int): Boolean { if (internalState == STATE_DRAGGING) { return false } if (touchingScrollingChild) { return false } if (internalState == STATE_EXPANDED && activePointerId == pointerId) { val scroll = if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null if (scroll != null && scroll.canScrollVertically(-1)) { // Let the content scroll up return false } } return viewRef != null && viewRef!!.get() === child } override fun onViewPositionChanged(changedView: View, left: Int, top: Int, dx: Int, dy: Int) { dispatchOnSlide(top) } override fun onViewDragStateChanged(state: Int) { if (state == ViewDragHelper.STATE_DRAGGING) { setStateInternal(STATE_DRAGGING) } } @Suppress("ComplexCondition", "LongMethod") override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { val top: Int @State val targetState: Int if (yvel < 0) { // Moving up if (fitToContents) { top = fitToContentsOffset targetState = STATE_EXPANDED } else { val currentTop = releasedChild.top if (currentTop > halfExpandedOffset) { top = halfExpandedOffset targetState = STATE_HALF_EXPANDED } else { top = expandedOffset targetState = STATE_EXPANDED } } } else if ((hideable && shouldHide( releasedChild, yvel ) && (releasedChild.top > collapsedOffset || abs(xvel) < abs(yvel))) ) { // Hide if we shouldn't collapse and the view was either released low or it was a // vertical swipe. top = parentHeight targetState = STATE_HIDDEN } else if (yvel == 0f || abs(xvel) > 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. val currentTop = releasedChild.top if (fitToContents) { if ((abs(currentTop - fitToContentsOffset) < abs(currentTop - collapsedOffset))) { top = fitToContentsOffset targetState = STATE_EXPANDED } else { top = collapsedOffset targetState = STATE_COLLAPSED } } else { if (currentTop < halfExpandedOffset) { if (currentTop < abs(currentTop - collapsedOffset)) { top = expandedOffset targetState = STATE_EXPANDED } else { top = halfExpandedOffset targetState = STATE_HALF_EXPANDED } } else { if ((abs(currentTop - halfExpandedOffset) < abs(currentTop - collapsedOffset))) { top = halfExpandedOffset targetState = STATE_HALF_EXPANDED } else { top = collapsedOffset targetState = STATE_COLLAPSED } } } } else { // Moving Down if (fitToContents) { top = collapsedOffset targetState = STATE_COLLAPSED } else { // Settle to the nearest correct height. val currentTop = releasedChild.top if ((abs(currentTop - halfExpandedOffset) < abs(currentTop - collapsedOffset))) { top = halfExpandedOffset targetState = STATE_HALF_EXPANDED } else { top = collapsedOffset targetState = STATE_COLLAPSED } } } if (viewDragHelper!!.settleCapturedViewAt(releasedChild.left, top)) { setStateInternal(STATE_SETTLING) if (targetState == STATE_EXPANDED && interpolatorAnimator != null) { interpolatorAnimator!!.reverse() } ViewCompat.postOnAnimation( releasedChild, SettleRunnable(releasedChild, targetState) ) } else { if (targetState == STATE_EXPANDED && interpolatorAnimator != null) { interpolatorAnimator!!.reverse() } setStateInternal(targetState) } } override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { return MathUtils.clamp( top, getExpandedOffset(), if (hideable) parentHeight else collapsedOffset ) } override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { return child.left } override fun getViewVerticalDragRange(child: View): Int { return if (hideable) { parentHeight } else { collapsedOffset } } } /** Callback for monitoring events about bottom sheets. */ interface QuickActionSheetCallback { /** * Called when the bottom sheet changes its state. * * @param bottomSheet The bottom sheet view. * @param newState The new state. This will be one of [.STATE_DRAGGING], [ ][.STATE_SETTLING], * [.STATE_EXPANDED], [.STATE_COLLAPSED], [ ][.STATE_HIDDEN], or [.STATE_HALF_EXPANDED]. */ fun onStateChanged(bottomSheet: View, @State newState: Int) /** * 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. */ fun onSlide(bottomSheet: View, slideOffset: Float) } /** @hide */ @RestrictTo(LIBRARY_GROUP) @IntDef(STATE_EXPANDED, STATE_COLLAPSED, STATE_DRAGGING, STATE_SETTLING, STATE_HIDDEN, STATE_HALF_EXPANDED) @kotlin.annotation.Retention(AnnotationRetention.SOURCE) annotation class State init { val a = context.obtainStyledAttributes(attrs, R.styleable.QuickActionSheetBehavior_Layout) // this.shapeThemingEnabled = a.hasValue(R.styleable.QuickActionSheetBehavior_Layout_shapeAppearance) // val hasBackgroundTint = a.hasValue(R.styleable.QuickActionSheetBehavior_Layout_backgroundTint) // if (hasBackgroundTint) { // val bottomSheetColor = MaterialResources.getColorStateList( // context, a, R.styleable.QuickActionSheetBehavior_Layout_backgroundTint // ) // createMaterialShapeDrawable(context, attrs, hasBackgroundTint, bottomSheetColor) // } else { // createMaterialShapeDrawable(context, attrs, hasBackgroundTint) // } createShapeValueAnimator() this.elevation = a.getDimension(R.styleable.QuickActionSheetBehavior_Layout_android_elevation, -1f) val value = a.peekValue(R.styleable.QuickActionSheetBehavior_Layout_mozac_behavior_peekHeight) if (value != null && value.data == PEEK_HEIGHT_AUTO) { setPeekHeight(value.data) } else { setPeekHeight( a.getDimensionPixelSize( R.styleable.QuickActionSheetBehavior_Layout_mozac_behavior_peekHeight, PEEK_HEIGHT_AUTO ) ) } isHideable = a.getBoolean(R.styleable.QuickActionSheetBehavior_Layout_mozac_behavior_hideable, false) isFitToContents = a.getBoolean(R.styleable.QuickActionSheetBehavior_Layout_mozac_behavior_fitToContents, true) skipCollapsed = a.getBoolean(R.styleable.QuickActionSheetBehavior_Layout_mozac_behavior_skipCollapsed, false) saveFlags = a.getInt(R.styleable.QuickActionSheetBehavior_Layout_mozac_behavior_saveFlags, SAVE_NONE) setHalfExpandedRatio( a.getFloat( R.styleable.QuickActionSheetBehavior_Layout_mozac_behavior_halfExpandedRatio, HALF_EXPANDED_RATIO_DEFAULT ) ) setExpandedOffset(a.getInt(R.styleable.QuickActionSheetBehavior_Layout_mozac_behavior_expandedOffset, 0)) a.recycle() val configuration = ViewConfiguration.get(context) maximumVelocity = configuration.scaledMaximumFlingVelocity.toFloat() } override fun onSaveInstanceState(parent: CoordinatorLayout, child: V): Parcelable? { return super.onSaveInstanceState(parent, child)?.let { SavedState(it, this) } } override fun onRestoreInstanceState(parent: CoordinatorLayout, child: V, state: Parcelable) { val ss = state as SavedState super.onRestoreInstanceState(parent, child, ss.superState!!) // 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.internalState = STATE_COLLAPSED } else { this.internalState = ss.state } } override fun onAttachedToLayoutParams(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 fun onDetachedFromLayoutParams() { super.onDetachedFromLayoutParams() // Release references so we don't run unnecessary codepaths while not attached to a view. viewRef = null viewDragHelper = null } override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean { if (ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child)) { child.fitsSystemWindows = true } // Only set MaterialShapeDrawable as background if shapeTheming is enabled, otherwise will // default to android:background declared in styles or layout. // if (shapeThemingEnabled && materialShapeDrawable != null) { // ViewCompat.setBackground(child, materialShapeDrawable) // } // Set elevation on MaterialShapeDrawable if (materialShapeDrawable != null) { // Use elevation attr if set on bottomsheet; otherwise, use elevation of child view. materialShapeDrawable!!.elevation = if (elevation == -1f) ViewCompat.getElevation(child) else elevation } if (viewRef == null) { // First layout with this behavior. peekHeightMin = parent.resources.getDimensionPixelSize(R.dimen.design_quick_action_sheet_peek_height_min) viewRef = WeakReference(child) } if (viewDragHelper == null) { viewDragHelper = ViewDragHelper.create(parent, dragCallback) } val savedTop = child.top // First let the parent lay it out parent.onLayoutChild(child, layoutDirection) // Offset the bottom sheet parentWidth = parent.width parentHeight = parent.height fitToContentsOffset = max(0, parentHeight - child.height) calculateHalfExpandedOffset() calculateCollapsedOffset() if (internalState == STATE_EXPANDED) { ViewCompat.offsetTopAndBottom(child, getExpandedOffset()) } else if (internalState == STATE_HALF_EXPANDED) { ViewCompat.offsetTopAndBottom(child, halfExpandedOffset) } else if (hideable && internalState == STATE_HIDDEN) { ViewCompat.offsetTopAndBottom(child, parentHeight) } else if (internalState == STATE_COLLAPSED) { ViewCompat.offsetTopAndBottom(child, collapsedOffset) } else if (internalState == STATE_DRAGGING || internalState == STATE_SETTLING) { ViewCompat.offsetTopAndBottom(child, savedTop - child.top) } nestedScrollingChildRef = WeakReference(findScrollingChild(child)!!) return true } @Suppress("ReturnCount") override fun onInterceptTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean { if (!child.isShown) { ignoreEvents = true return false } val action = event.actionMasked // Record the velocity if (action == MotionEvent.ACTION_DOWN) { reset() } if (velocityTracker == null) { velocityTracker = VelocityTracker.obtain() } velocityTracker!!.addMovement(event) when (action) { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { touchingScrollingChild = false activePointerId = MotionEvent.INVALID_POINTER_ID // Reset the ignore flag if (ignoreEvents) { ignoreEvents = false return false } } MotionEvent.ACTION_DOWN -> { val initialX = event.x.toInt() initialY = event.y.toInt() // Only intercept nested scrolling events here if the view not being moved by the // ViewDragHelper. if (internalState != STATE_SETTLING) { val scroll = if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null if (scroll != null && parent.isPointInChildBounds(scroll, initialX, initialY)) { activePointerId = event.getPointerId(event.actionIndex) touchingScrollingChild = true } } ignoreEvents = (activePointerId == MotionEvent.INVALID_POINTER_ID && !parent.isPointInChildBounds( child, initialX, initialY )) } } // 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. val scroll = if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null return (action == MotionEvent.ACTION_MOVE && scroll != null && !ignoreEvents && internalState != STATE_DRAGGING && !parent.isPointInChildBounds( scroll, event.x.toInt(), event.y.toInt() ) && viewDragHelper != null && abs(initialY - event.y) > viewDragHelper!!.touchSlop) } @Suppress("CollapsibleIfStatements") override fun onTouchEvent(parent: CoordinatorLayout, child: V, event: MotionEvent): Boolean { val action = event.actionMasked when { !child.isShown -> return false internalState == STATE_DRAGGING && action == MotionEvent.ACTION_DOWN -> return true } 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 (action == MotionEvent.ACTION_MOVE && !ignoreEvents) { if (abs(initialY - event.y) > viewDragHelper!!.touchSlop) { viewDragHelper!!.captureChildView(child, event.getPointerId(event.actionIndex)) } } return !ignoreEvents } override fun onStartNestedScroll( coordinatorLayout: CoordinatorLayout, child: V, directTargetChild: View, target: View, axes: Int, type: Int ): Boolean { lastNestedScrollDy = 0 nestedScrolled = false return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0 } override fun onNestedPreScroll( coordinatorLayout: CoordinatorLayout, child: V, target: View, dx: Int, dy: Int, consumed: IntArray, type: Int ) { if (type == ViewCompat.TYPE_NON_TOUCH) { // Ignore fling here. The ViewDragHelper handles it. return } val scrollingChild = if (nestedScrollingChildRef != null) nestedScrollingChildRef!!.get() else null if (target !== scrollingChild) { return } val currentTop = child.top val newTop = currentTop - dy if (dy > 0) { // Upward if (newTop < getExpandedOffset()) { consumed[1] = currentTop - getExpandedOffset() ViewCompat.offsetTopAndBottom(child, -consumed[1]) setStateInternal(STATE_EXPANDED) } else { consumed[1] = dy ViewCompat.offsetTopAndBottom(child, -dy) setStateInternal(STATE_DRAGGING) } } else if (dy < 0) { // Downward if (!target.canScrollVertically(-1)) { if (newTop <= collapsedOffset || hideable) { 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.top) lastNestedScrollDy = dy nestedScrolled = true } override fun onStopNestedScroll( coordinatorLayout: CoordinatorLayout, child: V, target: View, type: Int ) { if (child.top == getExpandedOffset()) { setStateInternal(STATE_EXPANDED) return } if ((nestedScrollingChildRef == null || target !== nestedScrollingChildRef!!.get() || !nestedScrolled) ) { return } val top: Int val targetState: Int if (lastNestedScrollDy > 0) { top = getExpandedOffset() targetState = STATE_EXPANDED } else if (hideable && shouldHide(child, yVelocity)) { top = parentHeight targetState = STATE_HIDDEN } else if (lastNestedScrollDy == 0) { val currentTop = child.top if (fitToContents) { if (abs(currentTop - fitToContentsOffset) < abs(currentTop - collapsedOffset)) { top = fitToContentsOffset targetState = STATE_EXPANDED } else { top = collapsedOffset targetState = STATE_COLLAPSED } } else { if (currentTop < halfExpandedOffset) { if (currentTop < abs(currentTop - collapsedOffset)) { top = expandedOffset targetState = STATE_EXPANDED } else { top = halfExpandedOffset targetState = STATE_HALF_EXPANDED } } else { if (abs(currentTop - halfExpandedOffset) < abs(currentTop - collapsedOffset)) { top = halfExpandedOffset targetState = STATE_HALF_EXPANDED } else { top = collapsedOffset targetState = STATE_COLLAPSED } } } } else { if (fitToContents) { top = collapsedOffset targetState = STATE_COLLAPSED } else { // Settle to nearest height. val currentTop = child.top if (abs(currentTop - halfExpandedOffset) < abs(currentTop - collapsedOffset)) { top = halfExpandedOffset targetState = STATE_HALF_EXPANDED } else { top = collapsedOffset targetState = STATE_COLLAPSED } } } if (viewDragHelper!!.smoothSlideViewTo(child, child.left, top)) { setStateInternal(STATE_SETTLING) ViewCompat.postOnAnimation(child, SettleRunnable(child, targetState)) } else { setStateInternal(targetState) } nestedScrolled = false } override fun onNestedScroll( coordinatorLayout: CoordinatorLayout, child: V, target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray ) { // Overridden to prevent the default consumption of the entire scroll distance. } override fun onNestedPreFling( coordinatorLayout: CoordinatorLayout, child: V, target: View, velocityX: Float, velocityY: Float ): Boolean { return if (nestedScrollingChildRef != null) { (target === nestedScrollingChildRef!!.get() && ((internalState != STATE_EXPANDED || super.onNestedPreFling( coordinatorLayout, child, target, velocityX, velocityY )))) } else { 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 [ ][.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#QuickActionSheetBehavior_Layout_behavior_peekHeight */ @Suppress("NestedBlockDepth") @JvmOverloads fun setPeekHeight(peekHeight: Int, animate: Boolean = false) { var 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 && viewRef != null) { calculateCollapsedOffset() if (internalState == STATE_COLLAPSED) { val view = viewRef!!.get() if (view != null) { if (animate) { startSettlingAnimationPendingLayout(internalState) } 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 [.PEEK_HEIGHT_AUTO] if the * sheet is configured to peek automatically at 16:9 ratio keyline * @attr ref com.google.android.material.R.styleable#QuickActionSheetBehavior_Layout_behavior_peekHeight */ @Suppress("unused") fun getPeekHeight(): Int { return if (peekHeightAuto) PEEK_HEIGHT_AUTO else peekHeight } /** * Determines the height of the QuickActionSheet in the [.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 QuickActionSheet 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 [.STATE_HALF_EXPANDED] ratio. * @attr com.google.android.material.R.styleable#QuickActionSheetBehavior_Layout_behavior_halfExpandedRatio */ fun setHalfExpandedRatio(ratio: Float) { if ((ratio <= 0) || (ratio >= 1)) { throw IllegalArgumentException("ratio must be a float value between 0 and 1") } this.halfExpandedRatio = ratio } /** * Determines the top offset of the QuickActionSheet in the [.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 [ ][.STATE_EXPANDED] offset. * Value must not exceed the offset in the half expanded state. * @attr com.google.android.material.R.styleable#QuickActionSheetBehavior_Layout_behavior_expandedOffset */ fun setExpandedOffset(offset: Int) { if (offset < 0) { throw IllegalArgumentException("offset must be greater than or equal to 0") } this.expandedOffset = offset } /** * Gets the ratio for the height of the QuickActionSheet in the [.STATE_HALF_EXPANDED] state. * * @attr com.google.android.material.R.styleable#QuickActionSheetBehavior_Layout_behavior_halfExpandedRatio */ @Suppress("unused") fun getHalfExpandedRatio(): Float { return halfExpandedRatio } /** * Sets a callback to be notified of bottom sheet events. * * @param callback The callback to notify when bottom sheet events occur. */ fun setQuickActionSheetCallback(callback: QuickActionSheetCallback) { this.callback = callback } private fun startSettlingAnimationPendingLayout(@State state: Int) { val child = viewRef?.get() ?: return // Start the animation; wait until a pending layout if there is one. val parent = child.parent if (parent != null && parent.isLayoutRequested && ViewCompat.isAttachedToWindow(child)) { val finalState = state child.post { startSettlingAnimation(child, finalState) } } else { startSettlingAnimation(child, state) } } internal fun setStateInternal(@State state: Int) { val previousState = this.internalState if (this.internalState == state) { return } this.internalState = state if (viewRef == null) { return } val bottomSheet = viewRef!!.get() ?: return ViewCompat.setImportantForAccessibility( bottomSheet, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES ) bottomSheet.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) updateDrawableOnStateChange(state, previousState) if (callback != null) { callback!!.onStateChanged(bottomSheet, state) } } private fun updateDrawableOnStateChange(@State state: Int, @State previousState: Int) { if (materialShapeDrawable != null) { val isOpening = state == STATE_EXPANDED && (previousState == STATE_HIDDEN || previousState == STATE_COLLAPSED) // If the QuickActionSheetBehavior's state is set directly to STATE_EXPANDED from // STATE_HIDDEN or STATE_COLLAPSED, bypassing STATE_DRAGGING, the corner transition animation // will not be triggered automatically, so we will trigger it here. if ((isOpening && interpolatorAnimator != null && interpolatorAnimator!!.animatedFraction == 1f) ) { interpolatorAnimator!!.reverse() } if ((state == STATE_DRAGGING && previousState == STATE_EXPANDED && interpolatorAnimator != null) ) { interpolatorAnimator!!.start() } } } private fun calculateCollapsedOffset() { val peek: Int = if (peekHeightAuto) { max(peekHeightMin, parentHeight - parentWidth * AUTO_ASPECT_RATIO_SHORT / AUTO_ASPECT_RATIO_LONG) } else { peekHeight } collapsedOffset = if (fitToContents) { max(parentHeight - peek, fitToContentsOffset) } else { parentHeight - peek } } private fun calculateHalfExpandedOffset() { this.halfExpandedOffset = (parentHeight * (1 - halfExpandedRatio)).toInt() } private fun reset() { activePointerId = ViewDragHelper.INVALID_POINTER if (velocityTracker != null) { velocityTracker!!.recycle() velocityTracker = null } } private fun restoreOptionalState(ss: SavedState) { if (this.saveFlags == SAVE_NONE) { return } if (this.saveFlags == SAVE_ALL || (this.saveFlags and SAVE_PEEK_HEIGHT) == SAVE_PEEK_HEIGHT) { this.peekHeight = ss.peekHeight } if ((this.saveFlags == SAVE_ALL || (this.saveFlags and SAVE_FIT_TO_CONTENTS) == SAVE_FIT_TO_CONTENTS)) { this.fitToContents = ss.fitToContents } if (this.saveFlags == SAVE_ALL || (this.saveFlags and SAVE_HIDEABLE) == SAVE_HIDEABLE) { this.hideable = ss.hideable } if ((this.saveFlags == SAVE_ALL || (this.saveFlags and SAVE_SKIP_COLLAPSED) == SAVE_SKIP_COLLAPSED)) { this.skipCollapsed = ss.skipCollapsed } } internal fun shouldHide(child: View, yvel: Float): Boolean { if (skipCollapsed) { return true } if (child.top < collapsedOffset) { // It should not hide, but collapse. return false } val newTop = child.top + yvel * HIDE_FRICTION return abs(newTop - collapsedOffset) / peekHeight.toFloat() > HIDE_THRESHOLD } @VisibleForTesting internal fun findScrollingChild(view: View): View? { if (ViewCompat.isNestedScrollingEnabled(view)) { return view } if (view is ViewGroup) { var i = 0 val count = view.childCount while (i < count) { val scrollingChild = findScrollingChild(view.getChildAt(i)) if (scrollingChild != null) { return scrollingChild } i++ } } return null } // private fun createMaterialShapeDrawable( // context: Context, attrs: AttributeSet, hasBackgroundTint: Boolean // ) { // this.createMaterialShapeDrawable(context, attrs, hasBackgroundTint, null) // } // private fun createMaterialShapeDrawable( // context: Context, // attrs: AttributeSet, // hasBackgroundTint: Boolean, // bottomSheetColor: ColorStateList? // ) { // if (this.shapeThemingEnabled) { // this.shapeAppearanceModelDefault = // ShapeAppearanceModel(context, attrs, R.attr.bottomSheetStyle, DEF_STYLE_RES) // // this.materialShapeDrawable = MaterialShapeDrawable(shapeAppearanceModelDefault) // this.materialShapeDrawable!!.initializeElevationOverlay(context) // // if (hasBackgroundTint && bottomSheetColor != null) { // materialShapeDrawable!!.fillColor = bottomSheetColor // } else { // // If the tint isn't set, use the theme default background color. // val defaultColor = TypedValue() // context.theme.resolveAttribute(android.R.attr.colorBackground, defaultColor, true) // materialShapeDrawable!!.setTint(defaultColor.data) // } // } // } private fun createShapeValueAnimator() { interpolatorAnimator = ValueAnimator.ofFloat(0f, 1f) interpolatorAnimator!!.duration = CORNER_ANIMATION_DURATION.toLong() interpolatorAnimator!!.addUpdateListener { animation -> val value = animation.animatedValue as Float if (materialShapeDrawable != null) { materialShapeDrawable!!.interpolation = value } } } private fun getExpandedOffset(): Int { return if (fitToContents) fitToContentsOffset else expandedOffset } internal fun startSettlingAnimation(child: View?, state: Int) { var localState = state var top: Int if (localState == STATE_COLLAPSED) { top = collapsedOffset } else if (localState == STATE_HALF_EXPANDED) { top = halfExpandedOffset if (fitToContents && top <= fitToContentsOffset) { // Skip to the expanded state if we would scroll past the height of the contents. localState = STATE_EXPANDED top = fitToContentsOffset } } else if (localState == STATE_EXPANDED) { top = getExpandedOffset() } else if (hideable && localState == STATE_HIDDEN) { top = parentHeight } else { throw IllegalArgumentException("Illegal state argument: $state") } if (viewDragHelper!!.smoothSlideViewTo(child!!, child.left, top)) { setStateInternal(STATE_SETTLING) ViewCompat.postOnAnimation(child, SettleRunnable(child, localState)) } else { setStateInternal(localState) } } internal fun dispatchOnSlide(top: Int) { val bottomSheet = viewRef!!.get() if (bottomSheet != null && callback != null) { if (top > collapsedOffset) { callback!!.onSlide( bottomSheet, (collapsedOffset - top).toFloat() / (parentHeight - collapsedOffset) ) } else { callback!!.onSlide( bottomSheet, (collapsedOffset - top).toFloat() / (collapsedOffset - getExpandedOffset()) ) } } } /** * Disables the shaped corner [ShapeAppearanceModel] interpolation transition animations. * Will have no effect unless the sheet utilizes a [MaterialShapeDrawable] with set shape * theming properties. Only For use in UI testing. */ @Suppress("unused") @VisibleForTesting fun disableShapeAnimations() { // Sets the shape value animator to null, prevents animations from occuring during testing. interpolatorAnimator = null } private inner class SettleRunnable internal constructor( private val view: View, @param:State @field:State private val targetState: Int ) : Runnable { override fun run() { if (viewDragHelper != null && viewDragHelper!!.continueSettling(true)) { ViewCompat.postOnAnimation(view, this) } else { if (internalState == STATE_SETTLING) { setStateInternal(targetState) } } } } /** State persisted across instances */ protected class SavedState : QuickActionSavedState { @State internal var state: Int = STATE_COLLAPSED internal var peekHeight: Int = 0 internal var fitToContents: Boolean = false internal var hideable: Boolean = false internal var skipCollapsed: Boolean = false @JvmOverloads constructor(source: Parcel, loader: ClassLoader? = null) : super(source, loader) { state = source.readInt() peekHeight = source.readInt() fitToContents = source.readInt() == 1 hideable = source.readInt() == 1 skipCollapsed = source.readInt() == 1 } constructor(superState: Parcelable, behavior: QuickActionSheetBehavior<*>) : super(superState) { this.state = behavior.internalState this.peekHeight = behavior.peekHeight this.fitToContents = behavior.fitToContents this.hideable = behavior.hideable this.skipCollapsed = behavior.skipCollapsed } override fun writeToParcel(dest: Parcel, flags: Int) { super.writeToParcel(dest, flags) dest.writeInt(state) dest.writeInt(peekHeight) dest.writeInt(if (fitToContents) 1 else 0) dest.writeInt(if (hideable) 1 else 0) dest.writeInt(if (skipCollapsed) 1 else 0) } override fun describeContents(): Int { return 0 } companion object CREATOR : Parcelable.Creator { override fun createFromParcel(parcel: Parcel): SavedState { return SavedState(parcel) } override fun newArray(size: Int): Array { return newArray(size) } } } override fun layoutDependsOn(parent: CoordinatorLayout, child: V, dependency: View): Boolean { if (dependency is BrowserToolbar) { return true } return super.layoutDependsOn(parent, child, dependency) } override fun onDependentViewChanged(parent: CoordinatorLayout, child: V, dependency: View): Boolean { return if (dependency is BrowserToolbar) { repositionQuickActionSheet(child, dependency) true } else { false } } private fun repositionQuickActionSheet(quickActionSheetContainer: V, toolbar: BrowserToolbar) { if (toolbar.translationY >= toolbar.height.toFloat() - POSITION_SNAP_BUFFER) { internalState = STATE_HIDDEN } else if (internalState == STATE_HIDDEN || internalState == STATE_SETTLING) { internalState = STATE_COLLAPSED } quickActionSheetContainer.translationY = toolbar.translationY + toolbar.height * -1.0f } companion object { /** The bottom sheet is dragging. */ const val STATE_DRAGGING = 1 /** The bottom sheet is settling. */ const val STATE_SETTLING = 2 /** The bottom sheet is expanded. */ const val STATE_EXPANDED = 3 /** The bottom sheet is collapsed. */ const val STATE_COLLAPSED = 4 /** The bottom sheet is hidden. */ const val STATE_HIDDEN = 5 /** The bottom sheet is half-expanded (used when mFitToContents is false). */ const val STATE_HALF_EXPANDED = 6 /** * Peek at the 16:9 ratio keyline of its parent. * * * This can be used as a parameter for [.setPeekHeight]. [.getPeekHeight] * will return this when the value is set. */ const val PEEK_HEIGHT_AUTO = -1 /** * This flag will preserve the peekHeight int value on configuration change. */ const val SAVE_PEEK_HEIGHT = 0x1 /** * This flag will preserve the fitToContents boolean value on configuration change. */ const val SAVE_FIT_TO_CONTENTS = 0x2 /** * This flag will preserve the hideable boolean value on configuration change. */ const val SAVE_HIDEABLE = 0x4 /** * This flag will preserve the skipCollapsed boolean value on configuration change. */ const val SAVE_SKIP_COLLAPSED = 0x8 /** * This flag will preserve all aforementioned values on configuration change. */ const val 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. */ const val SAVE_NONE = 0 private const val HIDE_THRESHOLD = 0.5f private const val HIDE_FRICTION = 0.1f private const val CORNER_ANIMATION_DURATION = 500 private const val PIXELS_PER_SECOND_IN_MS = 1000 private const val HALF_EXPANDED_RATIO_DEFAULT = 0.5f private const val AUTO_ASPECT_RATIO_SHORT = 9 private const val AUTO_ASPECT_RATIO_LONG = 16 private const val DEF_STYLE_RES = R.style.Widget_Design_BottomSheet_Modal /** * A utility function to get the [QuickActionSheetBehavior] associated with the `view`. * * @param view The [View] with [QuickActionSheetBehavior]. * @return The [QuickActionSheetBehavior] associated with the `view`. */ fun from(view: V): QuickActionSheetBehavior { val params = view.layoutParams as? LayoutParams ?: throw IllegalArgumentException("The view is not a child of CoordinatorLayout") val behavior = params.behavior as? QuickActionSheetBehavior<*> ?: throw IllegalArgumentException("The view is not associated with QuickActionSheetBehavior") @Suppress("UNCHECKED_CAST") return behavior as QuickActionSheetBehavior } } } /** * A [Parcelable] implementation that should be used by inheritance * hierarchies to ensure the state of all classes along the chain is saved. */ abstract class QuickActionSavedState : Parcelable { var superState: Parcelable? = null /** * Constructor called by derived classes when creating their SavedState objects * * @param superState The state of the superclass of this view */ protected constructor(superState: Parcelable? = null) { this.superState = if (superState !== EMPTY_STATE) superState else null } /** * Constructor used when reading from a parcel. Reads the state of the superclass. * * @param source parcel to read from * @param loader ClassLoader to use for reading */ @JvmOverloads protected constructor(source: Parcel, loader: ClassLoader? = null) { val superState = source.readParcelable(loader) this.superState = superState ?: EMPTY_STATE } override fun describeContents(): Int { return 0 } override fun writeToParcel(dest: Parcel, flags: Int) { dest.writeParcelable(superState, flags) } companion object { val EMPTY_STATE: QuickActionSavedState = object : QuickActionSavedState() {} @Suppress("unused") val CREATOR: Parcelable.Creator = object : Parcelable.ClassLoaderCreator { override fun createFromParcel(`in`: Parcel, loader: ClassLoader?): QuickActionSavedState { val superState = `in`.readParcelable(loader) if (superState != null) { throw IllegalStateException("superState must be null") } return EMPTY_STATE } override fun createFromParcel(`in`: Parcel): QuickActionSavedState { return createFromParcel(`in`, null) } override fun newArray(size: Int): Array { return newArray(size) } } } }