1
0
Fork 0
fenix/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetBehavior.kt

1352 lines
52 KiB
Kotlin

/* 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<V : View>(context: Context, attrs: AttributeSet) :
CoordinatorLayout.Behavior<V>(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<V>? = null
internal var nestedScrollingChildRef: WeakReference<View>? = 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<SavedState> {
override fun createFromParcel(parcel: Parcel): SavedState {
return SavedState(parcel)
}
override fun newArray(size: Int): Array<SavedState?> {
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 <V : View> from(view: V): QuickActionSheetBehavior<V> {
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<V>
}
}
}
/**
* 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<Parcelable>(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<QuickActionSavedState> =
object : Parcelable.ClassLoaderCreator<QuickActionSavedState> {
override fun createFromParcel(`in`: Parcel, loader: ClassLoader?): QuickActionSavedState {
val superState = `in`.readParcelable<Parcelable>(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<QuickActionSavedState> {
return newArray(size)
}
}
}
}