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

Java tutorial

Introduction

Here is the source code for com.facebook.react.views.scroll.ReactScrollView.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.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.support.v4.view.ViewCompat;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.OverScroller;
import android.widget.ScrollView;

import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.ReactContext;
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 javax.annotation.Nullable;

/**
 * A simple subclass of ScrollView that doesn't dispatch measure and layout to its children and has
 * a scroll listener to send scroll events to JS.
 *
 * <p>ReactScrollView only supports vertical scrolling. For horizontal scrolling,
 * use {@link ReactHorizontalScrollView}.
 */
@TargetApi(11)
public class ReactScrollView extends ScrollView
        implements ReactClippingViewGroup, ViewGroup.OnHierarchyChangeListener, View.OnLayoutChangeListener {

    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(); // for reuse to avoid allocation

    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 View mContentView;
    private ReactViewBackgroundManager mReactBackgroundManager;

    public ReactScrollView(ReactContext context) {
        this(context, null);
    }

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

        mScroller = getOverScrollerFromParent();
        setOnHierarchyChangeListener(this);
        setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY);
    }

    @Nullable
    private OverScroller getOverScrollerFromParent() {
        OverScroller scroller;

        if (!sTriedToGetScrollerField) {
            sTriedToGetScrollerField = true;
            try {
                sScrollerField = ScrollView.class.getDeclaredField("mScroller");
                sScrollerField.setAccessible(true);
            } catch (NoSuchFieldException e) {
                Log.w(ReactConstants.TAG, "Failed to get mScroller field for ScrollView! "
                        + "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 ScrollView (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 ScrollView!", e);
            }
        } else {
            scroller = null;
        }

        return scroller;
    }

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

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

    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 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 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
    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 setRemoveClippedSubviews(boolean removeClippedSubviews) {
        if (removeClippedSubviews && mClippingRect == null) {
            mClippingRect = new Rect();
        }
        mRemoveClippedSubviews = removeClippedSubviews;
        updateClippingRect();
    }

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

    @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));
    }

    @Override
    public void fling(int velocityY) {
        if (mPagingEnabled) {
            flingAndSnap(velocityY);
        } 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 Y 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 scrollWindowHeight = getHeight() - getPaddingBottom() - getPaddingTop();

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

            ViewCompat.postInvalidateOnAnimation(this);

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

    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();
    }

    private int getMaxScrollY() {
        int contentHeight = mContentView.getHeight();
        int viewportHeight = getHeight() - getPaddingBottom() - getPaddingTop();
        return Math.max(0, contentHeight - viewportHeight);
    }

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

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

        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) {
            enableFpsListener();
            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(ReactScrollView.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(ReactScrollView.this, this,
                                ReactScrollViewHelper.MOMENTUM_DELAY);
                    } else {
                        if (mSendMomentumEvents) {
                            ReactScrollViewHelper.emitScrollMomentumEndEvent(ReactScrollView.this);
                        }
                        ReactScrollView.this.mPostTouchRunnable = null;
                        disableFpsListener();
                    }
                }
            }
        };
        ViewCompat.postOnAnimationDelayed(ReactScrollView.this, mPostTouchRunnable,
                ReactScrollViewHelper.MOMENTUM_DELAY);
    }

    private int predictFinalScrollPosition(int velocityY) {
        // 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 = getMaxScrollY();
        int height = getHeight() - getPaddingBottom() - getPaddingTop();
        scroller.fling(getScrollX(), // startX
                getScrollY(), // startY
                0, // velocityX
                velocityY, // velocityY
                0, // minX
                0, // maxX
                0, // minY
                maximumOffset, // maxY
                0, // overX
                height / 2 // overY
        );
        return scroller.getFinalY();
    }

    /**
     * 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) getScrollY();
        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(getScrollX(), (int) targetOffset);
        }
    }

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

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

        int maximumOffset = getMaxScrollY();
        int targetOffset = predictFinalScrollPosition(velocityY);
        int smallerOffset = 0;
        int largerOffset = maximumOffset;
        int firstOffset = 0;
        int lastOffset = maximumOffset;
        int height = getHeight() - getPaddingBottom() - getPaddingTop();

        // 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
        if (!mSnapToEnd && targetOffset >= lastOffset) {
            if (getScrollY() >= lastOffset) {
                // free scrolling
            } else {
                // snap to end
                targetOffset = lastOffset;
            }
        } else if (!mSnapToStart && targetOffset <= firstOffset) {
            if (getScrollY() <= firstOffset) {
                // free scrolling
            } else {
                // snap to beginning
                targetOffset = firstOffset;
            }
        } else if (velocityY > 0) {
            // when snapping velocity can feel sluggish for slow swipes
            velocityY += (int) ((largerOffset - targetOffset) * 10.0);

            targetOffset = largerOffset;
        } else if (velocityY < 0) {
            // when snapping velocity can feel sluggish for slow swipes
            velocityY -= (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);

        // 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
                    0, // velocityX
                    velocityY != 0 ? velocityY : targetOffset - getScrollY(), // velocityY
                    0, // minX
                    0, // maxX
                    // setting both minY and maxY 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, // minY
                    targetOffset, // maxY
                    0, // overX
                    // we only want to allow overscrolling if the final offset is at the very edge of the view
                    (targetOffset == 0 || targetOffset == maximumOffset) ? height / 2 : 0 // overY
            );

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

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

    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.getCurrY() != mScroller.getFinalY()) {
                int scrollRange = getMaxScrollY();
                if (scrollY >= scrollRange) {
                    mScroller.abortAnimation();
                    scrollY = scrollRange;
                }
            }

            // END FB SCROLLVIEW CHANGE
        }

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

    @Override
    public void onChildViewAdded(View parent, View child) {
        mContentView = child;
        mContentView.addOnLayoutChangeListener(this);
    }

    @Override
    public void onChildViewRemoved(View parent, View child) {
        mContentView.removeOnLayoutChangeListener(this);
        mContentView = null;
    }

    /**
     * Called when a mContentView's layout has changed. Fixes the scroll position if it's too large
     * after the content resizes. Without this, the user would see a blank ScrollView when the scroll
     * position is larger than the ScrollView's max scroll position after the content shrinks.
     */
    @Override
    public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop,
            int oldRight, int oldBottom) {
        if (mContentView == null) {
            return;
        }

        int currentScrollY = getScrollY();
        int maxScrollY = getMaxScrollY();
        if (currentScrollY > maxScrollY) {
            scrollTo(getScrollX(), maxScrollY);
        }
    }

    @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);
    }

}