Skip to content

Instantly share code, notes, and snippets.

@Strate
Last active March 4, 2021 18:22
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Strate/1bfb72b66379db7e73f89c57cd2103e7 to your computer and use it in GitHub Desktop.
Save Strate/1bfb72b66379db7e73f89c57cd2103e7 to your computer and use it in GitHub Desktop.
package com.chatium.app.scrollview;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.widget.OverScroller;
import android.widget.ScrollView;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.uimanager.events.RCTEventEmitter;
import com.facebook.react.views.scroll.FpsListener;
import com.facebook.react.views.scroll.OnScrollDispatchHelper;
import java.lang.reflect.Field;
import javax.annotation.Nullable;
public class ReactScrollView extends com.facebook.react.views.scroll.ReactScrollView {
private static @Nullable
Field sScrollerField;
private static boolean sTriedToGetScrollerField = false;
private ViewGroup mContentViewGroup;
private @Nullable
View mFirstVisibleChild;
private int mFirstVisibleChildTop = 0;
private @Nullable
OverScroller mScroller;
public boolean maintainVisibleContentPosition_enabled = false;
public int maintainVisibleContentPosition_minIndexForVisible = 0;
private boolean mInitialScrollItem_OnceScrolled = false;
public boolean mInitialScroll_Enabled = false;
public boolean mInitialScroll_End = false;
public int mInitialScroll_Index;
public int mInitialScroll_Offset = 0;
public boolean mInitialScroll_StickToBottom = false;
private boolean mContentViewGroupInitialLayoutFound = false;
private @Nullable
VisibleItemsRange mPreviousVisibleItemsRange;
private ViewTreeObserver.OnPreDrawListener mPreDrawListener;
private OnHierarchyChangeListener mOnContentViewGroupHierarchyChangeListener;
public ReactScrollView(ReactContext context) {
super(context, null);
}
public ReactScrollView(ReactContext context, @Nullable FpsListener fpsListener) {
super(context, fpsListener);
mScroller = getOverScrollerFromParent();
mPreDrawListener = new ViewTreeObserver.OnPreDrawListener() {
@Override
public boolean onPreDraw() {
if (mContentViewGroup != null) {
int currentScrollY = getScrollY();
if (mFirstVisibleChild != null && mContentViewGroup.indexOfChild(mFirstVisibleChild) >= 0) {
int deltaY = mFirstVisibleChild.getTop() - mContentViewGroup.getPaddingTop() - mFirstVisibleChildTop;
if (deltaY != 0) {
setScrollY(getScrollY() + deltaY);
if (getScrollY() == currentScrollY) {
return true;
}
if (mScroller != null && !mScroller.isFinished()) {
int scrollWindowHeight = getHeight() - getPaddingBottom() - getPaddingTop();
int direction = mScroller.getCurrY() - mScroller.getStartY() >= 0 ? 1 : -1;
int currentVelocity = direction * (int) mScroller.getCurrVelocity();
mScroller.abortAnimation();
mScroller.fling(
getScrollX(),
getScrollY(),
0,
currentVelocity,
0, // minX
0, // maxX
0, // minY
Integer.MAX_VALUE, // maxY
0, // overX
scrollWindowHeight / 2 // overY
);
}
return false;
}
} else {
captureFirstVisibleChild();
}
if (getScrollY() < 0) {
setScrollY(0);
if (getScrollY() == currentScrollY) {
return true;
}
if (mScroller != null && !mScroller.isFinished()) {
mScroller.abortAnimation();
}
return false;
}
if (mContentViewGroup != null) {
int contentHeight = mContentViewGroup.getHeight() - mContentViewGroup.getPaddingBottom() - mContentViewGroup.getPaddingTop();
int viewportHeight = getHeight() - getPaddingBottom() - getPaddingTop();
int maxScrollY = Math.max(0, contentHeight - viewportHeight);
if (getScrollY() > maxScrollY) {
setScrollY(maxScrollY);
if (getScrollY() == currentScrollY) {
return true;
}
if (mScroller != null && !mScroller.isFinished()) {
mScroller.abortAnimation();
}
return false;
}
}
}
return true;
}
};
mOnContentViewGroupHierarchyChangeListener = new OnHierarchyChangeListener() {
@Override
public void onChildViewAdded(View parent, View child) {
emitVisibleItemsRangeChangedEvent();
}
@Override
public void onChildViewRemoved(View parent, View child) {
emitVisibleItemsRangeChangedEvent();
}
};
}
@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;
}
@Override
protected void onScrollChanged(int x, int y, int oldX, int oldY) {
captureFirstVisibleChild();
emitVisibleItemsRangeChangedEvent();
super.onScrollChanged(x, y, oldX, oldY);
}
private void captureFirstVisibleChild() {
if (maintainVisibleContentPosition_enabled && mContentViewGroup != null) {
for (int i = maintainVisibleContentPosition_minIndexForVisible; i < mContentViewGroup.getChildCount(); i++) {
View currentChild = mContentViewGroup.getChildAt(i);
if (currentChild.getTop() >= getScrollY()) {
mFirstVisibleChild = currentChild;
mFirstVisibleChildTop = currentChild.getTop() - mContentViewGroup.getPaddingTop();
break;
}
}
}
}
@Override
public void onChildViewAdded(View parent, View child) {
ReactScrollView self = this;
super.onChildViewAdded(parent, child);
if (parent == this) {
if (child instanceof ViewGroup) {
mContentViewGroup = (ViewGroup) child;
mContentViewGroup.setOnHierarchyChangeListener(mOnContentViewGroupHierarchyChangeListener);
if (maintainVisibleContentPosition_enabled) {
this.getViewTreeObserver().addOnPreDrawListener(mPreDrawListener);
}
}
}
}
@Override
public void onChildViewRemoved(View parent, View child) {
super.onChildViewRemoved(parent, child);
mFirstVisibleChild = null;
if (mContentViewGroup != null) {
mContentViewGroup.setOnHierarchyChangeListener(null);
}
mContentViewGroup = null;
this.getViewTreeObserver().removeOnPreDrawListener(mPreDrawListener);
}
private void performInitialScroll() {
if (mContentViewGroupInitialLayoutFound && getHeight() > 0) {
if (!mInitialScrollItem_OnceScrolled) {
mInitialScrollItem_OnceScrolled = true;
if (mInitialScroll_Enabled) {
if (mInitialScroll_End) {
setScrollY(mContentViewGroup.getHeight() - getHeight());
} else {
@Nullable View item = mContentViewGroup.getChildAt(mInitialScroll_Index);
if (item != null) {
if (mInitialScroll_StickToBottom) {
setScrollY(item.getBottom() - getHeight() + mInitialScroll_Offset);
} else {
setScrollY(item.getTop() + mInitialScroll_Offset);
}
}
}
emitVisibleItemsRangeChangedEvent();
}
}
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
performInitialScroll();
emitVisibleItemsRangeChangedEvent();
}
/**
* 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) {
mContentViewGroupInitialLayoutFound = true;
performInitialScroll();
emitVisibleItemsRangeChangedEvent();
if (!maintainVisibleContentPosition_enabled) {
super.onLayoutChange(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom);
}
}
public void emitVisibleItemsRangeChangedEvent() {
if (mContentViewGroup != null) {
VisibleItemsRange currentVisibleItemsRange = new VisibleItemsRange();
int minVisibleY = getScrollY() + 5;
int maxVisibleY = getScrollY() + getHeight() - 5;
for (int i = 0; i < mContentViewGroup.getChildCount(); i++) {
View item = mContentViewGroup.getChildAt(i);
if (item != null) {
boolean isItemVisible = (item.getTop() >= minVisibleY && item.getTop() < maxVisibleY) ||
(item.getBottom() > minVisibleY && item.getBottom() <= maxVisibleY) ||
(item.getTop() < minVisibleY && item.getBottom() > maxVisibleY);
if (currentVisibleItemsRange.minIdx == -1) {
if (isItemVisible) {
currentVisibleItemsRange.minIdx = i;
currentVisibleItemsRange.maxIdx = mContentViewGroup.getChildCount() - 1;
}
} else if (!isItemVisible) {
currentVisibleItemsRange.maxIdx = i - 1;
break;
}
}
}
if (
currentVisibleItemsRange.minIdx >= 0 &&
currentVisibleItemsRange.minIdx < mContentViewGroup.getChildCount() &&
currentVisibleItemsRange.maxIdx >= 0 &&
currentVisibleItemsRange.maxIdx < mContentViewGroup.getChildCount()
) {
View fromView = mContentViewGroup.getChildAt(currentVisibleItemsRange.minIdx);
View toView = mContentViewGroup.getChildAt(currentVisibleItemsRange.maxIdx);
if (fromView != null && toView != null) {
currentVisibleItemsRange.fromReactTag = fromView.getId();
currentVisibleItemsRange.toReactTag = toView.getId();
if (
mPreviousVisibleItemsRange == null ||
mPreviousVisibleItemsRange.minIdx != currentVisibleItemsRange.minIdx ||
mPreviousVisibleItemsRange.maxIdx != currentVisibleItemsRange.maxIdx ||
mPreviousVisibleItemsRange.fromReactTag != currentVisibleItemsRange.fromReactTag ||
mPreviousVisibleItemsRange.toReactTag != currentVisibleItemsRange.toReactTag
) {
WritableMap event = Arguments.createMap();
event.putInt("fromIndex", currentVisibleItemsRange.minIdx);
event.putInt("toIndex", currentVisibleItemsRange.maxIdx);
event.putInt("fromReactTag", fromView.getId());
event.putInt("toReactTag", toView.getId());
ReactContext reactContext = (ReactContext) getContext();
reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(
getId(),
"onVisibleItemsRangeChange",
event
);
mPreviousVisibleItemsRange = currentVisibleItemsRange;
}
}
}
}
}
}
package com.chatium.app.scrollview;
import android.util.Log;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.common.MapBuilder;
import com.facebook.react.uimanager.ThemedReactContext;
import com.facebook.react.uimanager.annotations.ReactProp;
import com.facebook.react.views.scroll.FpsListener;
import com.facebook.react.views.scroll.ReactScrollViewCommandHelper;
import java.util.Map;
import javax.annotation.Nullable;
public class ReactScrollViewManager extends com.facebook.react.views.scroll.ReactScrollViewManager {
private @Nullable FpsListener mFpsListener = null;
public ReactScrollViewManager() {
super(null);
}
public ReactScrollViewManager(@Nullable FpsListener fpsListener) {
super(fpsListener);
mFpsListener = fpsListener;
}
@Nullable
@Override
public Map<String, Object> getExportedCustomDirectEventTypeConstants() {
Map<String, Object> superRes = super.getExportedCustomDirectEventTypeConstants();
superRes.put("onVisibleItemsRangeChange", MapBuilder.of("registrationName", "onVisibleItemsRangeChange"));
return superRes;
}
@Override
public ReactScrollView createViewInstance(ThemedReactContext context) {
return new ReactScrollView(context, mFpsListener);
}
@ReactProp(name = "maintainVisibleContentPosition")
public void setMaintainVisibleContentPosition(ReactScrollView view, @Nullable ReadableMap value) {
if (value == null) {
view.maintainVisibleContentPosition_enabled = false;
} else {
view.maintainVisibleContentPosition_enabled = true;
view.maintainVisibleContentPosition_minIndexForVisible = value.getInt("minIndexForVisible");
}
}
@ReactProp(name = "initialScroll")
public void setInitialScroll(ReactScrollView view, @Nullable ReadableMap value) {
if (value == null) {
view.mInitialScroll_Enabled = false;
} else {
view.mInitialScroll_Enabled = true;
if (value.hasKey("end")) {
view.mInitialScroll_End = value.getBoolean("end");
} else {
if (value.hasKey("index")) {
view.mInitialScroll_Index = value.getInt("index");
}
if (value.hasKey("offset")) {
view.mInitialScroll_Offset = value.getInt("offset");
}
if (value.hasKey("stickToBottom")) {
view.mInitialScroll_StickToBottom = value.getBoolean("stickToBottom");
}
}
}
}
}
package com.chatium.app.scrollview;
class VisibleItemsRange {
int minIdx = -1;
int maxIdx = -1;
int fromReactTag = -1;
int toReactTag = -1;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment