diff --git a/app/src/common/shared/org/mozilla/vrbrowser/ui/views/CustomFastScroller.java b/app/src/common/shared/org/mozilla/vrbrowser/ui/views/CustomFastScroller.java new file mode 100644 index 000000000..13b726c58 --- /dev/null +++ b/app/src/common/shared/org/mozilla/vrbrowser/ui/views/CustomFastScroller.java @@ -0,0 +1,611 @@ +/* + * Copyright 2018 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.vrbrowser.ui.views; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.animation.ValueAnimator.AnimatorUpdateListener; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.view.MotionEvent; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.RecyclerView; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Class responsible to animate and provide a fast scroller. + */ +@VisibleForTesting +class CustomFastScroller extends RecyclerView.ItemDecoration implements RecyclerView.OnItemTouchListener { + @IntDef({STATE_HIDDEN, STATE_VISIBLE, STATE_DRAGGING}) + @Retention(RetentionPolicy.SOURCE) + private @interface State { } + // Scroll thumb not showing + private static final int STATE_HIDDEN = 0; + // Scroll thumb visible and moving along with the scrollbar + private static final int STATE_VISIBLE = 1; + // Scroll thumb being dragged by user + private static final int STATE_DRAGGING = 2; + + @IntDef({DRAG_X, DRAG_Y, DRAG_NONE}) + @Retention(RetentionPolicy.SOURCE) + private @interface DragState{ } + private static final int DRAG_NONE = 0; + private static final int DRAG_X = 1; + private static final int DRAG_Y = 2; + + @IntDef({ANIMATION_STATE_OUT, ANIMATION_STATE_FADING_IN, ANIMATION_STATE_IN, + ANIMATION_STATE_FADING_OUT}) + @Retention(RetentionPolicy.SOURCE) + private @interface AnimationState { } + private static final int ANIMATION_STATE_OUT = 0; + private static final int ANIMATION_STATE_FADING_IN = 1; + private static final int ANIMATION_STATE_IN = 2; + private static final int ANIMATION_STATE_FADING_OUT = 3; + + private static final int SHOW_DURATION_MS = 500; + private static final int HIDE_DELAY_AFTER_VISIBLE_MS = 1500; + private static final int HIDE_DELAY_AFTER_DRAGGING_MS = 1200; + private static final int HIDE_DURATION_MS = 500; + private static final int SCROLLBAR_FULL_OPAQUE = 255; + + private static final int[] PRESSED_STATE_SET = new int[]{android.R.attr.state_pressed}; + private static final int[] EMPTY_STATE_SET = new int[]{}; + + private final int mScrollbarMinimumRange; + private final int mMargin; + private final boolean mAlwaysVisible; + + // Final values for the vertical scroll bar + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final StateListDrawable mVerticalThumbDrawable; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final Drawable mVerticalTrackDrawable; + private final int mVerticalThumbWidth; + private final int mVerticalTrackWidth; + + // Final values for the horizontal scroll bar + private final StateListDrawable mHorizontalThumbDrawable; + private final Drawable mHorizontalTrackDrawable; + private final int mHorizontalThumbHeight; + private final int mHorizontalTrackHeight; + + // Dynamic values for the vertical scroll bar + @VisibleForTesting int mVerticalThumbHeight; + @VisibleForTesting int mVerticalThumbCenterY; + @VisibleForTesting float mVerticalDragY; + + // Dynamic values for the horizontal scroll bar + @VisibleForTesting int mHorizontalThumbWidth; + @VisibleForTesting int mHorizontalThumbCenterX; + @VisibleForTesting float mHorizontalDragX; + + private int mRecyclerViewWidth = 0; + private int mRecyclerViewHeight = 0; + + private RecyclerView mRecyclerView; + /** + * Whether the document is long/wide enough to require scrolling. If not, we don't show the + * relevant scroller. + */ + private boolean mNeedVerticalScrollbar = false; + private boolean mNeedHorizontalScrollbar = false; + @State private int mState = STATE_HIDDEN; + @DragState private int mDragState = DRAG_NONE; + + private final int[] mVerticalRange = new int[2]; + private final int[] mHorizontalRange = new int[2]; + private final int mDefaultWidth; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final ValueAnimator mShowHideAnimator = ValueAnimator.ofFloat(0, 1); + @SuppressWarnings("WeakerAccess") /* synthetic access */ + @AnimationState int mAnimationState = ANIMATION_STATE_OUT; + private final Runnable mHideRunnable = () -> hide(HIDE_DURATION_MS); + private final RecyclerView.OnScrollListener + mOnScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + updateScrollPosition(recyclerView.computeHorizontalScrollOffset(), + recyclerView.computeVerticalScrollOffset()); + } + }; + + CustomFastScroller(RecyclerView recyclerView, StateListDrawable verticalThumbDrawable, + Drawable verticalTrackDrawable, StateListDrawable horizontalThumbDrawable, + Drawable horizontalTrackDrawable, int defaultWidth, int scrollbarMinimumRange, + int margin, boolean alwaysVisible) { + mVerticalThumbDrawable = verticalThumbDrawable; + mVerticalTrackDrawable = verticalTrackDrawable; + mHorizontalThumbDrawable = horizontalThumbDrawable; + mHorizontalTrackDrawable = horizontalTrackDrawable; + mVerticalThumbWidth = Math.max(defaultWidth, verticalThumbDrawable.getIntrinsicWidth()); + mVerticalTrackWidth = Math.max(defaultWidth, verticalTrackDrawable.getIntrinsicWidth()); + mHorizontalThumbHeight = Math + .max(defaultWidth, horizontalThumbDrawable.getIntrinsicWidth()); + mHorizontalTrackHeight = Math + .max(defaultWidth, horizontalTrackDrawable.getIntrinsicWidth()); + mDefaultWidth = defaultWidth; + mScrollbarMinimumRange = scrollbarMinimumRange; + mMargin = margin; + mAlwaysVisible = alwaysVisible; + mVerticalThumbDrawable.setAlpha(SCROLLBAR_FULL_OPAQUE); + mVerticalTrackDrawable.setAlpha(SCROLLBAR_FULL_OPAQUE); + + mShowHideAnimator.addListener(new AnimatorListener()); + mShowHideAnimator.addUpdateListener(new AnimatorUpdater()); + + attachToRecyclerView(recyclerView); + } + + public void attachToRecyclerView(@Nullable RecyclerView recyclerView) { + if (mRecyclerView == recyclerView) { + return; // nothing to do + } + if (mRecyclerView != null) { + destroyCallbacks(); + } + mRecyclerView = recyclerView; + if (mRecyclerView != null) { + setupCallbacks(); + } + } + + private void setupCallbacks() { + mRecyclerView.addItemDecoration(this); + mRecyclerView.addOnItemTouchListener(this); + mRecyclerView.addOnScrollListener(mOnScrollListener); + } + + private void destroyCallbacks() { + mRecyclerView.removeItemDecoration(this); + mRecyclerView.removeOnItemTouchListener(this); + mRecyclerView.removeOnScrollListener(mOnScrollListener); + cancelHide(); + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void requestRedraw() { + mRecyclerView.invalidate(); + } + + void setState(@State int state) { + if (state == STATE_DRAGGING && mState != STATE_DRAGGING) { + mVerticalThumbDrawable.setState(PRESSED_STATE_SET); + cancelHide(); + } + + if (!mAlwaysVisible) { + if (state == STATE_HIDDEN) { + requestRedraw(); + } else { + show(); + } + + } else { + mVerticalThumbDrawable.setAlpha(SCROLLBAR_FULL_OPAQUE); + mVerticalTrackDrawable.setAlpha(SCROLLBAR_FULL_OPAQUE); + requestRedraw(); + } + + if (mState == STATE_DRAGGING && state != STATE_DRAGGING) { + mVerticalThumbDrawable.setState(EMPTY_STATE_SET); + if (!mAlwaysVisible) { + resetHideDelay(HIDE_DELAY_AFTER_DRAGGING_MS); + } + } else if (state == STATE_VISIBLE) { + if (!mAlwaysVisible) { + resetHideDelay(HIDE_DELAY_AFTER_VISIBLE_MS); + } + } + mState = state; + } + + private boolean isLayoutRTL() { + return ViewCompat.getLayoutDirection(mRecyclerView) == ViewCompat.LAYOUT_DIRECTION_RTL; + } + + public boolean isDragging() { + return mState == STATE_DRAGGING; + } + + @VisibleForTesting boolean isVisible() { + return mState == STATE_VISIBLE; + } + + @VisibleForTesting boolean isHidden() { + return mState == STATE_HIDDEN; + } + + + public void show() { + switch (mAnimationState) { + case ANIMATION_STATE_FADING_OUT: + mShowHideAnimator.cancel(); + // fall through + case ANIMATION_STATE_OUT: + mAnimationState = ANIMATION_STATE_FADING_IN; + mShowHideAnimator.setFloatValues((float) mShowHideAnimator.getAnimatedValue(), 1); + mShowHideAnimator.setDuration(SHOW_DURATION_MS); + mShowHideAnimator.setStartDelay(0); + mShowHideAnimator.start(); + break; + } + } + + public void hide() { + hide(0); + } + + @VisibleForTesting + void hide(int duration) { + switch (mAnimationState) { + case ANIMATION_STATE_FADING_IN: + mShowHideAnimator.cancel(); + // fall through + case ANIMATION_STATE_IN: + mAnimationState = ANIMATION_STATE_FADING_OUT; + mShowHideAnimator.setFloatValues((float) mShowHideAnimator.getAnimatedValue(), 0); + mShowHideAnimator.setDuration(duration); + mShowHideAnimator.start(); + break; + } + } + + private void cancelHide() { + mRecyclerView.removeCallbacks(mHideRunnable); + } + + private void resetHideDelay(int delay) { + cancelHide(); + mRecyclerView.postDelayed(mHideRunnable, delay); + } + + @Override + public void onDrawOver(Canvas canvas, RecyclerView parent, RecyclerView.State state) { + if (mRecyclerViewWidth != mRecyclerView.getWidth() + || mRecyclerViewHeight != mRecyclerView.getHeight()) { + mRecyclerViewWidth = mRecyclerView.getWidth(); + mRecyclerViewHeight = mRecyclerView.getHeight(); + // This is due to the different events ordering when keyboard is opened or + // retracted vs rotate. Hence to avoid corner cases we just disable the + // scroller when size changed, and wait until the scroll position is recomputed + // before showing it back. + if (!mAlwaysVisible) { + setState(STATE_HIDDEN); + } + return; + } + + if (mAnimationState != ANIMATION_STATE_OUT || mAlwaysVisible) { + if (mNeedVerticalScrollbar) { + drawVerticalScrollbar(canvas); + } + if (mNeedHorizontalScrollbar) { + drawHorizontalScrollbar(canvas); + } + } + } + + private void drawVerticalScrollbar(Canvas canvas) { + int viewWidth = mRecyclerViewWidth; + + int left = viewWidth - mVerticalThumbWidth; + int top = mVerticalThumbCenterY - mVerticalThumbHeight / 2; + mVerticalThumbDrawable.setBounds(0, 0, mVerticalThumbWidth, mVerticalThumbHeight); + mVerticalTrackDrawable + .setBounds(0, 0, mVerticalTrackWidth, mRecyclerViewHeight); + + if (isLayoutRTL()) { + mVerticalTrackDrawable.draw(canvas); + canvas.translate(mVerticalThumbWidth, top); + canvas.scale(-1, 1); + mVerticalThumbDrawable.draw(canvas); + canvas.scale(1, 1); + canvas.translate(-mVerticalThumbWidth, -top); + } else { + canvas.translate(left, 0); + mVerticalTrackDrawable.draw(canvas); + canvas.translate(0, top); + mVerticalThumbDrawable.draw(canvas); + canvas.translate(-left, -top); + } + } + + private void drawHorizontalScrollbar(Canvas canvas) { + int viewHeight = mRecyclerViewHeight; + + int top = viewHeight - mHorizontalThumbHeight; + int left = mHorizontalThumbCenterX - mHorizontalThumbWidth / 2; + mHorizontalThumbDrawable.setBounds(0, 0, mHorizontalThumbWidth, mHorizontalThumbHeight); + mHorizontalTrackDrawable + .setBounds(0, 0, mRecyclerViewWidth, mHorizontalTrackHeight); + + canvas.translate(0, top); + mHorizontalTrackDrawable.draw(canvas); + canvas.translate(left, 0); + mHorizontalThumbDrawable.draw(canvas); + canvas.translate(-left, -top); + } + + /** + * Notify the scroller of external change of the scroll, e.g. through dragging or flinging on + * the view itself. + * + * @param offsetX The new scroll X offset. + * @param offsetY The new scroll Y offset. + */ + void updateScrollPosition(int offsetX, int offsetY) { + int verticalContentLength = mRecyclerView.computeVerticalScrollRange(); + int verticalVisibleLength = mRecyclerViewHeight; + mNeedVerticalScrollbar = verticalContentLength - verticalVisibleLength > 0 + && mRecyclerViewHeight >= mScrollbarMinimumRange || mNeedHorizontalScrollbar; + + int horizontalContentLength = mRecyclerView.computeHorizontalScrollRange(); + int horizontalVisibleLength = mRecyclerViewWidth; + mNeedHorizontalScrollbar = horizontalContentLength - horizontalVisibleLength > 0 + && mRecyclerViewWidth >= mScrollbarMinimumRange || mNeedHorizontalScrollbar; + + if (!mNeedVerticalScrollbar && !mNeedHorizontalScrollbar) { + if (mState != STATE_HIDDEN && !mAlwaysVisible) { + setState(STATE_HIDDEN); + } + return; + } + + if (mNeedVerticalScrollbar) { + mVerticalThumbHeight = Math.max(mDefaultWidth * 4, Math.min(verticalVisibleLength, + (verticalVisibleLength * verticalVisibleLength) / verticalContentLength)); + mVerticalThumbCenterY = (int)((verticalVisibleLength - mVerticalThumbHeight) * offsetY + / ((float)verticalContentLength - verticalVisibleLength) + mVerticalThumbHeight / 2.0); + } + + if (mNeedHorizontalScrollbar && horizontalContentLength > 0) { + mHorizontalThumbWidth = Math.max(mDefaultWidth * 4, Math.min(horizontalVisibleLength, + (horizontalVisibleLength * horizontalVisibleLength) / horizontalContentLength)); + mHorizontalThumbCenterX = (int)((horizontalVisibleLength - mHorizontalThumbHeight) * offsetX + / ((float)horizontalContentLength - horizontalVisibleLength) + mHorizontalThumbHeight / 2.0); + } + + if (mState == STATE_HIDDEN || mState == STATE_VISIBLE) { + setState(STATE_VISIBLE); + } + } + + @Override + public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, + @NonNull MotionEvent ev) { + final boolean handled; + if (mState == STATE_VISIBLE) { + boolean insideVerticalThumb = isPointInsideVerticalThumb(ev.getX(), ev.getY()); + boolean insideHorizontalThumb = isPointInsideHorizontalThumb(ev.getX(), ev.getY()); + if (ev.getAction() == MotionEvent.ACTION_DOWN + && (insideVerticalThumb || insideHorizontalThumb)) { + if (insideHorizontalThumb) { + mDragState = DRAG_X; + mHorizontalDragX = (int) ev.getX(); + } else if (insideVerticalThumb) { + mDragState = DRAG_Y; + mVerticalDragY = (int) ev.getY(); + } + + setState(STATE_DRAGGING); + handled = true; + } else { + handled = false; + } + } else if (mState == STATE_DRAGGING) { + handled = true; + } else { + handled = false; + } + return handled; + } + + @Override + public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent me) { + if (mState == STATE_HIDDEN) { + return; + } + + if (me.getAction() == MotionEvent.ACTION_DOWN) { + boolean insideVerticalThumb = isPointInsideVerticalThumb(me.getX(), me.getY()); + boolean insideHorizontalThumb = isPointInsideHorizontalThumb(me.getX(), me.getY()); + if (insideVerticalThumb || insideHorizontalThumb) { + if (insideHorizontalThumb) { + mDragState = DRAG_X; + mHorizontalDragX = (int) me.getX(); + } else if (insideVerticalThumb) { + mDragState = DRAG_Y; + mVerticalDragY = (int) me.getY(); + } + setState(STATE_DRAGGING); + } + } else if (me.getAction() == MotionEvent.ACTION_UP && mState == STATE_DRAGGING) { + mVerticalDragY = 0; + mHorizontalDragX = 0; + setState(STATE_VISIBLE); + mDragState = DRAG_NONE; + } else if (me.getAction() == MotionEvent.ACTION_MOVE && mState == STATE_DRAGGING) { + if (!mAlwaysVisible) { + show(); + } + if (mDragState == DRAG_X) { + horizontalScrollTo(me.getX()); + } + if (mDragState == DRAG_Y) { + verticalScrollTo(me.getY()); + } + } + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { } + + private void verticalScrollTo(float y) { + final int[] scrollbarRange = getVerticalRange(); + y = Math.max(scrollbarRange[0], Math.min(scrollbarRange[1], y)); + if (Math.abs(mVerticalThumbCenterY - y) < 2) { + return; + } + int scrollingBy = scrollTo(mVerticalDragY, y, scrollbarRange, + mRecyclerView.computeVerticalScrollRange(), + mRecyclerView.computeVerticalScrollOffset(), mRecyclerViewHeight); + if (scrollingBy != 0) { + mRecyclerView.scrollBy(0, scrollingBy); + } + mVerticalDragY = y; + } + + private void horizontalScrollTo(float x) { + final int[] scrollbarRange = getHorizontalRange(); + x = Math.max(scrollbarRange[0], Math.min(scrollbarRange[1], x)); + if (Math.abs(mHorizontalThumbCenterX - x) < 2) { + return; + } + + int scrollingBy = scrollTo(mHorizontalDragX, x, scrollbarRange, + mRecyclerView.computeHorizontalScrollRange(), + mRecyclerView.computeHorizontalScrollOffset(), mRecyclerViewWidth); + if (scrollingBy != 0) { + mRecyclerView.scrollBy(scrollingBy, 0); + } + + mHorizontalDragX = x; + } + + private int scrollTo(float oldDragPos, float newDragPos, int[] scrollbarRange, int scrollRange, + int scrollOffset, int viewLength) { + int scrollbarLength = scrollbarRange[1] - scrollbarRange[0]; + if (scrollbarLength == 0) { + return 0; + } + float percentage = ((newDragPos - oldDragPos) / (float) scrollbarLength); + int totalPossibleOffset = scrollRange - viewLength; + int scrollingBy = (int) (percentage * totalPossibleOffset); + int absoluteOffset = scrollOffset + scrollingBy; + if (absoluteOffset < totalPossibleOffset && absoluteOffset >= 0) { + return scrollingBy; + } else { + return 0; + } + } + + @VisibleForTesting + boolean isPointInsideVerticalThumb(float x, float y) { + return (isLayoutRTL() ? x <= mVerticalThumbWidth / 2 + : x >= mRecyclerViewWidth - mVerticalThumbWidth) + && y >= mVerticalThumbCenterY - mVerticalThumbHeight / 2 + && y <= mVerticalThumbCenterY + mVerticalThumbHeight / 2; + } + + @VisibleForTesting + boolean isPointInsideHorizontalThumb(float x, float y) { + return (y >= mRecyclerViewHeight - mHorizontalThumbHeight) + && x >= mHorizontalThumbCenterX - mHorizontalThumbWidth / 2 + && x <= mHorizontalThumbCenterX + mHorizontalThumbWidth / 2; + } + + @VisibleForTesting + Drawable getHorizontalTrackDrawable() { + return mHorizontalTrackDrawable; + } + + @VisibleForTesting + Drawable getHorizontalThumbDrawable() { + return mHorizontalThumbDrawable; + } + + @VisibleForTesting + Drawable getVerticalTrackDrawable() { + return mVerticalTrackDrawable; + } + + @VisibleForTesting + Drawable getVerticalThumbDrawable() { + return mVerticalThumbDrawable; + } + + /** + * Gets the (min, max) vertical positions of the vertical scroll bar. + */ + private int[] getVerticalRange() { + mVerticalRange[0] = mMargin; + mVerticalRange[1] = mRecyclerViewHeight - mMargin; + return mVerticalRange; + } + + /** + * Gets the (min, max) horizontal positions of the horizontal scroll bar. + */ + private int[] getHorizontalRange() { + mHorizontalRange[0] = mMargin; + mHorizontalRange[1] = mRecyclerViewWidth - mMargin; + return mHorizontalRange; + } + + private class AnimatorListener extends AnimatorListenerAdapter { + + private boolean mCanceled = false; + + AnimatorListener() { + } + + @Override + public void onAnimationEnd(Animator animation) { + // Cancel is always followed by a new directive, so don't update state. + if (mCanceled) { + mCanceled = false; + return; + } + if ((float) mShowHideAnimator.getAnimatedValue() == 0) { + mAnimationState = ANIMATION_STATE_OUT; + setState(STATE_HIDDEN); + } else { + mAnimationState = ANIMATION_STATE_IN; + requestRedraw(); + } + } + + @Override + public void onAnimationCancel(Animator animation) { + mCanceled = true; + } + } + + private class AnimatorUpdater implements AnimatorUpdateListener { + AnimatorUpdater() { + } + + @Override + public void onAnimationUpdate(ValueAnimator valueAnimator) { + int alpha = (int) (SCROLLBAR_FULL_OPAQUE * ((float) valueAnimator.getAnimatedValue())); + mVerticalThumbDrawable.setAlpha(alpha); + mVerticalTrackDrawable.setAlpha(alpha); + requestRedraw(); + } + } +} + diff --git a/app/src/common/shared/org/mozilla/vrbrowser/ui/views/CustomRecyclerView.java b/app/src/common/shared/org/mozilla/vrbrowser/ui/views/CustomRecyclerView.java new file mode 100644 index 000000000..eabfe6bf8 --- /dev/null +++ b/app/src/common/shared/org/mozilla/vrbrowser/ui/views/CustomRecyclerView.java @@ -0,0 +1,92 @@ +package org.mozilla.vrbrowser.ui.views; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.util.AttributeSet; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.recyclerview.widget.RecyclerView; + +import org.mozilla.vrbrowser.R; + +public class CustomRecyclerView extends RecyclerView { + + private CustomFastScroller mFastScroller; + + public CustomRecyclerView(@NonNull Context context) { + this(context, null); + } + + public CustomRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public CustomRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + + if (attrs != null) { + int defStyleRes = 0; + TypedArray a = context.obtainStyledAttributes(attrs, androidx.recyclerview.R.styleable.RecyclerView, + defStyle, defStyleRes); + StateListDrawable verticalThumbDrawable = (StateListDrawable) a + .getDrawable(androidx.recyclerview.R.styleable.RecyclerView_fastScrollVerticalThumbDrawable); + Drawable verticalTrackDrawable = a + .getDrawable(androidx.recyclerview.R.styleable.RecyclerView_fastScrollVerticalTrackDrawable); + StateListDrawable horizontalThumbDrawable = (StateListDrawable) a + .getDrawable(androidx.recyclerview.R.styleable.RecyclerView_fastScrollHorizontalThumbDrawable); + Drawable horizontalTrackDrawable = a + .getDrawable(androidx.recyclerview.R.styleable.RecyclerView_fastScrollHorizontalTrackDrawable); + a.recycle(); + + TypedArray customAttributes = context.obtainStyledAttributes(attrs, R.styleable.CustomRecyclerView, + defStyle, defStyleRes); + boolean alwaysVisible = customAttributes.getBoolean(R.styleable.CustomRecyclerView_android_fastScrollAlwaysVisible, false); + boolean enabled = customAttributes.getBoolean(R.styleable.CustomRecyclerView_customFastScrollEnabled, false); + customAttributes.recycle(); + if (enabled) { + initFastScroller(alwaysVisible, verticalThumbDrawable, verticalTrackDrawable, horizontalThumbDrawable, horizontalTrackDrawable); + } + + } else { + setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); + } + + getViewTreeObserver().addOnGlobalLayoutListener(() -> { + if (getVisibility() == VISIBLE) { + mFastScroller.updateScrollPosition(computeHorizontalScrollOffset(), + computeVerticalScrollOffset()); + } + }); + } + + String exceptionLabel() { + return " " + super.toString() + + ", adapter:" + getAdapter() + + ", layout:" + getLayoutManager() + + ", context:" + getContext(); + } + + @VisibleForTesting + void initFastScroller(boolean alwaysVisible, StateListDrawable verticalThumbDrawable, + Drawable verticalTrackDrawable, StateListDrawable horizontalThumbDrawable, + Drawable horizontalTrackDrawable) { + if (verticalThumbDrawable == null || verticalTrackDrawable == null + || horizontalThumbDrawable == null || horizontalTrackDrawable == null) { + throw new IllegalArgumentException( + "Trying to set fast scroller without both required drawables." + exceptionLabel()); + } + + Resources resources = getContext().getResources(); + mFastScroller = new CustomFastScroller(this, verticalThumbDrawable, verticalTrackDrawable, + horizontalThumbDrawable, horizontalTrackDrawable, + resources.getDimensionPixelSize(R.dimen.fastscroll_default_thickness), + resources.getDimensionPixelSize(R.dimen.fastscroll_minimum_range), + resources.getDimensionPixelOffset(R.dimen.fastscroll_margin), alwaysVisible); + } +} diff --git a/app/src/common/shared/org/mozilla/vrbrowser/ui/views/CustomScrollView.java b/app/src/common/shared/org/mozilla/vrbrowser/ui/views/CustomScrollView.java new file mode 100644 index 000000000..66e219e3d --- /dev/null +++ b/app/src/common/shared/org/mozilla/vrbrowser/ui/views/CustomScrollView.java @@ -0,0 +1,413 @@ +package org.mozilla.vrbrowser.ui.views; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Interpolator; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.StateListDrawable; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.animation.AnimationUtils; +import android.widget.ScrollView; + +import androidx.annotation.NonNull; +import androidx.core.view.ViewCompat; + +import org.mozilla.vrbrowser.R; + +public class CustomScrollView extends ScrollView { + + public static long SCROLLER_FADE_TIMEOUT = 1500; + + private static final int[] DRAWABLE_STATE_PRESSED = new int[] { android.R.attr.state_pressed }; + private static final int[] DRAWABLE_STATE_HOVER = new int[] { android.R.attr.state_hovered }; + private static final int[] DRAWABLE_STATE_DEFAULT = new int[] {}; + + private static final float[] OPAQUE = { 255 }; + private static final float[] TRANSPARENT = { 0.0f }; + + private float mDownY; + private Rect mThumbRect; + private Drawable mThumbDrawable; + private int mThumbMinHeight; + private ScrollAnimator mScrollCache; + private boolean mThumbDynamicHeight; + private boolean mIsAlwaysVisible; + private boolean mIsHandlingTouchEvent = false; + private int mSize; + + public CustomScrollView(Context context) { + super(context); + createScrollDelegate(context, null, 0); + } + + public CustomScrollView(Context context, AttributeSet attrs) { + super(context, attrs); + createScrollDelegate(context, attrs, 0); + } + + public CustomScrollView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + createScrollDelegate(context, attrs, defStyle); + } + + private void createScrollDelegate(Context context, AttributeSet attrs, int defStyle) { + + if (attrs != null) { + TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CustomScrollView, 0, defStyle); + mThumbDrawable = a.getDrawable(R.styleable.CustomScrollView_android_fastScrollThumbDrawable); + if (mThumbDrawable == null) { + mThumbDrawable = getResources().getDrawable(R.drawable.fast_scroll_thumb, getContext().getTheme()); + } + mIsAlwaysVisible = a.getBoolean(R.styleable.CustomScrollView_android_fastScrollAlwaysVisible, false); + mThumbDynamicHeight = a.getBoolean(R.styleable.CustomScrollView_dynamicHeight, true); + mSize = a.getDimensionPixelSize(R.styleable.CustomScrollView_android_scrollbarSize, getResources().getDimensionPixelSize(R.dimen.scrollbarWidth)); + a.recycle(); + } + + setVerticalScrollBarEnabled(false); + mScrollCache = new ScrollAnimator(this); + mThumbRect = new Rect(0, 0, mSize, mSize); + + setThumbDrawable(mThumbDrawable); + setAlwaysVisible(mIsAlwaysVisible); + setThumbSize(mSize, mSize); + setThumbDynamicHeight(mThumbDynamicHeight); + } + + public void setThumbDrawable(@NonNull Drawable drawable) { + mThumbDrawable = drawable; + updateThumbRect(0); + } + + public void setThumbSize(int widthDp, int heightDp) { + mThumbRect.left = mThumbRect.right - widthDp; + mThumbMinHeight = heightDp; + updateThumbRect(0); + } + + public void setThumbDynamicHeight(boolean isDynamicHeight) { + if (mThumbDynamicHeight != isDynamicHeight) { + mThumbDynamicHeight = isDynamicHeight; + updateThumbRect(0); + } + } + + public void setAlwaysVisible(boolean visible) { + mIsAlwaysVisible = visible; + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (onInterceptTouchEventInternal(ev)) { + return true; + } + return super.onInterceptTouchEvent(ev); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(MotionEvent event) { + if (onTouchEventInternal(event)) { + return true; + } + return super.onTouchEvent(event); + } + + @Override + public boolean onHoverEvent(MotionEvent event) { + if (onHoverEventInternal(event)) { + return true; + } + return super.onHoverEvent(event); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + initialAwakenScrollBars(); + } + + @Override + protected void onVisibilityChanged(View changedView, int visibility) { + super.onVisibilityChanged(changedView, visibility); + + if (visibility == VISIBLE) { + if (ViewCompat.isAttachedToWindow(this)) { + initialAwakenScrollBars(); + } + + } + } + + @Override + protected void onWindowVisibilityChanged(int visibility) { + super.onWindowVisibilityChanged(visibility); + + if (visibility == VISIBLE) { + initialAwakenScrollBars(); + } + } + + @Override + protected boolean awakenScrollBars() { + return awakenScrollBarsInternal(SCROLLER_FADE_TIMEOUT); + } + + @Override + protected void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + + drawScrollBars(canvas); + } + + // Internal methods + + private boolean onInterceptTouchEventInternal(@NonNull MotionEvent ev) { + final int action = ev.getActionMasked(); + if (action == MotionEvent.ACTION_DOWN) { + return onTouchEventInternal(ev); + } + return false; + } + + private boolean onTouchEventInternal(@NonNull MotionEvent event) { + final int action = event.getActionMasked(); + final float y = event.getY(); + switch (action) { + case MotionEvent.ACTION_DOWN: { + if (mScrollCache.mState == ScrollAnimatorState.OFF) { + mIsHandlingTouchEvent = false; + return false; + } + if (!mIsHandlingTouchEvent) { + updateThumbRect(0); + final float x = event.getX(); + if (y >= mThumbRect.top && y <= mThumbRect.bottom && x >= mThumbRect.left && x <= mThumbRect.right) { + mIsHandlingTouchEvent = true; + mDownY = y; + super.onTouchEvent(event); + MotionEvent fakeCancelMotionEvent = MotionEvent.obtain(event); + fakeCancelMotionEvent.setAction(MotionEvent.ACTION_CANCEL); + super.onTouchEvent(fakeCancelMotionEvent); + fakeCancelMotionEvent.recycle(); + setHoveredThumb(false); + setPressedThumb(true); + updateThumbRect(0, true); + removeCallbacks(mScrollCache); + } + } + break; + } + case MotionEvent.ACTION_MOVE: { + if (mIsHandlingTouchEvent) { + final int touchDeltaY = Math.round(y - mDownY); + if (touchDeltaY != 0) { + updateThumbRect(touchDeltaY); + mDownY = y; + } + } + break; + } + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: { + if (mIsHandlingTouchEvent) { + mIsHandlingTouchEvent = false; + setPressedThumb(false); + awakenScrollBars(); + } + break; + } + + } + if (mIsHandlingTouchEvent) { + invalidate(); + getParent().requestDisallowInterceptTouchEvent(true); + return true; + } + return false; + } + + private boolean onHoverEventInternal(@NonNull MotionEvent event) { + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_HOVER_ENTER: + case MotionEvent.ACTION_HOVER_MOVE: { + setHoveredThumb(true); + return true; + } + case MotionEvent.ACTION_HOVER_EXIT: { + setHoveredThumb(false); + return true; + } + } + return false; + } + + private boolean initialAwakenScrollBars() { + return awakenScrollBarsInternal(mScrollCache.mScrollBarDefaultDelayBeforeFade * 4); + } + + private boolean awakenScrollBarsInternal(long startDelay) { + ViewCompat.postInvalidateOnAnimation(this); + if (!mIsHandlingTouchEvent) { + if (mScrollCache.mState == ScrollAnimatorState.OFF) { + final int KEY_REPEAT_FIRST_DELAY = 750; + startDelay = Math.max(KEY_REPEAT_FIRST_DELAY, startDelay); + } + long fadeStartTime = AnimationUtils.currentAnimationTimeMillis() + startDelay; + mScrollCache.mFadeStartTime = fadeStartTime; + mScrollCache.mState = ScrollAnimatorState.ON; + removeCallbacks(mScrollCache); + postDelayed(mScrollCache, fadeStartTime - AnimationUtils.currentAnimationTimeMillis()); + } + return false; + } + + private void drawScrollBars(Canvas canvas) { + boolean invalidate = false; + if (mIsHandlingTouchEvent) { + mThumbDrawable.setAlpha(255); + + } else { + if (!mIsAlwaysVisible) { + final ScrollAnimator cache = mScrollCache; + final ScrollAnimatorState state = cache.mState; + if (state == ScrollAnimatorState.OFF) { + return; + } + if (state == ScrollAnimatorState.FADING) { + if (cache.mInterpolatorValues == null) { + cache.mInterpolatorValues = new float[1]; + } + float[] values = cache.mInterpolatorValues; + if (cache.mScrollBarInterpolator.timeToValues(values) == Interpolator.Result.FREEZE_END) { + cache.mState = ScrollAnimatorState.OFF; + } else { + mThumbDrawable.setAlpha(Math.round(values[0])); + } + invalidate = true; + + } else { + mThumbDrawable.setAlpha(255); + } + } + } + + if (updateThumbRect(0)) { + final int scrollY = getScrollY(); + final int scrollX = getScrollX(); + mThumbDrawable.setBounds(mThumbRect.left + scrollX, mThumbRect.top + scrollY, mThumbRect.right + scrollX, + mThumbRect.bottom + scrollY); + mThumbDrawable.draw(canvas); + } + if (invalidate) { + invalidate(); + } + } + + private void setPressedThumb(boolean pressed) { + mThumbDrawable.setState(pressed ? DRAWABLE_STATE_PRESSED : DRAWABLE_STATE_DEFAULT); + invalidate(); + } + + private void setHoveredThumb(boolean hover) { + mThumbDrawable.setState(hover ? DRAWABLE_STATE_HOVER : DRAWABLE_STATE_DEFAULT); + invalidate(); + } + + private boolean updateThumbRect(int touchDeltaY) { + return updateThumbRect(touchDeltaY, false); + } + + private boolean updateThumbRect(int touchDeltaY, boolean forceReportFastScrolled) { + final int thumbWidth = mThumbRect.width(); + mThumbRect.right = getWidth(); + mThumbRect.left = mThumbRect.right - thumbWidth; + final int scrollRange = super.computeVerticalScrollRange(); + if (scrollRange <= 0) { + return false; + } + final int scrollOffset = super.computeVerticalScrollOffset(); + final int scrollExtent = super.computeVerticalScrollExtent(); + final int scrollMaxOffset = scrollRange - scrollExtent; + if (scrollMaxOffset <= 0) { + return false; + } + final float scrollPercent = scrollOffset * 1f / (scrollMaxOffset); + final float visiblePercent = scrollExtent * 1f / scrollRange; + final int viewHeight = getHeight(); + final int thumbHeight = mThumbDynamicHeight ? Math + .max(mThumbMinHeight, Math.round(visiblePercent * viewHeight)) : mThumbMinHeight; + mThumbRect.bottom = mThumbRect.top + thumbHeight; + final int thumbTop = Math.round((viewHeight - thumbHeight) * scrollPercent); + mThumbRect.offsetTo(mThumbRect.left, thumbTop); + + if (touchDeltaY != 0) { + int newThumbTop = thumbTop + touchDeltaY; + final int minThumbTop = 0; + final int maxThumbTop = viewHeight - thumbHeight; + if (newThumbTop > maxThumbTop) { + newThumbTop = maxThumbTop; + + } else if (newThumbTop < minThumbTop) { + newThumbTop = minThumbTop; + } + + final float newScrollPercent = newThumbTop * 1f / maxThumbTop; + final int newScrollOffset = Math.round((scrollRange - scrollExtent) * newScrollPercent); + final int viewScrollDeltaY = newScrollOffset - scrollOffset; + scrollBy(0, viewScrollDeltaY); + } + + return true; + } + + public enum ScrollAnimatorState { + OFF, + ON, + FADING + } + + private class ScrollAnimator implements Runnable { + + final int mScrollBarDefaultDelayBeforeFade; + final int mScrollBarFadeDuration; + float[] mInterpolatorValues; + View mHost; + long mFadeStartTime; + public ScrollAnimatorState mState = ScrollAnimatorState.OFF; + final Interpolator mScrollBarInterpolator = new Interpolator(1, 2); + + public ScrollAnimator(View host) { + mScrollBarDefaultDelayBeforeFade = ViewConfiguration.getScrollDefaultDelay(); + mScrollBarFadeDuration = ViewConfiguration.getScrollBarFadeDuration(); + mHost = host; + } + + public void run() { + long now = AnimationUtils.currentAnimationTimeMillis(); + if (now >= mFadeStartTime) { + int nextFrame = (int) now; + int framesCount = 0; + + Interpolator interpolator = mScrollBarInterpolator; + interpolator.setKeyFrame(framesCount++, nextFrame, OPAQUE); + + nextFrame += mScrollBarFadeDuration; + interpolator.setKeyFrame(framesCount, nextFrame, TRANSPARENT); + + mState = ScrollAnimatorState.FADING; + + mHost.invalidate(); + } + } + } + +} diff --git a/app/src/common/shared/org/mozilla/vrbrowser/ui/widgets/settings/SettingsView.java b/app/src/common/shared/org/mozilla/vrbrowser/ui/widgets/settings/SettingsView.java index 51a41d527..df3b2b11b 100644 --- a/app/src/common/shared/org/mozilla/vrbrowser/ui/widgets/settings/SettingsView.java +++ b/app/src/common/shared/org/mozilla/vrbrowser/ui/widgets/settings/SettingsView.java @@ -9,13 +9,14 @@ import androidx.annotation.NonNull; import org.mozilla.vrbrowser.R; +import org.mozilla.vrbrowser.ui.views.CustomScrollView; import org.mozilla.vrbrowser.ui.widgets.WidgetManagerDelegate; import org.mozilla.vrbrowser.ui.widgets.WidgetPlacement; abstract class SettingsView extends FrameLayout { protected Delegate mDelegate; protected WidgetManagerDelegate mWidgetManager; - protected ScrollView mScrollbar; + protected CustomScrollView mScrollbar; public interface Delegate { void onDismiss(); @@ -65,6 +66,7 @@ protected boolean isVisible() { public void onShown() { if (mScrollbar != null) { mScrollbar.fullScroll(ScrollView.FOCUS_UP); + mScrollbar.setSmoothScrollingEnabled(true); } setFocusableInTouchMode(true); diff --git a/app/src/main/res/drawable/empty_drawable.xml b/app/src/main/res/drawable/empty_drawable.xml new file mode 100644 index 000000000..b56e31ded --- /dev/null +++ b/app/src/main/res/drawable/empty_drawable.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/bookmarks.xml b/app/src/main/res/layout/bookmarks.xml index 94a96518b..cf36205ab 100644 --- a/app/src/main/res/layout/bookmarks.xml +++ b/app/src/main/res/layout/bookmarks.xml @@ -23,9 +23,7 @@ android:layout_height="match_parent" android:background="@drawable/panel_background" android:orientation="vertical" - android:paddingStart="30dp" android:paddingTop="30dp" - android:paddingEnd="30dp" android:paddingBottom="30dp"> @@ -113,12 +113,14 @@ android:textStyle="bold" /> - - + app:layoutManager="org.mozilla.vrbrowser.ui.adapters.CustomLinearLayoutManager"/> \ No newline at end of file diff --git a/app/src/main/res/layout/history.xml b/app/src/main/res/layout/history.xml index 5fd10800f..2d8a57f97 100644 --- a/app/src/main/res/layout/history.xml +++ b/app/src/main/res/layout/history.xml @@ -22,9 +22,7 @@ android:layout_height="match_parent" android:background="@drawable/panel_background" android:orientation="vertical" - android:paddingStart="30dp" android:paddingTop="30dp" - android:paddingEnd="30dp" android:paddingBottom="30dp"> @@ -128,12 +128,14 @@ android:textStyle="bold" /> - - + - + - + - + - + - + - + - + - + - + - - - - - - - + - - - - - - - + android:layout_height="match_parent" + app:layout="@layout/setting_radio_group_v" + app:options="@array/developer_options_voice_search_languages" + app:values="@array/developer_options_voice_search_languages_values" /> + - + - + + + + + + + + + + + + + diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml index aba8c7be4..8843fbfb3 100644 --- a/app/src/main/res/values/dimen.xml +++ b/app/src/main/res/values/dimen.xml @@ -266,4 +266,7 @@ 585dp + + 10dp + 20dp \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index c21924ed6..24074507e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -209,6 +209,24 @@ vertical + + + +