/
RecyclerViewBackedScrollView.java
397 lines (343 loc) · 15.5 KB
/
RecyclerViewBackedScrollView.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
// 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;
}
}