// Copyright 2004-present Facebook. All Rights Reserved.
package com.facebook.react.views.recyclerview;
import java.util.ArrayList;
import java.util.List;
import android.content.Context;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import com.facebook.infer.annotation.Assertions;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.common.annotations.VisibleForTesting;
import com.facebook.react.uimanager.UIManagerModule;
import com.facebook.react.uimanager.events.ContentSizeChangeEvent;
import com.facebook.react.uimanager.events.NativeGestureUtil;
import com.facebook.react.views.scroll.ScrollEvent;
import com.facebook.react.views.scroll.ScrollEventType;
/**
* Wraps {@link RecyclerView} providing interface similar to `ScrollView.js` where each children
* will be rendered as a separate {@link RecyclerView} row.
*
* Currently supports only vertically positioned item. Views will not be automatically recycled but
* they will be detached from native view hierarchy when scrolled offscreen.
*
* It works by storing all child views in an array within adapter and binding appropriate views to
* rows when requested.
*/
@VisibleForTesting
public class RecyclerViewBackedScrollView extends RecyclerView {
/**
* Simple implementation of {@link ViewHolder} as it's an abstract class. The only thing we need
* to hold in this implementation is the reference to {@link RecyclableWrapperViewGroup} that
* is already stored by default.
*/
private static class ConcreteViewHolder extends ViewHolder {
public ConcreteViewHolder(View itemView) {
super(itemView);
}
}
/**
* View that is going to be used as a cell in {@link RecyclerView}. It's going to be reusable and
* we will remove/attach views for a certain positions based on the {@code mViews} array stored
* in the adapter class.
*
* This method overrides {@link #onMeasure} and delegates measurements to the child view that has
* been attached to. This is because instances of {@link RecyclableWrapperViewGroup} are created
* outside of {@link NativeViewHierarchyManager} and their layout is not managed by that manager
* as opposed to all the other react-native views. Instead we use dimensions of the child view
* (dimensions has been set in layouting process) so that size of this view match the size of
* the view it wraps.
*/
private static class RecyclableWrapperViewGroup extends ViewGroup {
public RecyclableWrapperViewGroup(Context context) {
super(context);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// This view will only have one child that is managed by the `NativeViewHierarchyManager` and
// its position and dimensions are set separately. We don't need to handle its layouting here
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (getChildCount() > 0) {
// We override measure spec and use dimensions of the children. Children is a view added
// from the adapter and always have a correct dimensions specified as they are calculated
// and set with NativeViewHierarchyManager
View child = getChildAt(0);
setMeasuredDimension(child.getMeasuredWidth(), child.getMeasuredHeight());
} else {
Assertions.assertUnreachable("RecyclableWrapperView measured but no view attached");
}
}
}
/**
* JavaScript ListView implementation rely on getting correct scroll offset. This class helps
* with calculating that "real" offset of items in recycler view as those are not provided by
* android widget implementation ({@link #onScrollChanged} is called with offset 0). We can't use
* onScrolled either as we need to take into account that if height of element that is not above
* the visible window changes the real scroll offset will change too, but onScrolled will only
* give us scroll deltas that comes from the user interaction.
*
* This class helps in calculating "real" offset of row at specified index. It's used from
* {@link #onScrollChanged} to query for the first visible index. Since while scrolling the
* queried index will usually increment or decrement by one it's optimize to return result in
* that common case very quickly.
*/
private static class ScrollOffsetTracker {
private final ReactListAdapter mReactListAdapter;
private int mLastRequestedPosition;
private int mOffsetForLastPosition;
private ScrollOffsetTracker(ReactListAdapter reactListAdapter) {
mReactListAdapter = reactListAdapter;
}
public void onHeightChange(int index, int oldHeight, int newHeight) {
if (index < mLastRequestedPosition) {
mOffsetForLastPosition = (mOffsetForLastPosition - oldHeight + newHeight);
}
}
public int getTopOffsetForItem(int index) {
// This method is frequently called from the "onScroll" handler of the "RecyclerView" with an
// index of first visible item of the view. Implementation of this method takes advantage of
// that fact by caching the value for the last index that this method has been called with.
//
// There are a 2 cases that we optimize for:
// 1) The visible item doesn't change between subsequent "onScroll" calls, in that case we
// don't need to calculate anything, just return the cached value
// 2) The next visible item will be the one that is adjacent to the item that we store the
// cached value for: index + 1 when scrolling down or index - 1 when scrolling up. Then it
// is sufficient to add/subtract height of item at the "last index"
//
// The implementation accounts for the cases when next index is not necessarily a subsequent
// number of the cached one. In which case we try to minimize the number of rows we will loop
// through.
if (mLastRequestedPosition != index) {
int sum;
if (mLastRequestedPosition < index) {
// This can either happen when we're scrolling down or if the cached value has never been
// calculated
int startIndex;
if (mLastRequestedPosition != -1) {
// We already have the value cached, let's use it and only add heights of the items
// starting at the index we have the cached value for
sum = mOffsetForLastPosition;
startIndex = mLastRequestedPosition;
} else {
sum = 0;
startIndex = 0;
}
for (int i = startIndex; i < index; i++) {
sum += mReactListAdapter.mViews.get(i).getMeasuredHeight();
}
} else {
// We are scrolling up, we can either use cached value and subtract heights of rows
// between mLastRequestPosition and index, or we can calculate the height starting from 0
// (this can be quite a frequent case as well, when the list implements "jump to the top"
// action). We just go for the option that require less calculations
if (index < (mLastRequestedPosition - index)) {
// index is relatively small, it's faster to calculate the sum starting from 0
sum = 0;
for (int i = 0; i < index; i++) {
sum += mReactListAdapter.mViews.get(i).getMeasuredHeight();
}
} else {
// index is "closer" to the last cached index than it is to 0. We can reuse cached sum
// and calculate the new sum by subtracting heights of the elements between
// "mLastRequestPosition" and "index"
sum = mOffsetForLastPosition;
for (int i = mLastRequestedPosition - 1; i >= index; i--) {
sum -= mReactListAdapter.mViews.get(i).getMeasuredHeight();
}
}
}
mLastRequestedPosition = index;
mOffsetForLastPosition = sum;
}
return mOffsetForLastPosition;
}
}
/*package*/ static class ReactListAdapter extends Adapter<ConcreteViewHolder> {
private final List<View> mViews = new ArrayList<>();
private final ScrollOffsetTracker mScrollOffsetTracker;
private final RecyclerViewBackedScrollView mScrollView;
private int mTotalChildrenHeight = 0;
// The following `OnLayoutChangeListsner` is attached to the views stored in the adapter
// `mViews` array. It's used to get layout information passed to that view from css-layout
// and to update its layout to be enclosed in the wrapper view group.
private final View.OnLayoutChangeListener
mChildLayoutChangeListener = new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(
View v,
int left,
int top,
int right,
int bottom,
int oldLeft,
int oldTop,
int oldRight,
int oldBottom) {
// We need to get layout information from css-layout to set the size of the rows correctly.
int oldHeight = (oldBottom - oldTop);
int newHeight = (bottom - top);
if (oldHeight != newHeight) {
updateTotalChildrenHeight(newHeight - oldHeight);
mScrollOffsetTracker.onHeightChange(mViews.indexOf(v), oldHeight, newHeight);
// Since "wrapper" view position +dimensions are not managed by NativeViewHierarchyManager
// we need to ensure that the wrapper view is properly layed out as it dimension should
// be updated if the wrapped view dimensions are changed.
// To achieve that we call `forceLayout()` on the view modified and on `RecyclerView`
// instance (which is accessible with `v.getParent().getParent()` if the view is
// attached). We rely on NativeViewHierarchyManager to call `layout` on `RecyclerView`
// then, which will happen once all the children of `RecyclerView` have their layout
// updated. This will trigger `layout` call on attached wrapper nodes and will let us
// update dimensions of them through overridden onMeasure method.
// We don't care about calling this is the view is not currently attached as it would be
// laid out once added to the recycler.
if (v.getParent() != null
&& v.getParent().getParent() != null) {
View wrapper = (View) v.getParent(); // native view that wraps view added to adapter
wrapper.forceLayout();
// wrapper.getParent() points to the recycler if the view is currently attached (it
// could be in "scrape" state when it is attached to recyclable wrapper but not to
// the recycler)
((View) wrapper.getParent()).forceLayout();
}
}
}
};
public ReactListAdapter(RecyclerViewBackedScrollView scrollView) {
mScrollView = scrollView;
mScrollOffsetTracker = new ScrollOffsetTracker(this);
setHasStableIds(true);
}
public void addView(View child, int index) {
mViews.add(index, child);
updateTotalChildrenHeight(child.getMeasuredHeight());
child.addOnLayoutChangeListener(mChildLayoutChangeListener);
notifyItemInserted(index);
}
public void removeViewAt(int index) {
View child = mViews.get(index);
if (child != null) {
mViews.remove(index);
child.removeOnLayoutChangeListener(mChildLayoutChangeListener);
updateTotalChildrenHeight(-child.getMeasuredHeight());
notifyItemRemoved(index);
}
}
private void updateTotalChildrenHeight(int delta) {
if (delta != 0) {
mTotalChildrenHeight += delta;
mScrollView.onTotalChildrenHeightChange(mTotalChildrenHeight);
}
}
@Override
public ConcreteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new ConcreteViewHolder(new RecyclableWrapperViewGroup(parent.getContext()));
}
@Override
public void onBindViewHolder(ConcreteViewHolder holder, int position) {
RecyclableWrapperViewGroup vg = (RecyclableWrapperViewGroup) holder.itemView;
View row = mViews.get(position);
if (row.getParent() != vg) {
vg.addView(row, 0);
}
}
@Override
public void onViewRecycled(ConcreteViewHolder holder) {
super.onViewRecycled(holder);
((RecyclableWrapperViewGroup) holder.itemView).removeAllViews();
}
@Override
public int getItemCount() {
return mViews.size();
}
@Override
public long getItemId(int position) {
return mViews.get(position).getId();
}
public View getView(int index) {
return mViews.get(index);
}
public int getTotalChildrenHeight() {
return mTotalChildrenHeight;
}
public int getTopOffsetForItem(int index) {
return mScrollOffsetTracker.getTopOffsetForItem(index);
}
}
private boolean mSendContentSizeChangeEvents;
public void setSendContentSizeChangeEvents(boolean sendContentSizeChangeEvents) {
mSendContentSizeChangeEvents = sendContentSizeChangeEvents;
}
private int calculateAbsoluteOffset() {
int offsetY = 0;
if (getChildCount() > 0) {
View recyclerViewChild = getChildAt(0);
int childPosition = getChildViewHolder(recyclerViewChild).getLayoutPosition();
offsetY = ((ReactListAdapter) getAdapter()).getTopOffsetForItem(childPosition) -
recyclerViewChild.getTop();
}
return offsetY;
}
/*package*/ void scrollTo(int scrollX, int scrollY, boolean animated) {
int deltaY = scrollY - calculateAbsoluteOffset();
if (animated) {
smoothScrollBy(0, deltaY);
} else {
scrollBy(0, deltaY);
}
}
@Override
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
((ReactContext) getContext()).getNativeModule(UIManagerModule.class).getEventDispatcher()
.dispatchEvent(ScrollEvent.obtain(
getId(),
ScrollEventType.SCROLL,
0, /* offsetX = 0, horizontal scrolling only */
calculateAbsoluteOffset(),
getWidth(),
((ReactListAdapter) getAdapter()).getTotalChildrenHeight(),
getWidth(),
getHeight()));
}
private void onTotalChildrenHeightChange(int newTotalChildrenHeight) {
if (mSendContentSizeChangeEvents) {
((ReactContext) getContext()).getNativeModule(UIManagerModule.class).getEventDispatcher()
.dispatchEvent(new ContentSizeChangeEvent(
getId(),
getWidth(),
newTotalChildrenHeight));
}
}
public RecyclerViewBackedScrollView(Context context) {
super(context);
setHasFixedSize(true);
setItemAnimator(new NotAnimatedItemAnimator());
setLayoutManager(new LinearLayoutManager(context));
setAdapter(new ReactListAdapter(this));
}
/*package*/ void addViewToAdapter(View child, int index) {
((ReactListAdapter) getAdapter()).addView(child, index);
}
/*package*/ void removeViewFromAdapter(int index) {
((ReactListAdapter) getAdapter()).removeViewAt(index);
}
/*package*/ View getChildAtFromAdapter(int index) {
return ((ReactListAdapter) getAdapter()).getView(index);
}
/*package*/ int getChildCountFromAdapter() {
return getAdapter().getItemCount();
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (super.onInterceptTouchEvent(ev)) {
NativeGestureUtil.notifyNativeGestureStarted(this, ev);
return true;
}
return false;
}
}