diff --git a/app/src/main/java/org/mozilla/fenix/components/topsheet/TopSheetBehavior.kt b/app/src/main/java/org/mozilla/fenix/components/topsheet/TopSheetBehavior.kt new file mode 100644 index 000000000..577f94435 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/topsheet/TopSheetBehavior.kt @@ -0,0 +1,810 @@ +package org.mozilla.fenix.components.topsheet + +import android.animation.ValueAnimator +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.util.AttributeSet +import android.util.TypedValue +import android.view.* +import androidx.annotation.IntDef +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.NestedScrollingChild +import androidx.core.view.ViewCompat +import androidx.customview.widget.ViewDragHelper +import com.google.android.material.R +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import java.lang.ref.WeakReference +import kotlin.math.abs + +/* +* 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. +*/ /** + * An interaction behavior plugin for a child view of [CoordinatorLayout] to make it work as + * a bottom sheet. + */ +class TopSheetBehavior +/** + * Default constructor for inflating TopSheetBehaviors from layout. + * + * @param context The [Context]. + * @param attrs The [AttributeSet]. + */(context: Context, attrs: AttributeSet?) : CoordinatorLayout.Behavior(context, attrs) { + /** + * Callback for monitoring events about top sheets. + */ + abstract class TopSheetCallback { + /** + * Called when the top sheet changes its state. + * + * @param topSheet The top sheet view. + * @param newState The new state. This will be one of [.STATE_DRAGGING], + * [.STATE_SETTLING], [.STATE_EXPANDED], + * [.STATE_COLLAPSED], or [.STATE_HIDDEN]. + */ + abstract fun onStateChanged( + topSheet: View, + @State newState: Int + ) + + /** + * Called when the top sheet is being dragged. + * + * @param topSheet The top sheet view. + * @param slideOffset The new offset of this top sheet within its range, from 0 to 1 + * when it is moving upward, and from 0 to -1 when it moving downward. + * @param isOpening detect showing + */ + abstract fun onSlide( + topSheet: View, + slideOffset: Float, + isOpening: Boolean? + ) + } + + /** + * @hide + */ + @IntDef( + STATE_EXPANDED, + STATE_COLLAPSED, + STATE_DRAGGING, + STATE_SETTLING, + STATE_HIDDEN + ) + @kotlin.annotation.Retention(AnnotationRetention.SOURCE) + annotation class State + + private var mMaximumVelocity = 0f + private var mPeekHeight = 0 + private var mMinOffset = 0 + private var mMaxOffset = 0 + private var skipCollapsed = false + + /** + * Gets/Sets the height of the top sheet when it is collapsed. + * + * @var peekHeight The height of the collapsed top sheet in pixels. + * @attr ref com.google.android.material.R.styleable#TopSheetBehavior_Params_behavior_peekHeight + */ + private var peekHeight: Int + get() = mPeekHeight + set(peekHeight) { + mPeekHeight = 0.coerceAtLeast(peekHeight) + if (mViewRef != null && mViewRef!!.get() != null) { + mMinOffset = + (-mViewRef!!.get()!!.height).coerceAtLeast( + -(mViewRef!!.get()!!.height - mPeekHeight)) + } + } + + @State + private var mState = STATE_COLLAPSED + private var mViewDragHelper: ViewDragHelper? = null + private var mIgnoreEvents = false + private var mLastNestedScrollDy = 0 + private var mNestedScrolled = false + private var mParentHeight = 0 + private var mViewRef: WeakReference? = null + private var mNestedScrollingChildRef: WeakReference? = null + private var mCallback: TopSheetCallback? = null + private var mVelocityTracker: VelocityTracker? = null + private var mActivePointerId = 0 + private var mInitialY = 0 + private var mTouchingScrollingChild = false + + /** True if Behavior has a non-null value for the @shapeAppearance attribute */ + private var shapeThemingEnabled = false + + /** Default Shape Appearance to be used in topsheet */ + private var shapeAppearanceModelDefault: ShapeAppearanceModel? = null + private var materialShapeDrawable: MaterialShapeDrawable? = null + private var interpolatorAnimator: ValueAnimator? = null + private var isShapeExpanded = false + + var elevation = -1f + var isHideable = false + + init { + val a = context.obtainStyledAttributes( + attrs, + R.styleable.BottomSheetBehavior_Layout + ) + shapeThemingEnabled = + a.hasValue(R.styleable.BottomSheetBehavior_Layout_shapeAppearance) + createMaterialShapeDrawable(context, attrs!!) + createShapeValueAnimator() + peekHeight = context.resources.displayMetrics.heightPixels * 3 / 4 + isHideable = a.getBoolean( + R.styleable.BottomSheetBehavior_Layout_behavior_hideable, + false + ) + skipCollapsed = a.getBoolean( + R.styleable.BottomSheetBehavior_Layout_behavior_skipCollapsed, + false + ) + a.recycle() + val configuration = + ViewConfiguration.get(context) + mMaximumVelocity = configuration.scaledMaximumFlingVelocity.toFloat() + } + + override fun onSaveInstanceState(parent: CoordinatorLayout, child: V): Parcelable? { + return SavedState( + super.onSaveInstanceState( + parent, + child + ), mState + ) + } + + override fun onRestoreInstanceState( + parent: CoordinatorLayout, + child: V, + state: Parcelable + ) { + val ss = + state as SavedState + super.onRestoreInstanceState(parent, child, ss.superState) + // Intermediate states are restored as collapsed state + mState = if (ss.state == STATE_DRAGGING || ss.state == STATE_SETTLING) { + STATE_COLLAPSED + } else { + ss.state + } + } + + override fun onLayoutChild( + parent: CoordinatorLayout, + child: V, + layoutDirection: Int + ): Boolean { + if (ViewCompat.getFitsSystemWindows(parent) && !ViewCompat.getFitsSystemWindows(child as View)) { + 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 as View, materialShapeDrawable) + } + // Set elevation on MaterialShapeDrawable + // Set elevation on MaterialShapeDrawable + if (materialShapeDrawable != null) { + // Use elevation attr if set on topsheet; otherwise, use elevation of child view. + materialShapeDrawable!!.elevation = + if (elevation == -1f) ViewCompat.getElevation(child as View) else elevation + // Update the material shape based on initial state. + isShapeExpanded = state == BottomSheetBehavior.STATE_EXPANDED + materialShapeDrawable!!.interpolation = if (isShapeExpanded) 0f else 1f + } + + val savedTop = child!!.top + // First let the parent lay it out + parent.onLayoutChild(child, layoutDirection) + // Offset the top sheet + mParentHeight = parent.height + mMinOffset = (-child.height).coerceAtLeast(-(child.height - mPeekHeight)) + mMaxOffset = 0 + if (mState == STATE_EXPANDED) { + ViewCompat.offsetTopAndBottom(child, mMaxOffset) + } else if (isHideable && mState == STATE_HIDDEN) { + ViewCompat.offsetTopAndBottom(child, mParentHeight) + } else if (mState == STATE_COLLAPSED) { + ViewCompat.offsetTopAndBottom(child, mMinOffset) + } else if (mState == STATE_DRAGGING || mState == STATE_SETTLING) { + ViewCompat.offsetTopAndBottom(child, savedTop - child.top) + } + if (mViewDragHelper == null) { + mViewDragHelper = ViewDragHelper.create(parent, mDragCallback) + } + mViewRef = WeakReference(child) + mNestedScrollingChildRef = + WeakReference(findScrollingChild(child)) + return true + } + + override fun onInterceptTouchEvent( + parent: CoordinatorLayout, + child: V, + event: MotionEvent + ): Boolean { + if (!child!!.isShown) { + return false + } + val action = event.actionMasked + // Record the velocity + if (action == MotionEvent.ACTION_DOWN) { + reset() + } + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain() + } + mVelocityTracker!!.addMovement(event) + when (action) { + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + mTouchingScrollingChild = false + mActivePointerId = MotionEvent.INVALID_POINTER_ID + // Reset the ignore flag + if (mIgnoreEvents) { + mIgnoreEvents = false + return false + } + } + MotionEvent.ACTION_DOWN -> { + val initialX = event.x.toInt() + mInitialY = event.y.toInt() + val scroll = mNestedScrollingChildRef!!.get() + if (scroll != null && parent.isPointInChildBounds(scroll, initialX, mInitialY)) { + mActivePointerId = event.getPointerId(event.actionIndex) + mTouchingScrollingChild = true + } + mIgnoreEvents = mActivePointerId == MotionEvent.INVALID_POINTER_ID && + !parent.isPointInChildBounds(child, initialX, mInitialY) + } + } + if (!mIgnoreEvents && mViewDragHelper!!.shouldInterceptTouchEvent(event)) { + return true + } + // We have to handle cases that the ViewDragHelper does not capture the top 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 = mNestedScrollingChildRef!!.get() + return action == MotionEvent.ACTION_MOVE && scroll != null && + !mIgnoreEvents && mState != STATE_DRAGGING && + !parent.isPointInChildBounds( + scroll, + event.x.toInt(), + event.y.toInt() + ) && abs(mInitialY - event.y) > mViewDragHelper!!.touchSlop + } + + override fun onTouchEvent( + parent: CoordinatorLayout, + child: V, + event: MotionEvent + ): Boolean { + if (!child!!.isShown) { + return false + } + val action = event.actionMasked + if (mState == STATE_DRAGGING && action == MotionEvent.ACTION_DOWN) { + return true + } + if (mViewDragHelper != null) { + //no crash + mViewDragHelper!!.processTouchEvent(event) + // Record the velocity + if (action == MotionEvent.ACTION_DOWN) { + reset() + } + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain() + } + mVelocityTracker!!.addMovement(event) + // The ViewDragHelper tries to capture only the top-most View. We have to explicitly tell it + // to capture the top sheet in case it is not captured and the touch slop is passed. + if (action == MotionEvent.ACTION_MOVE && !mIgnoreEvents) { + if (abs(mInitialY - event.y) > mViewDragHelper!!.touchSlop) { + mViewDragHelper!!.captureChildView( + child, + event.getPointerId(event.actionIndex) + ) + } + } + } + return !mIgnoreEvents + } + + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + directTargetChild: View, + target: View, + nestedScrollAxes: Int + ): Boolean { + mLastNestedScrollDy = 0 + mNestedScrolled = false + return nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 + } + + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, child: V, target: View, dx: Int, + dy: Int, consumed: IntArray + ) { + val scrollingChild = mNestedScrollingChildRef!!.get() + if (target !== scrollingChild) { + return + } + val currentTop = child!!.top + val newTop = currentTop - dy + if (dy > 0) { // Upward + if (!target.canScrollVertically(1)) { + if (newTop >= mMinOffset || isHideable) { + consumed[1] = dy + ViewCompat.offsetTopAndBottom(child, -dy) + setStateInternal(STATE_DRAGGING) + } else { + consumed[1] = currentTop - mMinOffset + ViewCompat.offsetTopAndBottom(child, -consumed[1]) + setStateInternal(STATE_COLLAPSED) + } + } + } else if (dy < 0) { // Downward + // Negative to check scrolling up, positive to check scrolling down + if (newTop < mMaxOffset) { + consumed[1] = dy + ViewCompat.offsetTopAndBottom(child, -dy) + setStateInternal(STATE_DRAGGING) + } else { + consumed[1] = currentTop - mMaxOffset + ViewCompat.offsetTopAndBottom(child, -consumed[1]) + setStateInternal(STATE_EXPANDED) + } + } + dispatchOnSlide(child.top) + mLastNestedScrollDy = dy + mNestedScrolled = true + } + + override fun onStopNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View + ) { + if (child!!.top == mMaxOffset) { + setStateInternal(STATE_EXPANDED) + return + } + if (target !== mNestedScrollingChildRef!!.get() || !mNestedScrolled) { + return + } + val top: Int + val targetState: Int + if (mLastNestedScrollDy < 0) { + top = mMaxOffset + targetState = STATE_EXPANDED + } else if (isHideable && shouldHide(child, yVelocity)) { + top = -child.height + targetState = STATE_HIDDEN + } else if (mLastNestedScrollDy == 0) { + val currentTop = child.top + if (abs(currentTop - mMinOffset) > abs(currentTop - mMaxOffset)) { + top = mMaxOffset + targetState = STATE_EXPANDED + } else { + top = mMinOffset + targetState = STATE_COLLAPSED + } + } else { + top = mMinOffset + targetState = STATE_COLLAPSED + } + if (mViewDragHelper!!.smoothSlideViewTo(child, child.left, top)) { + setStateInternal(STATE_SETTLING) + ViewCompat.postOnAnimation( + child, + SettleRunnable( + child, + targetState + ) + ) + } else { + setStateInternal(targetState) + } + mNestedScrolled = false + } + + override fun onNestedPreFling( + coordinatorLayout: CoordinatorLayout, child: V, target: View, + velocityX: Float, velocityY: Float + ): Boolean { + return target === mNestedScrollingChildRef!!.get() && + (mState != STATE_EXPANDED || + super.onNestedPreFling( + coordinatorLayout, child, target, + velocityX, velocityY + )) + } + + /** + * Sets a callback to be notified of top sheet events. + * + * @param callback The callback to notify when top sheet events occur. + */ + fun setTopSheetCallback(callback: TopSheetCallback?) { + mCallback = callback + } + + /** + * Gets/Sets the state of the top sheet. When set, the top sheet will transition to + * that state with animation. + * + * @var state One of [.STATE_EXPANDED], [.STATE_COLLAPSED], [.STATE_DRAGGING], + * and [.STATE_SETTLING]. + */ + @get:State + var state: Int + get() = mState + set(state) { + if (state == mState) { + return + } + if (mViewRef == null) { + // The view is not laid out yet; modify mState and let onLayoutChild handle it later + if (state == STATE_COLLAPSED || state == STATE_EXPANDED || + isHideable && state == STATE_HIDDEN + ) { + mState = state + } + return + } + val child = mViewRef!!.get() ?: return + val top: Int + top = if (state == STATE_COLLAPSED) { + mMinOffset + } else if (state == STATE_EXPANDED) { + mMaxOffset + } else if (isHideable && state == STATE_HIDDEN) { + -child.height + } else { + throw IllegalArgumentException("Illegal state argument: $state") + } + setStateInternal(STATE_SETTLING) + if (mViewDragHelper!!.smoothSlideViewTo(child, child.left, top)) { + ViewCompat.postOnAnimation( + child, + SettleRunnable(child, state) + ) + } + } + + private var oldState = mState + private fun setStateInternal(@State state: Int) { + if (state == STATE_COLLAPSED || state == STATE_EXPANDED) { + oldState = state + } + if (mState == state) { + return + } + mState = state + updateDrawableForTargetState(state) + val topSheet: View? = mViewRef!!.get() + if (topSheet != null && mCallback != null) { + mCallback!!.onStateChanged(topSheet, state) + } + } + + private fun updateDrawableForTargetState(@BottomSheetBehavior.State state: Int) { + if (state == BottomSheetBehavior.STATE_SETTLING) { + // Special case: we want to know which state we're settling to, so wait for another call. + return + } + val expand = state == BottomSheetBehavior.STATE_EXPANDED + if (isShapeExpanded != expand) { + isShapeExpanded = expand + if (materialShapeDrawable != null && interpolatorAnimator != null) { + if (interpolatorAnimator!!.isRunning) { + interpolatorAnimator!!.reverse() + } else { + val to = if (expand) 0f else 1f + val from = 1f - to + interpolatorAnimator!!.setFloatValues(from, to) + interpolatorAnimator!!.start() + } + } + } + } + + private fun reset() { + mActivePointerId = ViewDragHelper.INVALID_POINTER + if (mVelocityTracker != null) { + mVelocityTracker!!.recycle() + mVelocityTracker = null + } + } + + private fun shouldHide(child: View, yvel: Float): Boolean { + if (child.top > mMinOffset) { + // It should not hide, but collapse. + return false + } + val newTop = child.top + yvel * HIDE_FRICTION + return abs(newTop - mMinOffset) / mPeekHeight.toFloat() > HIDE_THRESHOLD + } + + private fun findScrollingChild(view: View): View? { + if (view is NestedScrollingChild) { + 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 val yVelocity: Float + get() { + mVelocityTracker!!.computeCurrentVelocity(1000, mMaximumVelocity) + return mVelocityTracker!!.getYVelocity(mActivePointerId) + } + + private val mDragCallback: ViewDragHelper.Callback = object : ViewDragHelper.Callback() { + override fun tryCaptureView( + child: View, + pointerId: Int + ): Boolean { + if (mState == STATE_DRAGGING) { + return false + } + if (mTouchingScrollingChild) { + return false + } + if (mState == STATE_EXPANDED && mActivePointerId == pointerId) { + val scroll = mNestedScrollingChildRef!!.get() + if (scroll != null && scroll.canScrollVertically(-1)) { + // Let the content scroll up + return false + } + } + return mViewRef != null && mViewRef!!.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) + } + } + + override fun onViewReleased( + releasedChild: View, + xvel: Float, + yvel: Float + ) { + val top: Int + @State val targetState: Int + if (yvel > 0) { // Moving up + top = mMaxOffset + targetState = STATE_EXPANDED + } else if (isHideable && shouldHide(releasedChild, yvel)) { + top = -mViewRef!!.get()!!.height + targetState = STATE_HIDDEN + } else if (yvel == 0f) { + val currentTop = releasedChild.top + if (abs(currentTop - mMinOffset) > abs(currentTop - mMaxOffset)) { + top = mMaxOffset + targetState = STATE_EXPANDED + } else { + top = mMinOffset + targetState = STATE_COLLAPSED + } + } else { + top = mMinOffset + targetState = STATE_COLLAPSED + } + if (mViewDragHelper!!.settleCapturedViewAt(releasedChild.left, top)) { + setStateInternal(STATE_SETTLING) + ViewCompat.postOnAnimation( + releasedChild, + SettleRunnable( + releasedChild, + targetState + ) + ) + } else { + setStateInternal(targetState) + } + } + + override fun clampViewPositionVertical( + child: View, + top: Int, + dy: Int + ): Int { + return constrain( + top, + if (isHideable) -child.height else mMinOffset, + mMaxOffset + ) + } + + override fun clampViewPositionHorizontal( + child: View, + left: Int, + dx: Int + ): Int { + return child.left + } + + override fun getViewVerticalDragRange(child: View): Int { + return if (isHideable) { + child.height + } else { + mMaxOffset - mMinOffset + } + } + } + + private fun createMaterialShapeDrawable( + context: Context, + attrs: AttributeSet + ) { + if (shapeThemingEnabled) { + shapeAppearanceModelDefault = ShapeAppearanceModel.builder( + context, + attrs, + R.attr.bottomSheetStyle, + DEF_STYLE_RES + ) + .build() + materialShapeDrawable = MaterialShapeDrawable(shapeAppearanceModelDefault!!) + materialShapeDrawable?.initializeElevationOverlay(context) + 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 dispatchOnSlide(top: Int) { + val topSheet: View? = mViewRef!!.get() + if (topSheet != null && mCallback != null) { + val isOpening = oldState == STATE_COLLAPSED + if (top < mMinOffset) { + mCallback!!.onSlide( + topSheet, + (top - mMinOffset).toFloat() / mPeekHeight, + isOpening + ) + } else { + mCallback!!.onSlide( + topSheet, + (top - mMinOffset).toFloat() / (mMaxOffset - mMinOffset), isOpening + ) + } + } + } + + private inner class SettleRunnable internal constructor( + private val mView: View, + @field:State @param:State private val mTargetState: Int + ) : + Runnable { + + override fun run() { + if (mViewDragHelper != null && mViewDragHelper!!.continueSettling(true)) { + ViewCompat.postOnAnimation(mView, this) + } else { + setStateInternal(mTargetState) + } + } + + } + + private class SavedState(superState: Parcelable?, @State val state: Int) : + AbsSavedState(superState) { + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeInt(state) + } + } + + companion object { + /** + * The top sheet is dragging. + */ + const val STATE_DRAGGING = 1 + + /** + * The top sheet is settling. + */ + const val STATE_SETTLING = 2 + + /** + * The top sheet is expanded. + */ + const val STATE_EXPANDED = 3 + + /** + * The top sheet is collapsed. + */ + const val STATE_COLLAPSED = 4 + + /** + * The top sheet is hidden. + */ + const val STATE_HIDDEN = 5 + private const val HIDE_THRESHOLD = 0.5f + private const val HIDE_FRICTION = 0.1f + + /** + * A utility function to get the [TopSheetBehavior] associated with the `view`. + * + * @param view The [View] with [TopSheetBehavior]. + * @return The [TopSheetBehavior] associated with the `view`. + */ + @Suppress("UNCHECKED_CAST") + fun from(view: V): TopSheetBehavior { + val params = view!!.layoutParams + require(params is CoordinatorLayout.LayoutParams) { "The view is not a child of CoordinatorLayout" } + val behavior = + params + .behavior + require(behavior is TopSheetBehavior<*>) { "The view is not associated with TopSheetBehavior" } + return behavior as TopSheetBehavior + } + + fun constrain(amount: Int, low: Int, high: Int): Int { + return if (amount < low) low else if (amount > high) high else amount + } + + private const val CORNER_ANIMATION_DURATION = 500 + private val DEF_STYLE_RES = R.style.Widget_Design_BottomSheet_Modal + + } +} \ No newline at end of file diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt index 36aeae234..2a4fec19b 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayView.kt @@ -45,6 +45,8 @@ import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.toolbar.TabCounter.Companion.INFINITE_CHAR_PADDING_BOTTOM import org.mozilla.fenix.components.toolbar.TabCounter.Companion.MAX_VISIBLE_TABS import org.mozilla.fenix.components.toolbar.TabCounter.Companion.SO_MANY_TABS_OPEN +import org.mozilla.fenix.components.toolbar.ToolbarPosition +import org.mozilla.fenix.components.topsheet.TopSheetBehavior import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.settings import org.mozilla.fenix.tabtray.SaveToCollectionsButtonAdapter.MultiselectModeChange @@ -68,12 +70,17 @@ class TabTrayView( ) : LayoutContainer, TabLayout.OnTabSelectedListener { val lifecycleScope = lifecycleOwner.lifecycleScope - val view = LayoutInflater.from(container.context) - .inflate(R.layout.component_tabstray, container, true) + val view = when (container.context.settings().toolbarPosition) { + ToolbarPosition.BOTTOM -> LayoutInflater.from(container.context).inflate(R.layout.component_tabstray_bottom, container, true) + ToolbarPosition.TOP -> LayoutInflater.from(container.context).inflate(R.layout.component_tabstray, container, true) + } private val isPrivateModeSelected: Boolean get() = view.tab_layout.selectedTabPosition == PRIVATE_TAB_ID - private val behavior = BottomSheetBehavior.from(view.tab_wrapper) + private val behavior = when (container.context.settings().toolbarPosition) { + ToolbarPosition.BOTTOM -> TopSheetBehavior.from(view.tab_wrapper) + ToolbarPosition.TOP -> BottomSheetBehavior.from(view.tab_wrapper) + } private val concatAdapter = ConcatAdapter(tabsAdapter) private val tabTrayItemMenu: TabTrayItemMenu @@ -95,17 +102,33 @@ class TabTrayView( init { components.analytics.metrics.track(Event.TabsTrayOpened) - behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) { - } + if (container.context.settings().toolbarPosition == ToolbarPosition.TOP) { + (behavior as BottomSheetBehavior).addBottomSheetCallback(object : + BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) { + } - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_HIDDEN) { - components.analytics.metrics.track(Event.TabsTrayClosed) - interactor.onTabTrayDismissed() + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_HIDDEN) { + components.analytics.metrics.track(Event.TabsTrayClosed) + interactor.onTabTrayDismissed() + } } - } - }) + }) + } else { + (behavior as TopSheetBehavior).setTopSheetCallback(object : + TopSheetBehavior.TopSheetCallback() { + override fun onSlide(topSheet: View, slideOffset: Float, isOpening: Boolean?) { + } + + override fun onStateChanged(topSheet: View, newState: Int) { + if (newState == TopSheetBehavior.STATE_HIDDEN) { + components.analytics.metrics.track(Event.TabsTrayClosed) + interactor.onTabTrayDismissed() + } + } + }) + } val selectedTabIndex = if (!isPrivate) { DEFAULT_TAB_ID @@ -258,6 +281,9 @@ class TabTrayView( fun updateTabsTrayLayout() { view.tabsTray.apply { val gridLayoutManager = GridLayoutManager(container.context, gridViewNumberOfCols(container.context)) + if (container.context.settings().toolbarPosition == ToolbarPosition.BOTTOM) { + gridLayoutManager.reverseLayout = true + } gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { val numTabs = tabsAdapter.itemCount @@ -274,7 +300,11 @@ class TabTrayView( } fun expand() { - behavior.state = BottomSheetBehavior.STATE_EXPANDED + if (container.context.settings().toolbarPosition == ToolbarPosition.TOP) { + (behavior as BottomSheetBehavior).state = BottomSheetBehavior.STATE_EXPANDED + } else { + (behavior as TopSheetBehavior).state = TopSheetBehavior.STATE_EXPANDED + } } enum class TabChange { @@ -433,9 +463,15 @@ class TabTrayView( if (multiselect) MULTISELECT_HANDLE_HEIGHT.dpToPx(displayMetrics) else NORMAL_HANDLE_HEIGHT.dpToPx( displayMetrics ) - topMargin = if (multiselect) 0.dpToPx(displayMetrics) else NORMAL_TOP_MARGIN.dpToPx( - displayMetrics - ) + if (container.context.settings().toolbarPosition == ToolbarPosition.TOP) { + topMargin = if (multiselect) 0.dpToPx(displayMetrics) else NORMAL_TOP_MARGIN.dpToPx( + displayMetrics + ) + } else { + bottomMargin = if (multiselect) 0.dpToPx(displayMetrics) else NORMAL_BOTTOM_MARGIN.dpToPx( + displayMetrics + ) + } } view.tab_wrapper.setChildWPercent( @@ -508,7 +544,9 @@ class TabTrayView( view.context.resources.getDimension(R.dimen.tab_tray_top_offset).toInt() } - behavior.setExpandedOffset(topOffset) + if (container.context.settings().toolbarPosition == ToolbarPosition.TOP) { + (behavior as BottomSheetBehavior).setExpandedOffset(topOffset) + } } fun dismissMenu() { @@ -543,6 +581,7 @@ class TabTrayView( private const val MULTISELECT_HANDLE_HEIGHT = 11 private const val NORMAL_HANDLE_HEIGHT = 3 private const val NORMAL_TOP_MARGIN = 8 + private const val NORMAL_BOTTOM_MARGIN = 8 private const val NORMAL_HANDLE_PERCENT_WIDTH = 0.1F } } diff --git a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt index 32e49ff4e..a6ed73cd4 100644 --- a/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabtray/TabTrayViewHolder.kt @@ -12,13 +12,13 @@ import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.AppCompatImageButton +import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import mozilla.components.browser.state.state.MediaState import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.tabstray.TabViewHolder import mozilla.components.browser.tabstray.TabsTrayStyling import mozilla.components.browser.tabstray.thumbnail.TabThumbnailView -import mozilla.components.browser.toolbar.MAX_URI_LENGTH import mozilla.components.concept.tabstray.Tab import mozilla.components.concept.tabstray.TabsTray import mozilla.components.feature.media.ext.pauseIfPlaying @@ -35,8 +35,6 @@ import org.mozilla.fenix.ext.increaseTapArea import org.mozilla.fenix.ext.removeAndDisable import org.mozilla.fenix.ext.removeTouchDelegate import org.mozilla.fenix.ext.showAndEnable -import org.mozilla.fenix.ext.toShortUrl -import org.mozilla.fenix.ext.settings import org.mozilla.fenix.utils.Do import kotlin.math.max @@ -50,6 +48,8 @@ class TabTrayViewHolder( private val metrics: MetricController = itemView.context.components.analytics.metrics ) : TabViewHolder(itemView) { + private val iconCard: CardView = itemView.findViewById(R.id.mozac_browser_tabstray_icon_card) + private val iconView: ImageView = itemView.findViewById(R.id.mozac_browser_tabstray_icon) private val titleView: TextView = itemView.findViewById(R.id.mozac_browser_tabstray_title) private val closeView: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close) @@ -74,6 +74,7 @@ class TabTrayViewHolder( // Basic text updateTitle(tab) + updateIcon(tab) updateCloseButtonDescription(tab.title) // Drawables and theme @@ -147,6 +148,15 @@ class TabTrayViewHolder( titleView.text = title } + private fun updateIcon(tab: Tab) { + if (tab.icon != null) { + iconCard.visibility = View.VISIBLE + iconView.setImageBitmap(tab.icon) + } else { + iconCard.visibility = View.GONE + } + } + @VisibleForTesting internal fun updateBackgroundColor(isSelected: Boolean) { val color = if (isSelected) { diff --git a/app/src/main/res/layout/component_tabstray.xml b/app/src/main/res/layout/component_tabstray.xml index b29fa5057..59c777c05 100644 --- a/app/src/main/res/layout/component_tabstray.xml +++ b/app/src/main/res/layout/component_tabstray.xml @@ -4,7 +4,6 @@ - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/tab_tray_item.xml b/app/src/main/res/layout/tab_tray_item.xml index 4e2f7f630..bf1d3a9cc 100644 --- a/app/src/main/res/layout/tab_tray_item.xml +++ b/app/src/main/res/layout/tab_tray_item.xml @@ -7,7 +7,7 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/tab_item" android:layout_width="match_parent" - android:layout_height="175dp" + android:layout_height="165dp" android:clickable="true" android:focusable="true" android:foreground="?android:selectableItemBackground"> @@ -16,8 +16,7 @@ android:id="@+id/play_pause_button" android:layout_width="24dp" android:layout_height="24dp" - android:layout_marginTop="30dp" - android:layout_marginEnd="5dp" + android:layout_marginTop="23dp" android:background="?attr/selectableItemBackgroundBorderless" android:contentDescription="@string/mozac_feature_media_notification_action_pause" android:elevation="10dp" @@ -30,8 +29,8 @@ android:id="@+id/mozac_browser_tabstray_card" android:layout_width="match_parent" android:layout_height="@dimen/tab_tray_thumbnail_height" - android:layout_marginHorizontal="16dp" - android:layout_marginTop="40dp" + android:layout_marginHorizontal="7dp" + android:layout_marginTop="30dp" android:backgroundTint="?tabTrayThumbnailItemBackground" app:cardBackgroundColor="@color/photonWhite" app:layout_constraintStart_toStartOf="parent" @@ -74,24 +73,41 @@ + + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 163e64e1c..ec488d897 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -604,6 +604,24 @@ + + +