com.facebook.react.views.scroll.ReactHorizontalScrollView.java Source code

Java tutorial

Introduction

Here is the source code for com.facebook.react.views.scroll.ReactHorizontalScrollView.java

Source

/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

package com.facebook.react.views.scroll;

import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.graphics.Rect;
import android.hardware.SensorManager;
import android.support.v4.view.ViewCompat;
import android.support.v4.text.TextUtilsCompat;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.widget.HorizontalScrollView;
import android.widget.OverScroller;

import com.facebook.infer.annotation.Assertions;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.uimanager.events.NativeGestureUtil;
import com.facebook.react.uimanager.MeasureSpecAssertions;
import com.facebook.react.uimanager.ReactClippingViewGroup;
import com.facebook.react.uimanager.ReactClippingViewGroupHelper;
import com.facebook.react.uimanager.ViewProps;
import com.facebook.react.views.view.ReactViewBackgroundManager;

import java.lang.reflect.Field;
import java.util.List;
import java.util.Locale;
import javax.annotation.Nullable;

/**
 * Similar to {@link ReactScrollView} but only supports horizontal scrolling.
 */
@TargetApi(16)
public class ReactHorizontalScrollView extends HorizontalScrollView implements ReactClippingViewGroup {

    private static @Nullable Field sScrollerField;
    private static boolean sTriedToGetScrollerField = false;

    private final OnScrollDispatchHelper mOnScrollDispatchHelper = new OnScrollDispatchHelper();
    private final @Nullable OverScroller mScroller;
    private final VelocityHelper mVelocityHelper = new VelocityHelper();
    private final Rect mRect = new Rect();

    private boolean mActivelyScrolling;
    private @Nullable Rect mClippingRect;
    private @Nullable String mOverflow = ViewProps.HIDDEN;
    private boolean mDragging;
    private boolean mPagingEnabled = false;
    private @Nullable Runnable mPostTouchRunnable;
    private boolean mRemoveClippedSubviews;
    private boolean mScrollEnabled = true;
    private boolean mSendMomentumEvents;
    private @Nullable FpsListener mFpsListener = null;
    private @Nullable String mScrollPerfTag;
    private @Nullable Drawable mEndBackground;
    private int mEndFillColor = Color.TRANSPARENT;
    private int mSnapInterval = 0;
    private float mDecelerationRate = 0.985f;
    private @Nullable List<Integer> mSnapOffsets;
    private boolean mSnapToStart = true;
    private boolean mSnapToEnd = true;
    private ReactViewBackgroundManager mReactBackgroundManager;

    public ReactHorizontalScrollView(Context context) {
        this(context, null);
    }

    public ReactHorizontalScrollView(Context context, @Nullable FpsListener fpsListener) {
        super(context);
        mReactBackgroundManager = new ReactViewBackgroundManager(this);
        mFpsListener = fpsListener;

        mScroller = getOverScrollerFromParent();
    }

    @Nullable
    private OverScroller getOverScrollerFromParent() {
        OverScroller scroller;

        if (!sTriedToGetScrollerField) {
            sTriedToGetScrollerField = true;
            try {
                sScrollerField = HorizontalScrollView.class.getDeclaredField("mScroller");
                sScrollerField.setAccessible(true);
            } catch (NoSuchFieldException e) {
                Log.w(ReactConstants.TAG, "Failed to get mScroller field for HorizontalScrollView! "
                        + "This app will exhibit the bounce-back scrolling bug :(");
            }
        }

        if (sScrollerField != null) {
            try {
                Object scrollerValue = sScrollerField.get(this);
                if (scrollerValue instanceof OverScroller) {
                    scroller = (OverScroller) scrollerValue;
                } else {
                    Log.w(ReactConstants.TAG,
                            "Failed to cast mScroller field in HorizontalScrollView (probably due to OEM changes to AOSP)! "
                                    + "This app will exhibit the bounce-back scrolling bug :(");
                    scroller = null;
                }
            } catch (IllegalAccessException e) {
                throw new RuntimeException("Failed to get mScroller from HorizontalScrollView!", e);
            }
        } else {
            scroller = null;
        }

        return scroller;
    }

    public void setScrollPerfTag(@Nullable String scrollPerfTag) {
        mScrollPerfTag = scrollPerfTag;
    }

    @Override
    public void setRemoveClippedSubviews(boolean removeClippedSubviews) {
        if (removeClippedSubviews && mClippingRect == null) {
            mClippingRect = new Rect();
        }
        mRemoveClippedSubviews = removeClippedSubviews;
        updateClippingRect();
    }

    @Override
    public boolean getRemoveClippedSubviews() {
        return mRemoveClippedSubviews;
    }

    public void setSendMomentumEvents(boolean sendMomentumEvents) {
        mSendMomentumEvents = sendMomentumEvents;
    }

    public void setScrollEnabled(boolean scrollEnabled) {
        mScrollEnabled = scrollEnabled;
    }

    public void setPagingEnabled(boolean pagingEnabled) {
        mPagingEnabled = pagingEnabled;
    }

    public void setDecelerationRate(float decelerationRate) {
        mDecelerationRate = decelerationRate;

        if (mScroller != null) {
            mScroller.setFriction(1.0f - mDecelerationRate);
        }
    }

    public void setSnapInterval(int snapInterval) {
        mSnapInterval = snapInterval;
    }

    public void setSnapOffsets(List<Integer> snapOffsets) {
        mSnapOffsets = snapOffsets;
    }

    public void setSnapToStart(boolean snapToStart) {
        mSnapToStart = snapToStart;
    }

    public void setSnapToEnd(boolean snapToEnd) {
        mSnapToEnd = snapToEnd;
    }

    public void flashScrollIndicators() {
        awakenScrollBars();
    }

    public void setOverflow(String overflow) {
        mOverflow = overflow;
        invalidate();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        getDrawingRect(mRect);

        switch (mOverflow) {
        case ViewProps.VISIBLE:
            break;
        default:
            canvas.clipRect(mRect);
            break;
        }

        super.onDraw(canvas);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec);

        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        // Call with the present values in order to re-layout if necessary
        scrollTo(getScrollX(), getScrollY());
    }

    @Override
    protected void onScrollChanged(int x, int y, int oldX, int oldY) {
        super.onScrollChanged(x, y, oldX, oldY);

        mActivelyScrolling = true;

        if (mOnScrollDispatchHelper.onScrollChanged(x, y)) {
            if (mRemoveClippedSubviews) {
                updateClippingRect();
            }

            ReactScrollViewHelper.emitScrollEvent(this, mOnScrollDispatchHelper.getXFlingVelocity(),
                    mOnScrollDispatchHelper.getYFlingVelocity());
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (!mScrollEnabled) {
            return false;
        }

        try {
            if (super.onInterceptTouchEvent(ev)) {
                NativeGestureUtil.notifyNativeGestureStarted(this, ev);
                ReactScrollViewHelper.emitScrollBeginDragEvent(this);
                mDragging = true;
                enableFpsListener();
                return true;
            }
        } catch (IllegalArgumentException e) {
            // Log and ignore the error. This seems to be a bug in the android SDK and
            // this is the commonly accepted workaround.
            // https://tinyurl.com/mw6qkod (Stack Overflow)
            Log.w(ReactConstants.TAG, "Error intercepting touch event.", e);
        }

        return false;
    }

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (!mScrollEnabled) {
            return false;
        }

        mVelocityHelper.calculateVelocity(ev);
        int action = ev.getAction() & MotionEvent.ACTION_MASK;
        if (action == MotionEvent.ACTION_UP && mDragging) {
            float velocityX = mVelocityHelper.getXVelocity();
            float velocityY = mVelocityHelper.getYVelocity();
            ReactScrollViewHelper.emitScrollEndDragEvent(this, velocityX, velocityY);
            mDragging = false;
            // After the touch finishes, we may need to do some scrolling afterwards either as a result
            // of a fling or because we need to page align the content
            handlePostTouchScrolling(Math.round(velocityX), Math.round(velocityY));
        }

        return super.onTouchEvent(ev);
    }

    @Override
    public void fling(int velocityX) {
        if (mPagingEnabled) {
            flingAndSnap(velocityX);
        } else if (mScroller != null) {
            // FB SCROLLVIEW CHANGE

            // We provide our own version of fling that uses a different call to the standard OverScroller
            // which takes into account the possibility of adding new content while the ScrollView is
            // animating. Because we give essentially no max X for the fling, the fling will continue as long
            // as there is content. See #onOverScrolled() to see the second part of this change which properly
            // aborts the scroller animation when we get to the bottom of the ScrollView content.

            int scrollWindowWidth = getWidth() - getPaddingStart() - getPaddingEnd();

            mScroller.fling(getScrollX(), // startX
                    getScrollY(), // startY
                    velocityX, // velocityX
                    0, // velocityY
                    0, // minX
                    Integer.MAX_VALUE, // maxX
                    0, // minY
                    0, // maxY
                    scrollWindowWidth / 2, // overX
                    0 // overY
            );

            ViewCompat.postInvalidateOnAnimation(this);

            // END FB SCROLLVIEW CHANGE
        } else {
            super.fling(velocityX);
        }
        handlePostTouchScrolling(velocityX, 0);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        if (mRemoveClippedSubviews) {
            updateClippingRect();
        }
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        if (mRemoveClippedSubviews) {
            updateClippingRect();
        }
    }

    @Override
    public void updateClippingRect() {
        if (!mRemoveClippedSubviews) {
            return;
        }

        Assertions.assertNotNull(mClippingRect);

        ReactClippingViewGroupHelper.calculateClippingRect(this, mClippingRect);
        View contentView = getChildAt(0);
        if (contentView instanceof ReactClippingViewGroup) {
            ((ReactClippingViewGroup) contentView).updateClippingRect();
        }
    }

    @Override
    public void getClippingRect(Rect outClippingRect) {
        outClippingRect.set(Assertions.assertNotNull(mClippingRect));
    }

    private int getSnapInterval() {
        if (mSnapInterval != 0) {
            return mSnapInterval;
        }
        return getWidth();
    }

    public void setEndFillColor(int color) {
        if (color != mEndFillColor) {
            mEndFillColor = color;
            mEndBackground = new ColorDrawable(mEndFillColor);
        }
    }

    @Override
    protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolean clampedY) {
        if (mScroller != null) {
            // FB SCROLLVIEW CHANGE

            // This is part two of the reimplementation of fling to fix the bounce-back bug. See #fling() for
            // more information.

            if (!mScroller.isFinished() && mScroller.getCurrX() != mScroller.getFinalX()) {
                int scrollRange = computeHorizontalScrollRange() - getWidth();
                if (scrollX >= scrollRange) {
                    mScroller.abortAnimation();
                    scrollX = scrollRange;
                }
            }

            // END FB SCROLLVIEW CHANGE
        }

        super.onOverScrolled(scrollX, scrollY, clampedX, clampedY);
    }

    private void enableFpsListener() {
        if (isScrollPerfLoggingEnabled()) {
            Assertions.assertNotNull(mFpsListener);
            Assertions.assertNotNull(mScrollPerfTag);
            mFpsListener.enable(mScrollPerfTag);
        }
    }

    private void disableFpsListener() {
        if (isScrollPerfLoggingEnabled()) {
            Assertions.assertNotNull(mFpsListener);
            Assertions.assertNotNull(mScrollPerfTag);
            mFpsListener.disable(mScrollPerfTag);
        }
    }

    private boolean isScrollPerfLoggingEnabled() {
        return mFpsListener != null && mScrollPerfTag != null && !mScrollPerfTag.isEmpty();
    }

    @Override
    public void draw(Canvas canvas) {
        if (mEndFillColor != Color.TRANSPARENT) {
            final View content = getChildAt(0);
            if (mEndBackground != null && content != null && content.getRight() < getWidth()) {
                mEndBackground.setBounds(content.getRight(), 0, getWidth(), getHeight());
                mEndBackground.draw(canvas);
            }
        }
        super.draw(canvas);
    }

    /**
     * This handles any sort of scrolling that may occur after a touch is finished.  This may be
     * momentum scrolling (fling) or because you have pagingEnabled on the scroll view.  Because we
     * don't get any events from Android about this lifecycle, we do all our detection by creating a
     * runnable that checks if we scrolled in the last frame and if so assumes we are still scrolling.
     */
    private void handlePostTouchScrolling(int velocityX, int velocityY) {
        // If we aren't going to do anything (send events or snap to page), we can early exit out.
        if (!mSendMomentumEvents && !mPagingEnabled && !isScrollPerfLoggingEnabled()) {
            return;
        }

        // Check if we are already handling this which may occur if this is called by both the touch up
        // and a fling call
        if (mPostTouchRunnable != null) {
            return;
        }

        if (mSendMomentumEvents) {
            ReactScrollViewHelper.emitScrollMomentumBeginEvent(this, velocityX, velocityY);
        }

        mActivelyScrolling = false;
        mPostTouchRunnable = new Runnable() {

            private boolean mSnappingToPage = false;

            @Override
            public void run() {
                if (mActivelyScrolling) {
                    // We are still scrolling so we just post to check again a frame later
                    mActivelyScrolling = false;
                    ViewCompat.postOnAnimationDelayed(ReactHorizontalScrollView.this, this,
                            ReactScrollViewHelper.MOMENTUM_DELAY);
                } else {
                    if (mPagingEnabled && !mSnappingToPage) {
                        // Only if we have pagingEnabled and we have not snapped to the page do we
                        // need to continue checking for the scroll.  And we cause that scroll by asking for it
                        mSnappingToPage = true;
                        flingAndSnap(0);
                        ViewCompat.postOnAnimationDelayed(ReactHorizontalScrollView.this, this,
                                ReactScrollViewHelper.MOMENTUM_DELAY);
                    } else {
                        if (mSendMomentumEvents) {
                            ReactScrollViewHelper.emitScrollMomentumEndEvent(ReactHorizontalScrollView.this);
                        }
                        ReactHorizontalScrollView.this.mPostTouchRunnable = null;
                        disableFpsListener();
                    }
                }
            }
        };
        ViewCompat.postOnAnimationDelayed(ReactHorizontalScrollView.this, mPostTouchRunnable,
                ReactScrollViewHelper.MOMENTUM_DELAY);
    }

    private int predictFinalScrollPosition(int velocityX) {
        // ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's
        // no way to customize the scroll duration. So, we create a temporary OverScroller
        // so we can predict where a fling would land and snap to nearby that point.
        OverScroller scroller = new OverScroller(getContext());
        scroller.setFriction(1.0f - mDecelerationRate);

        // predict where a fling would end up so we can scroll to the nearest snap offset
        int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth());
        int width = getWidth() - getPaddingStart() - getPaddingEnd();
        scroller.fling(getScrollX(), // startX
                getScrollY(), // startY
                velocityX, // velocityX
                0, // velocityY
                0, // minX
                maximumOffset, // maxX
                0, // minY
                0, // maxY
                width / 2, // overX
                0 // overY
        );
        return scroller.getFinalX();
    }

    /**
     * This will smooth scroll us to the nearest snap offset point
     * It currently just looks at where the content is and slides to the nearest point.
     * It is intended to be run after we are done scrolling, and handling any momentum scrolling.
     */
    private void smoothScrollAndSnap(int velocity) {
        double interval = (double) getSnapInterval();
        double currentOffset = (double) getScrollX();
        double targetOffset = (double) predictFinalScrollPosition(velocity);

        int previousPage = (int) Math.floor(currentOffset / interval);
        int nextPage = (int) Math.ceil(currentOffset / interval);
        int currentPage = (int) Math.round(currentOffset / interval);
        int targetPage = (int) Math.round(targetOffset / interval);

        if (velocity > 0 && nextPage == previousPage) {
            nextPage++;
        } else if (velocity < 0 && previousPage == nextPage) {
            previousPage--;
        }

        if (
        // if scrolling towards next page
        velocity > 0 &&
        // and the middle of the page hasn't been crossed already
                currentPage < nextPage &&
                // and it would have been crossed after flinging
                targetPage > previousPage) {
            currentPage = nextPage;
        } else if (
        // if scrolling towards previous page
        velocity < 0 &&
        // and the middle of the page hasn't been crossed already
                currentPage > previousPage &&
                // and it would have been crossed after flinging
                targetPage < nextPage) {
            currentPage = previousPage;
        }

        targetOffset = currentPage * interval;
        if (targetOffset != currentOffset) {
            mActivelyScrolling = true;
            smoothScrollTo((int) targetOffset, getScrollY());
        }
    }

    private void flingAndSnap(int velocityX) {
        if (getChildCount() <= 0) {
            return;
        }

        // pagingEnabled only allows snapping one interval at a time
        if (mSnapInterval == 0 && mSnapOffsets == null) {
            smoothScrollAndSnap(velocityX);
            return;
        }

        int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth());
        int targetOffset = predictFinalScrollPosition(velocityX);
        int smallerOffset = 0;
        int largerOffset = maximumOffset;
        int firstOffset = 0;
        int lastOffset = maximumOffset;
        int width = getWidth() - getPaddingStart() - getPaddingEnd();

        // offsets are from the right edge in RTL layouts
        boolean isRTL = TextUtilsCompat
                .getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL;
        if (isRTL) {
            targetOffset = maximumOffset - targetOffset;
            velocityX = -velocityX;
        }

        // get the nearest snap points to the target offset
        if (mSnapOffsets != null) {
            firstOffset = mSnapOffsets.get(0);
            lastOffset = mSnapOffsets.get(mSnapOffsets.size() - 1);

            for (int i = 0; i < mSnapOffsets.size(); i++) {
                int offset = mSnapOffsets.get(i);

                if (offset <= targetOffset) {
                    if (targetOffset - offset < targetOffset - smallerOffset) {
                        smallerOffset = offset;
                    }
                }

                if (offset >= targetOffset) {
                    if (offset - targetOffset < largerOffset - targetOffset) {
                        largerOffset = offset;
                    }
                }
            }
        } else {
            double interval = (double) getSnapInterval();
            double ratio = (double) targetOffset / interval;
            smallerOffset = (int) (Math.floor(ratio) * interval);
            largerOffset = (int) (Math.ceil(ratio) * interval);
        }

        // Calculate the nearest offset
        int nearestOffset = targetOffset - smallerOffset < largerOffset - targetOffset ? smallerOffset
                : largerOffset;

        // if scrolling after the last snap offset and snapping to the
        // end of the list is disabled, then we allow free scrolling
        int currentOffset = getScrollX();
        if (isRTL) {
            currentOffset = maximumOffset - currentOffset;
        }
        if (!mSnapToEnd && targetOffset >= lastOffset) {
            if (currentOffset >= lastOffset) {
                // free scrolling
            } else {
                // snap to end
                targetOffset = lastOffset;
            }
        } else if (!mSnapToStart && targetOffset <= firstOffset) {
            if (currentOffset <= firstOffset) {
                // free scrolling
            } else {
                // snap to beginning
                targetOffset = firstOffset;
            }
        } else if (velocityX > 0) {
            // when snapping velocity can feel sluggish for slow swipes
            velocityX += (int) ((largerOffset - targetOffset) * 10.0);

            targetOffset = largerOffset;
        } else if (velocityX < 0) {
            // when snapping velocity can feel sluggish for slow swipes
            velocityX -= (int) ((targetOffset - smallerOffset) * 10.0);

            targetOffset = smallerOffset;
        } else {
            targetOffset = nearestOffset;
        }

        // Make sure the new offset isn't out of bounds
        targetOffset = Math.min(Math.max(0, targetOffset), maximumOffset);

        if (isRTL) {
            targetOffset = maximumOffset - targetOffset;
            velocityX = -velocityX;
        }

        // smoothScrollTo will always scroll over 250ms which is often *waaay*
        // too short and will cause the scrolling to feel almost instant
        // try to manually interact with OverScroller instead
        // if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo
        if (mScroller != null) {
            mActivelyScrolling = true;

            mScroller.fling(getScrollX(), // startX
                    getScrollY(), // startY
                    // velocity = 0 doesn't work with fling() so we pretend there's a reasonable
                    // initial velocity going on when a touch is released without any movement
                    velocityX != 0 ? velocityX : targetOffset - getScrollX(), // velocityX
                    0, // velocityY
                    // setting both minX and maxX to the same value will guarantee that we scroll to it
                    // but using the standard fling-style easing rather than smoothScrollTo's 250ms animation
                    targetOffset, // minX
                    targetOffset, // maxX
                    0, // minY
                    0, // maxY
                    // we only want to allow overscrolling if the final offset is at the very edge of the view
                    (targetOffset == 0 || targetOffset == maximumOffset) ? width / 2 : 0, // overX
                    0 // overY
            );

            postInvalidateOnAnimation();
        } else {
            smoothScrollTo(targetOffset, getScrollY());
        }
    }

    @Override
    public void setBackgroundColor(int color) {
        mReactBackgroundManager.setBackgroundColor(color);
    }

    public void setBorderWidth(int position, float width) {
        mReactBackgroundManager.setBorderWidth(position, width);
    }

    public void setBorderColor(int position, float color, float alpha) {
        mReactBackgroundManager.setBorderColor(position, color, alpha);
    }

    public void setBorderRadius(float borderRadius) {
        mReactBackgroundManager.setBorderRadius(borderRadius);
    }

    public void setBorderRadius(float borderRadius, int position) {
        mReactBackgroundManager.setBorderRadius(borderRadius, position);
    }

    public void setBorderStyle(@Nullable String style) {
        mReactBackgroundManager.setBorderStyle(style);
    }

}