From 4987c40d672c9cef342fae32ba8665ed66e9a284 Mon Sep 17 00:00:00 2001 From: Colin Lee Date: Wed, 14 Aug 2019 11:56:29 -0500 Subject: [PATCH] [fenix] For https://github.com/mozilla-mobile/fenix/issues/1667: Screen reader can't reach other UI items when QAB expanded (https://github.com/mozilla-mobile/fenix/pull/4695) --- .../fenix/browser/BaseBrowserFragment.kt | 4 +- .../toolbar/BrowserToolbarController.kt | 3 +- .../quickactionsheet/QuickActionSheet.kt | 89 +- .../QuickActionSheetBehavior.kt | 1351 +++++++++++++++++ .../quickactionsheet/QuickActionSheetView.kt | 17 +- app/src/main/res/layout/fragment_browser.xml | 4 +- app/src/main/res/values/attrs.xml | 52 + app/src/main/res/values/dimens.xml | 2 + .../DefaultBrowserToolbarControllerTest.kt | 6 +- 9 files changed, 1447 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetBehavior.kt diff --git a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt index 10cbb6727d..175c7fa928 100644 --- a/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/browser/BaseBrowserFragment.kt @@ -19,7 +19,6 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController -import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.Snackbar import kotlinx.android.synthetic.main.component_search.* import kotlinx.android.synthetic.main.fragment_browser.* @@ -72,6 +71,7 @@ import org.mozilla.fenix.ext.enterToImmersiveMode import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.toTab +import org.mozilla.fenix.quickactionsheet.QuickActionSheetBehavior import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.utils.Settings @@ -173,7 +173,7 @@ abstract class BaseBrowserFragment : Fragment(), BackHandler, SessionManager.Obs it.flags = Intent.FLAG_ACTIVITY_NEW_TASK }, currentSessionAsTab = session.toTab(context!!), - bottomSheetBehavior = BottomSheetBehavior.from(nestedScrollQuickAction) + bottomSheetBehavior = QuickActionSheetBehavior.from(nestedScrollQuickAction) ) browserInteractor = diff --git a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt index a074dc98c5..493ef6a718 100644 --- a/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt +++ b/app/src/main/java/org/mozilla/fenix/components/toolbar/BrowserToolbarController.kt @@ -26,6 +26,7 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.nav import org.mozilla.fenix.home.sessioncontrol.Tab import org.mozilla.fenix.lib.Do +import org.mozilla.fenix.quickactionsheet.QuickActionSheetBehavior /** * An interface that handles the view manipulation of the BrowserToolbar, triggered by the Interactor @@ -47,7 +48,7 @@ class DefaultBrowserToolbarController( private val getSupportUrl: () -> String, private val openInFenixIntent: Intent, private val currentSessionAsTab: Tab, - private val bottomSheetBehavior: BottomSheetBehavior + private val bottomSheetBehavior: QuickActionSheetBehavior ) : BrowserToolbarController { override fun handleToolbarClick() { diff --git a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheet.kt b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheet.kt index dc87a8ef17..cb3657ba6c 100644 --- a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheet.kt +++ b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheet.kt @@ -11,17 +11,15 @@ import android.view.View import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import android.widget.LinearLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.widget.NestedScrollView import com.google.android.material.bottomsheet.BottomSheetBehavior -import mozilla.components.browser.toolbar.BrowserToolbar -import org.mozilla.fenix.R import kotlinx.android.synthetic.main.layout_quick_action_sheet.view.* import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.mozilla.fenix.R import org.mozilla.fenix.utils.Settings const val POSITION_SNAP_BUFFER = 1f @@ -35,7 +33,7 @@ class QuickActionSheet @JvmOverloads constructor( private val scope = MainScope() - private lateinit var quickActionSheetBehavior: QuickActionSheetBehavior + private lateinit var quickActionSheetBehavior: QuickActionSheetBehavior init { inflate(context, R.layout.layout_quick_action_sheet, this) @@ -43,8 +41,8 @@ class QuickActionSheet @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - quickActionSheetBehavior = BottomSheetBehavior.from(quick_action_sheet.parent as View) - as QuickActionSheetBehavior + quickActionSheetBehavior = + QuickActionSheetBehavior.from(quick_action_sheet.parent as NestedScrollView) quickActionSheetBehavior.isHideable = false setupHandle() } @@ -76,21 +74,21 @@ class QuickActionSheet @JvmOverloads constructor( } class HandleAccessibilityDelegate( - private val quickActionSheetBehavior: QuickActionSheetBehavior + private val quickActionSheetBehavior: QuickActionSheetBehavior ) : View.AccessibilityDelegate() { private var finalState = BottomSheetBehavior.STATE_COLLAPSED - get() = when (quickActionSheetBehavior.state) { - BottomSheetBehavior.STATE_EXPANDED, - BottomSheetBehavior.STATE_HIDDEN, - BottomSheetBehavior.STATE_COLLAPSED -> { - quickActionSheetBehavior.state + get() = when (quickActionSheetBehavior.state) { + BottomSheetBehavior.STATE_EXPANDED, + BottomSheetBehavior.STATE_HIDDEN, + BottomSheetBehavior.STATE_COLLAPSED -> { + quickActionSheetBehavior.state + } + else -> field + } + set(value) { + field = value + quickActionSheetBehavior.state = value } - else -> field - } - set(value) { - field = value - quickActionSheetBehavior.state = value - } override fun performAccessibilityAction(host: View?, action: Int, args: Bundle?): Boolean { finalState = when (action) { @@ -113,11 +111,13 @@ class QuickActionSheet @JvmOverloads constructor( override fun onInitializeAccessibilityNodeInfo(host: View?, info: AccessibilityNodeInfo?) { super.onInitializeAccessibilityNodeInfo(host, info) - info?.addAction(when (finalState) { - BottomSheetBehavior.STATE_COLLAPSED, - BottomSheetBehavior.STATE_HIDDEN -> AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND - else -> AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE - }) + info?.addAction( + when (finalState) { + BottomSheetBehavior.STATE_COLLAPSED, + BottomSheetBehavior.STATE_HIDDEN -> AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND + else -> AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE + } + ) } } @@ -126,44 +126,3 @@ class QuickActionSheet @JvmOverloads constructor( const val BOUNCE_ANIMATION_PAUSE_LENGTH = 2000L } } - -class QuickActionSheetBehavior( - context: Context, - attrs: AttributeSet -) : BottomSheetBehavior(context, attrs) { - - override fun layoutDependsOn(parent: CoordinatorLayout, child: NestedScrollView, dependency: View): Boolean { - if (dependency is BrowserToolbar) { - return true - } - - return super.layoutDependsOn(parent, child, dependency) - } - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: NestedScrollView, - dependency: View - ): Boolean { - return if (dependency is BrowserToolbar) { - repositionQuickActionSheet(child, dependency) - true - } else { - false - } - } - - private fun repositionQuickActionSheet(quickActionSheetContainer: NestedScrollView, toolbar: BrowserToolbar) { - if (toolbar.translationY >= toolbar.height.toFloat() - POSITION_SNAP_BUFFER) { - state = STATE_HIDDEN - } else if (state == STATE_HIDDEN || state == STATE_SETTLING) { - state = STATE_COLLAPSED - } - quickActionSheetContainer.translationY = toolbar.translationY + toolbar.height * -1.0f - } - - companion object { - fun from(view: NestedScrollView) = - BottomSheetBehavior.from(view) as QuickActionSheetBehavior - } -} diff --git a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetBehavior.kt b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetBehavior.kt new file mode 100644 index 0000000000..52b826c01c --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetBehavior.kt @@ -0,0 +1,1351 @@ +/* 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") + 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) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetView.kt b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetView.kt index 2dd4f0f86a..a9003e4650 100644 --- a/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetView.kt +++ b/app/src/main/java/org/mozilla/fenix/quickactionsheet/QuickActionSheetView.kt @@ -11,7 +11,6 @@ import androidx.annotation.DrawableRes import androidx.core.content.edit import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView -import com.google.android.material.bottomsheet.BottomSheetBehavior import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.fragment_browser.* import kotlinx.android.synthetic.main.layout_quick_action_sheet.* @@ -31,6 +30,7 @@ interface QuickActionSheetViewInteractor { fun onQuickActionSheetAppearancePressed() fun onQuickActionSheetOpenLinkPressed() } + /** * View for the quick action sheet that slides out from the toolbar. */ @@ -47,13 +47,14 @@ class QuickActionSheetView( private val quickActionSheetBehavior = QuickActionSheetBehavior.from(nestedScrollQuickAction) init { - quickActionSheetBehavior.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onStateChanged(v: View, state: Int) { - updateImportantForAccessibility(state) + quickActionSheetBehavior.setQuickActionSheetCallback(object : + QuickActionSheetBehavior.QuickActionSheetCallback { + override fun onStateChanged(bottomSheet: View, newState: Int) { + updateImportantForAccessibility(newState) - if (state == BottomSheetBehavior.STATE_EXPANDED) { + if (newState == QuickActionSheetBehavior.STATE_EXPANDED) { interactor.onQuickActionSheetOpened() - } else if (state == BottomSheetBehavior.STATE_COLLAPSED) { + } else if (newState == QuickActionSheetBehavior.STATE_COLLAPSED) { interactor.onQuickActionSheetClosed() } } @@ -86,7 +87,7 @@ class QuickActionSheetView( R.id.quick_action_open_app_link -> interactor.onQuickActionSheetOpenLinkPressed() else -> return } - quickActionSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED + quickActionSheetBehavior.state = QuickActionSheetBehavior.STATE_COLLAPSED } /** @@ -102,7 +103,7 @@ class QuickActionSheetView( */ private fun updateImportantForAccessibility(state: Int) { view.quick_action_buttons_layout.importantForAccessibility = when (state) { - BottomSheetBehavior.STATE_COLLAPSED, BottomSheetBehavior.STATE_HIDDEN -> + QuickActionSheetBehavior.STATE_COLLAPSED, QuickActionSheetBehavior.STATE_HIDDEN -> View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS else -> View.IMPORTANT_FOR_ACCESSIBILITY_AUTO diff --git a/app/src/main/res/layout/fragment_browser.xml b/app/src/main/res/layout/fragment_browser.xml index 480c0b0a29..918e7db444 100644 --- a/app/src/main/res/layout/fragment_browser.xml +++ b/app/src/main/res/layout/fragment_browser.xml @@ -26,8 +26,8 @@ android:id="@+id/nestedScrollQuickAction" android:layout_width="match_parent" android:layout_height="wrap_content" - app:behavior_hideable="true" - app:behavior_peekHeight="12dp" + app:mozac_behavior_hideable="true" + app:mozac_behavior_peekHeight="12dp" app:layout_behavior="org.mozilla.fenix.quickactionsheet.QuickActionSheetBehavior" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 398f858edb..b83f42e21a 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -34,6 +34,8 @@ 46dp + 64dp + 32dp diff --git a/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarControllerTest.kt b/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarControllerTest.kt index 6744e90063..938cea03cd 100644 --- a/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/components/toolbar/DefaultBrowserToolbarControllerTest.kt @@ -7,7 +7,6 @@ package org.mozilla.fenix.components.toolbar import android.content.Intent import androidx.core.widget.NestedScrollView import androidx.navigation.NavController -import com.google.android.material.bottomsheet.BottomSheetBehavior import io.mockk.Runs import io.mockk.every import io.mockk.just @@ -37,6 +36,7 @@ import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.nav import org.mozilla.fenix.home.sessioncontrol.Tab import org.mozilla.fenix.home.sessioncontrol.TabCollection +import org.mozilla.fenix.quickactionsheet.QuickActionSheetBehavior @ExperimentalCoroutinesApi @ObsoleteCoroutinesApi @@ -54,7 +54,7 @@ class DefaultBrowserToolbarControllerTest { private val getSupportUrl: () -> String = { "https://supportUrl.org" } private val openInFenixIntent: Intent = mockk(relaxed = true) private val currentSessionAsTab: Tab = mockk(relaxed = true) - private val bottomSheetBehavior: BottomSheetBehavior = mockk(relaxed = true) + private val bottomSheetBehavior: QuickActionSheetBehavior = mockk(relaxed = true) private val metrics: MetricController = mockk(relaxed = true) private val sessionUseCases: SessionUseCases = mockk(relaxed = true) @@ -236,7 +236,7 @@ class DefaultBrowserToolbarControllerTest { controller.handleToolbarItemInteraction(item) - verify { bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED } + verify { bottomSheetBehavior.state = QuickActionSheetBehavior.STATE_COLLAPSED } verify { findInPageLauncher() } verify { metrics.track(Event.FindInPageOpened) } }