-
-
Save anonymous/e605afc18a5a1edd5429c19a1092da2c to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import javax.annotation.Nonnull; | |
import javax.annotation.Nullable; | |
import java.lang.reflect.Field; | |
import java.util.ArrayList; | |
import android.graphics.Canvas; | |
import android.graphics.Color; | |
import android.graphics.Rect; | |
import android.graphics.drawable.ColorDrawable; | |
import android.graphics.drawable.Drawable; | |
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.react.bridge.ReactContext; | |
import com.facebook.react.common.ReactConstants; | |
import com.facebook.react.uimanager.MeasureSpecAssertions; | |
import com.facebook.react.uimanager.events.NativeGestureUtil; | |
import com.facebook.react.uimanager.ReactClippingViewGroup; | |
import com.facebook.react.uimanager.ReactClippingViewGroupHelper; | |
import com.facebook.infer.annotation.Assertions; | |
import com.facebook.react.views.scroll.FpsListener; | |
import com.facebook.react.views.scroll.OnScrollDispatchHelper; | |
import com.facebook.react.views.view.ReactViewGroup; | |
public class CustomScrollView extends ScrollView implements ReactClippingViewGroup, ViewGroup.OnHierarchyChangeListener, View.OnLayoutChangeListener { | |
private static String TAG = CustomScrollView.class.getSimpleName(); | |
private static Field sScrollerField; | |
private static boolean sTriedToGetScrollerField = false; | |
private static Field sAllReactChildrenField; | |
private static boolean sTriedAllReactChildrenField = false; | |
private final OnScrollDispatchHelper mOnScrollDispatchHelper = new OnScrollDispatchHelper(); | |
private final OverScroller mScroller; | |
private | |
@Nullable | |
Rect mClippingRect; | |
private boolean mDoneFlinging; | |
private boolean mDragging; | |
private boolean mFlinging; | |
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 ViewGroup mContentView; | |
private String mAnchorTag; | |
private int mLastAnchorY; | |
public CustomScrollView(ReactContext context) { | |
this(context, null); | |
} | |
public CustomScrollView(ReactContext context, @Nullable FpsListener fpsListener) { | |
super(context); | |
mFpsListener = fpsListener; | |
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 scroller = sScrollerField.get(this); | |
if (scroller instanceof OverScroller) { | |
mScroller = (OverScroller) scroller; | |
} 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 :("); | |
mScroller = null; | |
} | |
} catch (IllegalAccessException e) { | |
throw new RuntimeException("Failed to get mScroller from ScrollView!", e); | |
} | |
} else { | |
mScroller = null; | |
} | |
getAllReactChildrenField(null); | |
setOnHierarchyChangeListener(this); | |
setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY); | |
} | |
// changes | |
public void getAllReactChildrenField(Class clazz) { | |
// inspired by https://github.com/facebook/react-native/blob/master/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java#L75 | |
if (!sTriedAllReactChildrenField) { | |
sTriedAllReactChildrenField = true; | |
try { | |
sAllReactChildrenField = (clazz == null ? ReactViewGroup.class : clazz).getDeclaredField("mAllChildren"); | |
sAllReactChildrenField.setAccessible(true); | |
} catch (NoSuchFieldException e) { | |
Log.e(TAG, "Failed to get mAllChildren field for ReactViewGroup!"); | |
} | |
} | |
} | |
public void setSendMomentumEvents(boolean sendMomentumEvents) { | |
mSendMomentumEvents = sendMomentumEvents; | |
} | |
public void setScrollPerfTag(String scrollPerfTag) { | |
mScrollPerfTag = scrollPerfTag; | |
} | |
public void setScrollEnabled(boolean scrollEnabled) { | |
mScrollEnabled = scrollEnabled; | |
} | |
@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(); | |
} | |
findAnchorView(); | |
} | |
@Override | |
protected void onAttachedToWindow() { | |
super.onAttachedToWindow(); | |
if (mRemoveClippedSubviews) { | |
updateClippingRect(); | |
} | |
findAnchorView(); | |
} | |
// changes | |
protected void findAnchorView() { | |
// Set up anchor view | |
mAnchorTag = null; | |
if (!(mContentView instanceof ReactViewGroup)) { | |
getAllReactChildrenField(mContentView.getClass()); | |
} | |
if (sAllReactChildrenField != null) { | |
try { | |
View[] children = (View[]) sAllReactChildrenField.get(mContentView); | |
if (children != null) { | |
for (int i = 0; i < children.length; i++) { | |
View child = children[i]; | |
if (child == null || !isMessageTag(child)) { | |
continue; | |
} | |
String tag = (String) child.getTag(); | |
if (child.getBottom() >= getScrollY()) { | |
mLastAnchorY = child.getTop(); | |
mAnchorTag = tag; | |
break; | |
} | |
} | |
} | |
} catch (IllegalAccessException e) { | |
Log.e(TAG, "Failed to get mAllChildren field for " + mContentView.getClass().getSimpleName()); | |
} | |
} | |
} | |
@Override | |
protected void onScrollChanged(int x, int y, int oldX, int oldY) { | |
super.onScrollChanged(x, y, oldX, oldY); | |
if (mOnScrollDispatchHelper.onScrollChanged(x, y)) { | |
if (mRemoveClippedSubviews) { | |
updateClippingRect(); | |
} | |
if (mFlinging) { | |
mDoneFlinging = false; | |
} | |
findAnchorView(); | |
CustomScrollViewHelper.emitScrollEvent(this, null); | |
} | |
} | |
@Override | |
public boolean onInterceptTouchEvent(MotionEvent ev) { | |
if (!mScrollEnabled) { | |
return false; | |
} | |
if (super.onInterceptTouchEvent(ev)) { | |
NativeGestureUtil.notifyNativeGestureStarted(this, ev); | |
CustomScrollViewHelper.emitScrollBeginDragEvent(this); | |
mDragging = true; | |
enableFpsListener(); | |
return true; | |
} | |
return false; | |
} | |
@Override | |
public boolean onTouchEvent(MotionEvent ev) { | |
if (!mScrollEnabled) { | |
return false; | |
} | |
int action = ev.getAction() & MotionEvent.ACTION_MASK; | |
if (action == MotionEvent.ACTION_UP && mDragging) { | |
CustomScrollViewHelper.emitScrollEndDragEvent(this); | |
mDragging = false; | |
disableFpsListener(); | |
} | |
return super.onTouchEvent(ev); | |
} | |
@Override | |
public boolean getRemoveClippedSubviews() { | |
return mRemoveClippedSubviews; | |
} | |
@Override | |
public void setRemoveClippedSubviews(boolean removeClippedSubviews) { | |
if (removeClippedSubviews && mClippingRect == null) { | |
mClippingRect = new Rect(); | |
} | |
mRemoveClippedSubviews = removeClippedSubviews; | |
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)); | |
} | |
@Override | |
public void fling(int velocityY) { | |
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(), | |
getScrollY(), | |
0, | |
velocityY, | |
0, | |
0, | |
0, | |
Integer.MAX_VALUE, | |
0, | |
scrollWindowHeight / 2); | |
postInvalidateOnAnimation(); | |
// END FB SCROLLVIEW CHANGE | |
} else { | |
super.fling(velocityY); | |
} | |
if (mSendMomentumEvents || isScrollPerfLoggingEnabled()) { | |
mFlinging = true; | |
enableFpsListener(); | |
CustomScrollViewHelper.emitScrollMomentumBeginEvent(this); | |
Runnable r = new Runnable() { | |
@Override | |
public void run() { | |
if (mDoneFlinging) { | |
mFlinging = false; | |
disableFpsListener(); | |
CustomScrollViewHelper.emitScrollMomentumEndEvent(CustomScrollView.this); | |
} else { | |
mDoneFlinging = true; | |
CustomScrollView.this.postOnAnimationDelayed(this, CustomScrollViewHelper.MOMENTUM_DELAY); | |
} | |
} | |
}; | |
postOnAnimationDelayed(r, CustomScrollViewHelper.MOMENTUM_DELAY); | |
} | |
} | |
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); | |
} | |
// changes | |
public ArrayList<String> getVisibleIds() { | |
ArrayList<String> visibleIds = new ArrayList<>(); | |
if (mContentView instanceof ReactViewGroup) { | |
for (int i = 0; i < mContentView.getChildCount(); i++) { | |
View child = mContentView.getChildAt(i); | |
if (child != null && isMessageTag(child) && isChildVisible(child)) { | |
visibleIds.add((String) child.getTag()); | |
} | |
} | |
} | |
return visibleIds; | |
} | |
// changes | |
private boolean isChildVisible(@Nonnull View child) { | |
int height = getHeight(); | |
int containerTop = getScrollY(); | |
int containerBottom = containerTop + height; | |
int viewTop = child.getTop(); | |
int viewBottom = child.getBottom(); | |
return (viewTop >= containerTop && viewBottom <= containerBottom); | |
} | |
// changes | |
private boolean isMessageTag(@Nonnull View child) { | |
return (child.getTag() instanceof String); | |
} | |
@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); | |
} | |
} | |
super.draw(canvas); | |
// Dispatch event after scrollview is drawn. | |
// Without this, as onScrollChanged() is called before a view draws, | |
// we'd miss to calculate visible ids currently on screen. | |
CustomScrollViewHelper.emitScrollEvent(this, getVisibleIds()); | |
} | |
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) { | |
assert mContentView instanceof ViewGroup; | |
mContentView = (ViewGroup) 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); | |
} | |
// changes | |
if (mAnchorTag != null) { | |
View mAnchorView = null; | |
if (!(mContentView instanceof ReactViewGroup)) { | |
getAllReactChildrenField(mContentView.getClass()); | |
} | |
if (sAllReactChildrenField != null) { | |
try { | |
View[] children = (View[]) sAllReactChildrenField.get(mContentView); | |
if (children != null) { | |
for (int i = 0; i < children.length; i++) { | |
View child = children[i]; | |
if (child != null && mAnchorTag.equals(child.getTag())) { | |
mAnchorView = child; | |
break; | |
} | |
} | |
} | |
} catch (IllegalAccessException e) { | |
Log.e(TAG, "Failed to get mAllChildren field for " + mContentView.getClass().getSimpleName()); | |
} | |
} | |
if (mAnchorView != null) { | |
int anchorChange = mAnchorView.getTop() - mLastAnchorY; | |
scrollTo(getScrollX(), currentScrollY + anchorChange); | |
} | |
} | |
findAnchorView(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment