emptyElement}
249 | header={headerElement}
250 | footer={footerElement} />
251 | );
252 |
253 | stateItemCount = 1;
254 | }
255 |
256 | return (
257 |
261 | {body}
262 |
263 | );
264 | }
265 |
266 | scrollToEnd({ animated = true, velocity } = {}) {
267 | this.scrollToIndex({
268 | index: this.props.dataSource.size() - 1,
269 | animated,
270 | velocity
271 | });
272 | }
273 |
274 | scrollToIndex = ({ animated = true, index, velocity, viewPosition, viewOffset }) => {
275 | index = Math.max(0, Math.min(index, this.props.dataSource.size()-1));
276 |
277 | if (animated) {
278 | UIManager.dispatchViewManagerCommand(
279 | ReactNative.findNodeHandle(this),
280 | UIManager.AndroidRecyclerViewBackedScrollView.Commands.scrollToIndex,
281 | [animated, index, velocity, viewPosition, viewOffset],
282 | );
283 | } else {
284 | this.setState({
285 | firstVisibleIndex: index,
286 | lastVisibleIndex: index + (this.state.lastVisibleIndex - this.state.firstVisibleIndex)
287 | }, () => {
288 | UIManager.dispatchViewManagerCommand(
289 | ReactNative.findNodeHandle(this),
290 | UIManager.AndroidRecyclerViewBackedScrollView.Commands.scrollToIndex,
291 | [animated, index, velocity, viewPosition, viewOffset],
292 | );
293 | });
294 | }
295 | }
296 |
297 | _needsItemUpdate(itemKey) {
298 | return this._shouldUpdateAll || this._shouldUpdateKeys.includes(itemKey);
299 | }
300 |
301 | _handleVisibleItemsChange = ({nativeEvent}) => {
302 | var firstIndex = nativeEvent.firstIndex;
303 | var lastIndex = nativeEvent.lastIndex;
304 |
305 | this.setState({
306 | firstVisibleIndex: firstIndex,
307 | lastVisibleIndex: lastIndex,
308 | });
309 |
310 | const { onVisibleItemsChange } = this.props;
311 | if (onVisibleItemsChange) {
312 | onVisibleItemsChange(nativeEvent);
313 | }
314 | }
315 |
316 | _calcItemRangeToRender(firstVisibleIndex, lastVisibleIndex) {
317 | const { dataSource, windowSize } = this.props;
318 | var count = dataSource.size();
319 | var from = Math.min(count, Math.max(0, firstVisibleIndex - windowSize));
320 | var to = Math.min(count, lastVisibleIndex + windowSize);
321 | return [from, to];
322 | }
323 |
324 | _notifyItemMoved(currentPosition, nextPosition) {
325 | UIManager.dispatchViewManagerCommand(
326 | ReactNative.findNodeHandle(this),
327 | UIManager.AndroidRecyclerViewBackedScrollView.Commands.notifyItemMoved,
328 | [currentPosition, nextPosition],
329 | );
330 | this.forceUpdate();
331 | }
332 |
333 | _notifyItemRangeInserted(position, count) {
334 | UIManager.dispatchViewManagerCommand(
335 | ReactNative.findNodeHandle(this),
336 | UIManager.AndroidRecyclerViewBackedScrollView.Commands.notifyItemRangeInserted,
337 | [position, count],
338 | );
339 |
340 | const { firstVisibleIndex, lastVisibleIndex, itemCount } = this.state;
341 |
342 | if (itemCount == 0) {
343 | this.setState({
344 | itemCount: this.props.dataSource.size(),
345 | firstVisibleIndex: 0,
346 | lastVisibleIndex: this.props.initialListSize
347 | });
348 | } else {
349 | if (position <= firstVisibleIndex) {
350 | this.setState({
351 | firstVisibleIndex: this.state.firstVisibleIndex + count,
352 | lastVisibleIndex: this.state.lastVisibleIndex + count,
353 | });
354 | } else {
355 | this.forceUpdate();
356 | }
357 | }
358 | }
359 |
360 | _notifyItemRangeRemoved(position, count) {
361 | UIManager.dispatchViewManagerCommand(
362 | ReactNative.findNodeHandle(this),
363 | UIManager.AndroidRecyclerViewBackedScrollView.Commands.notifyItemRangeRemoved,
364 | [position, count],
365 | );
366 | this.forceUpdate();
367 | }
368 |
369 | _notifyDataSetChanged(itemCount) {
370 | UIManager.dispatchViewManagerCommand(
371 | ReactNative.findNodeHandle(this),
372 | UIManager.AndroidRecyclerViewBackedScrollView.Commands.notifyDataSetChanged,
373 | [itemCount],
374 | );
375 | this.setState({
376 | itemCount
377 | });
378 | }
379 | }
380 |
381 | var nativeOnlyProps = {
382 | nativeOnly: {
383 | onVisibleItemsChange: true,
384 | itemCount: true
385 | }
386 | };
387 |
388 | var styles = StyleSheet.create({
389 | absolute: {
390 | position: 'absolute',
391 | top: 0,
392 | left: 0,
393 | right: 0
394 | },
395 | });
396 |
397 | const NativeRecyclerView = requireNativeComponent('AndroidRecyclerViewBackedScrollView', RecyclerView, nativeOnlyProps);
398 |
399 | module.exports = RecyclerView;
400 |
--------------------------------------------------------------------------------
/android/src/main/java/com/github/godness84/RNRecyclerViewList/RecyclerViewBackedScrollView.java:
--------------------------------------------------------------------------------
1 | package com.github.godness84.RNRecyclerViewList;
2 |
3 | import android.content.Context;
4 | import android.graphics.PointF;
5 | import android.support.annotation.Nullable;
6 | import android.support.v7.widget.DefaultItemAnimator;
7 | import android.support.v7.widget.LinearLayoutManager;
8 | import android.support.v7.widget.LinearSmoothScroller;
9 | import android.support.v7.widget.RecyclerView;
10 | import android.util.DisplayMetrics;
11 | import android.util.Log;
12 | import android.view.ContextThemeWrapper;
13 | import android.view.MotionEvent;
14 | import android.view.View;
15 | import android.view.ViewGroup;
16 |
17 | import com.facebook.react.bridge.ReactContext;
18 | import com.facebook.react.common.SystemClock;
19 | import com.facebook.react.common.annotations.VisibleForTesting;
20 | import com.facebook.react.uimanager.PixelUtil;
21 | import com.facebook.react.uimanager.UIManagerModule;
22 | import com.facebook.react.uimanager.events.NativeGestureUtil;
23 | import com.facebook.react.views.scroll.OnScrollDispatchHelper;
24 | import com.facebook.react.views.scroll.ScrollEvent;
25 | import com.facebook.react.views.scroll.ScrollEventType;
26 | import com.facebook.react.views.scroll.VelocityHelper;
27 |
28 | import java.util.ArrayList;
29 | import java.util.List;
30 |
31 | /**
32 | * Wraps {@link RecyclerView} providing interface similar to `ScrollView.js` where each children
33 | * will be rendered as a separate {@link RecyclerView} row.
34 | *
35 | * Currently supports only vertically positioned item. Views will not be automatically recycled but
36 | * they will be detached from native view hierarchy when scrolled offscreen.
37 | *
38 | * It works by storing all child views in an array within adapter and binding appropriate views to
39 | * rows when requested.
40 | */
41 | @VisibleForTesting
42 | public class RecyclerViewBackedScrollView extends RecyclerView {
43 |
44 | private final static String TAG = "RecyclerViewBackedScrol";
45 |
46 | private final OnScrollDispatchHelper mOnScrollDispatchHelper = new OnScrollDispatchHelper();
47 | private final VelocityHelper mVelocityHelper = new VelocityHelper();
48 |
49 | static class ScrollOptions {
50 | @Nullable Float millisecondsPerInch;
51 | @Nullable Float viewPosition;
52 | @Nullable Float viewOffset;
53 | }
54 |
55 | /**
56 | * Simple implementation of {@link ViewHolder} as it's an abstract class. The only thing we need
57 | * to hold in this implementation is the reference to {@link RecyclableWrapperViewGroup} that
58 | * is already stored by default.
59 | */
60 | private static class ConcreteViewHolder extends ViewHolder {
61 | public ConcreteViewHolder(View itemView) {
62 | super(itemView);
63 | }
64 | }
65 |
66 | /**
67 | * View that is going to be used as a cell in {@link RecyclerView}. It's going to be reusable and
68 | * we will remove/attach views for a certain positions based on the {@code mViews} array stored
69 | * in the adapter class.
70 | *
71 | * This method overrides {@link #onMeasure} and delegates measurements to the child view that has
72 | * been attached to. This is because instances of {@link RecyclableWrapperViewGroup} are created
73 | * outside of {@link } and their layout is not managed by that manager
74 | * as opposed to all the other react-native views. Instead we use dimensions of the child view
75 | * (dimensions has been set in layouting process) so that size of this view match the size of
76 | * the view it wraps.
77 | */
78 | static class RecyclableWrapperViewGroup extends ViewGroup {
79 |
80 | private ReactListAdapter mAdapter;
81 | private int mLastMeasuredWidth;
82 | private int mLastMeasuredHeight;
83 |
84 | public RecyclableWrapperViewGroup(Context context, ReactListAdapter adapter) {
85 | super(context);
86 | mAdapter = adapter;
87 | mLastMeasuredHeight = 10;
88 | mLastMeasuredWidth = 10;
89 | }
90 |
91 | private OnLayoutChangeListener mChildLayoutChangeListener = new OnLayoutChangeListener() {
92 | @Override
93 | public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
94 | int oldHeight = (oldBottom - oldTop);
95 | int newHeight = (bottom - top);
96 |
97 | if (oldHeight != newHeight) {
98 | if (getParent() != null) {
99 | requestLayout();
100 | getParent().requestLayout();
101 | }
102 | }
103 | }
104 | };
105 |
106 | @Override
107 | protected void onLayout(boolean changed, int l, int t, int r, int b) {
108 | // This view will only have one child that is managed by the `NativeViewHierarchyManager` and
109 | // its position and dimensions are set separately. We don't need to handle its layouting here
110 | }
111 |
112 | @Override
113 | public void onViewAdded(View child) {
114 | super.onViewAdded(child);
115 | child.addOnLayoutChangeListener(mChildLayoutChangeListener);
116 | }
117 |
118 | @Override
119 | public void onViewRemoved(View child) {
120 | super.onViewRemoved(child);
121 | child.removeOnLayoutChangeListener(mChildLayoutChangeListener);
122 | }
123 |
124 | @Override
125 | protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
126 | // We override measure spec and use dimensions of the children. Children is a view added
127 | // from the adapter and always have a correct dimensions specified as they are calculated
128 | // and set with NativeViewHierarchyManager.
129 | // In case there is no view attached, we use the last measured dimensions.
130 |
131 | if (getChildCount() > 0) {
132 | View child = getChildAt(0);
133 | mLastMeasuredWidth = child.getMeasuredWidth();
134 | mLastMeasuredHeight = child.getMeasuredHeight();
135 | setMeasuredDimension(mLastMeasuredWidth, mLastMeasuredHeight);
136 | } else {
137 | setMeasuredDimension(mLastMeasuredWidth, mLastMeasuredHeight);
138 | }
139 | }
140 |
141 | public ReactListAdapter getAdapter() {
142 | return mAdapter;
143 | }
144 |
145 | @Override
146 | public boolean onTouchEvent(MotionEvent event) {
147 | // Similarly to ReactViewGroup, we return true.
148 | // In this case it is necessary in order to force the RecyclerView to intercept the touch events,
149 | // in this way we can exactly know when the drag starts because "onInterceptTouchEvent"
150 | // of the RecyclerView will return true.
151 | return true;
152 | }
153 | }
154 |
155 | /*package*/ static class ReactListAdapter extends Adapter {
156 |
157 | private final List mViews = new ArrayList<>();
158 | private final RecyclerViewBackedScrollView mScrollView;
159 | private int mItemCount = 0;
160 |
161 | public ReactListAdapter(RecyclerViewBackedScrollView scrollView) {
162 | mScrollView = scrollView;
163 | //setHasStableIds(true);
164 | }
165 |
166 | public void addView(RecyclerViewItemView child, int index) {
167 | mViews.add(index, child);
168 |
169 | final int itemIndex = child.getItemIndex();
170 |
171 | notifyItemChanged(itemIndex);
172 | }
173 |
174 | public void removeViewAt(int index) {
175 | RecyclerViewItemView child = mViews.get(index);
176 | if (child != null) {
177 | mViews.remove(index);
178 | }
179 | }
180 |
181 | public int getViewCount() {
182 | return mViews.size();
183 | }
184 |
185 | @Override
186 | public ConcreteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
187 | return new ConcreteViewHolder(new RecyclableWrapperViewGroup(parent.getContext(), this));
188 | }
189 |
190 | @Override
191 | public void onBindViewHolder(ConcreteViewHolder holder, int position) {
192 | RecyclableWrapperViewGroup vg = (RecyclableWrapperViewGroup) holder.itemView;
193 | View row = getViewByItemIndex(position);
194 | if (row != null && row.getParent() != vg) {
195 | if (row.getParent() != null) {
196 | ((ViewGroup) row.getParent()).removeView(row);
197 | }
198 | vg.addView(row, 0);
199 | }
200 | }
201 |
202 | @Override
203 | public void onViewRecycled(ConcreteViewHolder holder) {
204 | super.onViewRecycled(holder);
205 | ((RecyclableWrapperViewGroup) holder.itemView).removeAllViews();
206 | }
207 |
208 | @Override
209 | public int getItemCount() {
210 | return mItemCount;
211 | }
212 |
213 | public void setItemCount(int itemCount) {
214 | this.mItemCount = itemCount;
215 | }
216 |
217 | public View getView(int index) {
218 | return mViews.get(index);
219 | }
220 |
221 | public RecyclerViewItemView getViewByItemIndex(int position) {
222 | for (int i = 0; i < mViews.size(); i++) {
223 | if (mViews.get(i).getItemIndex() == position) {
224 | return mViews.get(i);
225 | }
226 | }
227 |
228 | return null;
229 | }
230 | }
231 |
232 | private boolean mDragging;
233 | private int mFirstVisibleIndex, mLastVisibleIndex;
234 |
235 | @Override
236 | protected void onScrollChanged(int l, int t, int oldl, int oldt) {
237 | super.onScrollChanged(l, t, oldl, oldt);
238 |
239 | if (mOnScrollDispatchHelper.onScrollChanged(l, t)) {
240 | getReactContext().getNativeModule(UIManagerModule.class).getEventDispatcher()
241 | .dispatchEvent(ScrollEvent.obtain(
242 | getId(),
243 | ScrollEventType.SCROLL,
244 | 0, /* offsetX = 0, horizontal scrolling only */
245 | computeVerticalScrollOffset(),
246 | mOnScrollDispatchHelper.getXFlingVelocity(),
247 | mOnScrollDispatchHelper.getYFlingVelocity(),
248 | getWidth(),
249 | computeVerticalScrollRange(),
250 | getWidth(),
251 | getHeight()));
252 | }
253 |
254 | final int firstIndex = ((LinearLayoutManager) getLayoutManager()).findFirstVisibleItemPosition();
255 | final int lastIndex = ((LinearLayoutManager) getLayoutManager()).findLastVisibleItemPosition();
256 |
257 | if (firstIndex != mFirstVisibleIndex || lastIndex != mLastVisibleIndex) {
258 | getReactContext().getNativeModule(UIManagerModule.class).getEventDispatcher()
259 | .dispatchEvent(new VisibleItemsChangeEvent(
260 | getId(),
261 | SystemClock.nanoTime(),
262 | firstIndex,
263 | lastIndex));
264 |
265 | mFirstVisibleIndex = firstIndex;
266 | mLastVisibleIndex = lastIndex;
267 | }
268 | }
269 |
270 | private ReactContext getReactContext() {
271 | return (ReactContext) ((ContextThemeWrapper) getContext()).getBaseContext();
272 | }
273 |
274 | public RecyclerViewBackedScrollView(Context context) {
275 | super(new ContextThemeWrapper(context, R.style.ScrollbarRecyclerView));
276 | setHasFixedSize(true);
277 | ((DefaultItemAnimator)getItemAnimator()).setSupportsChangeAnimations(false);
278 | setLayoutManager(new LinearLayoutManager(context));
279 | setAdapter(new ReactListAdapter(this));
280 | }
281 |
282 | /*package*/ void addViewToAdapter(RecyclerViewItemView child, int index) {
283 | ((ReactListAdapter) getAdapter()).addView(child, index);
284 | }
285 |
286 | /*package*/ void removeViewFromAdapter(int index) {
287 | ((ReactListAdapter) getAdapter()).removeViewAt(index);
288 | }
289 |
290 | /*package*/ View getChildAtFromAdapter(int index) {
291 | return ((ReactListAdapter) getAdapter()).getView(index);
292 | }
293 |
294 | /*package*/ int getChildCountFromAdapter() {
295 | return ((ReactListAdapter) getAdapter()).getViewCount();
296 | }
297 |
298 | /*package*/ void setItemCount(int itemCount) {
299 | ((ReactListAdapter) getAdapter()).setItemCount(itemCount);
300 | }
301 |
302 | /*package*/ int getItemCount() {
303 | return getAdapter().getItemCount();
304 | }
305 |
306 | @Override
307 | public boolean onInterceptTouchEvent(MotionEvent ev) {
308 | if (super.onInterceptTouchEvent(ev)) {
309 | NativeGestureUtil.notifyNativeGestureStarted(this, ev);
310 | mDragging = true;
311 | getReactContext().getNativeModule(UIManagerModule.class).getEventDispatcher()
312 | .dispatchEvent(ScrollEvent.obtain(
313 | getId(),
314 | ScrollEventType.BEGIN_DRAG,
315 | 0, /* offsetX = 0, horizontal scrolling only */
316 | computeVerticalScrollOffset(),
317 | 0, // xVelocity
318 | 0, // yVelocity
319 | getWidth(),
320 | computeVerticalScrollRange(),
321 | getWidth(),
322 | getHeight()));
323 | return true;
324 | }
325 |
326 | return false;
327 | }
328 |
329 | @Override
330 | public boolean onTouchEvent(MotionEvent ev) {
331 | int action = ev.getAction() & MotionEvent.ACTION_MASK;
332 | if (action == MotionEvent.ACTION_UP && mDragging) {
333 | mDragging = false;
334 | mVelocityHelper.calculateVelocity(ev);
335 | getReactContext().getNativeModule(UIManagerModule.class).getEventDispatcher()
336 | .dispatchEvent(ScrollEvent.obtain(
337 | getId(),
338 | ScrollEventType.END_DRAG,
339 | 0, /* offsetX = 0, horizontal scrolling only */
340 | computeVerticalScrollOffset(),
341 | mVelocityHelper.getXVelocity(),
342 | mVelocityHelper.getYVelocity(),
343 | getWidth(),
344 | computeVerticalScrollRange(),
345 | getWidth(),
346 | getHeight()));
347 | }
348 | return super.onTouchEvent(ev);
349 | }
350 |
351 | private boolean mRequestedLayout = false;
352 |
353 | @Override
354 | public void requestLayout() {
355 | super.requestLayout();
356 |
357 | if (!mRequestedLayout) {
358 | mRequestedLayout = true;
359 | this.post(new Runnable() {
360 | @Override
361 | public void run() {
362 | mRequestedLayout = false;
363 | layout(getLeft(), getTop(), getRight(), getBottom());
364 | onLayout(false, getLeft(), getTop(), getRight(), getBottom());
365 | }
366 | });
367 | }
368 | }
369 |
370 | @Override
371 | public void scrollToPosition(int position) {
372 | this.scrollToPosition(position, new ScrollOptions());
373 | }
374 |
375 | public void scrollToPosition(final int position, final ScrollOptions options) {
376 | if (options.viewPosition != null) {
377 | final LinearLayoutManager layoutManager = (LinearLayoutManager) getLayoutManager();
378 | final ReactListAdapter adapter = (ReactListAdapter) getAdapter();
379 | final View view = adapter.getViewByItemIndex(position);
380 | if (view != null) {
381 | final int viewHeight = view.getHeight();
382 |
383 | // In order to calculate the correct offset, we need the height of the target view.
384 | // If the height of the view is not available it means RN has not calculated it yet.
385 | // So let's listen to the layout change and we will retry scrolling.
386 | if (viewHeight == 0) {
387 | view.addOnLayoutChangeListener(new OnLayoutChangeListener() {
388 | @Override
389 | public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {
390 | view.removeOnLayoutChangeListener(this);
391 | scrollToPosition(position, options);
392 | }
393 | });
394 | return;
395 | }
396 |
397 | final int boxStart = layoutManager.getPaddingTop();
398 | final int boxEnd = layoutManager.getHeight() - layoutManager.getPaddingBottom();
399 | final int boxHeight = boxEnd - boxStart;
400 | float viewOffset = options.viewOffset != null ? PixelUtil.toPixelFromDIP(options.viewOffset) : 0;
401 | int offset = (int) ((boxHeight - viewHeight) * options.viewPosition + viewOffset);
402 | layoutManager.scrollToPositionWithOffset(position, offset);
403 | return;
404 | }
405 | }
406 |
407 | super.scrollToPosition(position);
408 | }
409 |
410 | @Override
411 | public void smoothScrollToPosition(int position) {
412 | this.smoothScrollToPosition(position, new ScrollOptions());
413 | }
414 |
415 | public void smoothScrollToPosition(int position, final ScrollOptions options) {
416 | final RecyclerView.SmoothScroller smoothScroller = new LinearSmoothScroller(this.getContext()) {
417 | @Override
418 | protected int getVerticalSnapPreference() {
419 | return LinearSmoothScroller.SNAP_TO_START;
420 | }
421 |
422 | @Override
423 | public PointF computeScrollVectorForPosition(int targetPosition) {
424 | return ((LinearLayoutManager) this.getLayoutManager()).computeScrollVectorForPosition(targetPosition);
425 | }
426 |
427 | @Override
428 | protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
429 | if (options.millisecondsPerInch != null) {
430 | return options.millisecondsPerInch / displayMetrics.densityDpi;
431 | } else {
432 | return super.calculateSpeedPerPixel(displayMetrics);
433 | }
434 | }
435 |
436 | @Override
437 | public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int snapPreference) {
438 | int calc = super.calculateDtToFit(viewStart, viewEnd, boxStart, boxEnd, snapPreference);
439 | if (options.viewPosition != null) {
440 | int viewHeight = viewEnd - viewStart;
441 | int boxHeight = boxEnd - boxStart;
442 | float viewOffset = options.viewOffset != null ? PixelUtil.toPixelFromDIP(options.viewOffset) : 0;
443 | float target = boxStart + (boxHeight - viewHeight) * options.viewPosition + viewOffset;
444 | return (int) (target - viewStart);
445 | } else {
446 | return super.calculateDtToFit(viewStart, viewEnd, boxStart, boxEnd, snapPreference);
447 | }
448 | }
449 | };
450 |
451 | smoothScroller.setTargetPosition(position);
452 | this.getLayoutManager().startSmoothScroll(smoothScroller);
453 | }
454 |
455 | public void setItemAnimatorEnabled(boolean enabled) {
456 | if (enabled) {
457 | DefaultItemAnimator animator = new DefaultItemAnimator();
458 | animator.setSupportsChangeAnimations(false);
459 | setItemAnimator(animator);
460 | } else {
461 | setItemAnimator(null);
462 | }
463 | }
464 | }
465 |
--------------------------------------------------------------------------------