Skip to content

Commit 680b458

Browse files
authored
feat(android): lazy view (#331)
* feat(android): lazy view renderer * fix: high initial page fails during lazy render * fix(android): programmatic scroll page index desync on heavy pages * chore(android): rename some functions
1 parent bd0a058 commit 680b458

File tree

7 files changed

+178
-66
lines changed

7 files changed

+178
-66
lines changed

android/src/main/java/com/reactnativepagerview/FragmentAdapter.kt

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,54 +3,102 @@ package com.reactnativepagerview
33
import android.view.View
44
import androidx.fragment.app.Fragment
55
import androidx.fragment.app.FragmentActivity
6+
import androidx.recyclerview.widget.DiffUtil
67
import androidx.viewpager2.adapter.FragmentStateAdapter
7-
import java.util.*
88

99

1010
class FragmentAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
11-
private val childrenViews: MutableList<View> = ArrayList()
11+
private val childrenViews = mutableListOf<View>()
12+
private var count = 0
13+
private var offset = 0
14+
private var isDirty = false
15+
private val prevItemIds = mutableListOf<Long>()
16+
1217
override fun createFragment(position: Int): Fragment {
13-
return ViewPagerFragment(childrenViews[position])
18+
return ViewPagerFragment(getViewAtPosition(position))
1419
}
1520

16-
override fun getItemCount(): Int {
17-
return childrenViews.size
18-
}
21+
override fun getItemCount() = count
1922

2023
override fun getItemId(position: Int): Long {
21-
return childrenViews[position].id.toLong()
24+
val view = getViewAtPosition(position)
25+
return view?.id?.toLong() ?: UNRENDERED_ID_OFFSET + position
2226
}
2327

2428
override fun containsItem(itemId: Long): Boolean {
25-
for (child in childrenViews) {
26-
if (itemId.toInt() == child.id) {
27-
return true
28-
}
29+
if (itemId >= UNRENDERED_ID_OFFSET) {
30+
val position = itemId - UNRENDERED_ID_OFFSET
31+
return getViewAtPosition(position.toInt()) == null
2932
}
30-
return false
33+
return childrenViews.any { it.id.toLong() == itemId }
3134
}
3235

33-
fun addFragment(child: View, index: Int) {
34-
childrenViews.add(index, child)
35-
notifyItemInserted(index)
36+
/**
37+
* Returns true if any changes were applied.
38+
*/
39+
fun notifyAboutChanges(): Boolean {
40+
if (!isDirty) {
41+
return false
42+
}
43+
44+
isDirty = false
45+
val diff = DiffUtil.calculateDiff(
46+
PagerDiffCallback(prevItemIds, this),
47+
false
48+
)
49+
diff.dispatchUpdatesTo(this)
50+
return true
51+
}
52+
53+
fun setCount(count: Int) {
54+
if (this.count != count) {
55+
markDirty()
56+
this.count = count
57+
}
58+
}
59+
60+
fun setOffset(offset: Int) {
61+
if (this.offset != offset) {
62+
markDirty()
63+
this.offset = offset
64+
}
3665
}
3766

38-
fun removeFragment(child: View) {
39-
val index = childrenViews.indexOf(child)
40-
removeFragmentAt(index)
67+
fun addReactView(child: View, index: Int) {
68+
markDirty()
69+
childrenViews.add(index, child)
4170
}
4271

43-
fun removeFragmentAt(index: Int) {
72+
fun getReactChildAt(index: Int) = childrenViews[index]
73+
74+
fun getReactChildCount() = childrenViews.size
75+
76+
fun removeReactViewAt(index: Int) {
77+
markDirty()
4478
childrenViews.removeAt(index)
45-
notifyItemRemoved(index)
4679
}
4780

48-
fun removeAll() {
49-
childrenViews.clear()
50-
notifyDataSetChanged()
81+
private fun getViewAtPosition(position: Int): View? {
82+
val index = position - offset
83+
return if (index >= 0 && index < childrenViews.size) childrenViews[index] else null
84+
}
85+
86+
private fun markDirty() {
87+
if (isDirty) {
88+
return
89+
}
90+
isDirty = true
91+
prevItemIds.clear()
92+
for (position in 0 until itemCount) {
93+
prevItemIds.add(getItemId(position))
94+
}
5195
}
5296

53-
fun getChildViewAt(index: Int): View {
54-
return childrenViews[index]
97+
companion object {
98+
/**
99+
* If an id is `UNRENDERED_ID_OFFSET` or higher, it represents a view
100+
* that is not currently rendered.
101+
*/
102+
const val UNRENDERED_ID_OFFSET = 0xffffffffL
55103
}
56104
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.reactnativepagerview
2+
3+
import androidx.recyclerview.widget.DiffUtil
4+
import com.reactnativepagerview.FragmentAdapter.Companion.UNRENDERED_ID_OFFSET
5+
6+
class PagerDiffCallback(private val oldList: List<Long>, private val adapter: FragmentAdapter) : DiffUtil.Callback() {
7+
override fun getOldListSize() = oldList.size
8+
9+
override fun getNewListSize() = adapter.itemCount
10+
11+
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
12+
val oldId = oldList[oldItemPosition]
13+
val newId = adapter.getItemId(newItemPosition)
14+
// An unrendered item is assumed the same as any item in the same position.
15+
return if (oldId >= UNRENDERED_ID_OFFSET || newId >= UNRENDERED_ID_OFFSET) oldItemPosition == newItemPosition else oldId == newId
16+
}
17+
18+
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
19+
return oldList[oldItemPosition] == adapter.getItemId(newItemPosition)
20+
}
21+
}

android/src/main/java/com/reactnativepagerview/PagerViewViewManager.kt

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -34,19 +34,16 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
3434
eventDispatcher = reactContext.getNativeModule(UIManagerModule::class.java)!!.eventDispatcher
3535
vp.registerOnPageChangeCallback(object : OnPageChangeCallback() {
3636
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
37-
super.onPageScrolled(position, positionOffset, positionOffsetPixels)
3837
eventDispatcher.dispatchEvent(
3938
PageScrollEvent(vp.id, position, positionOffset))
4039
}
4140

4241
override fun onPageSelected(position: Int) {
43-
super.onPageSelected(position)
4442
eventDispatcher.dispatchEvent(
4543
PageSelectedEvent(vp.id, position))
4644
}
4745

4846
override fun onPageScrollStateChanged(state: Int) {
49-
super.onPageScrollStateChanged(state)
5047
val pageScrollState: String = when (state) {
5148
ViewPager2.SCROLL_STATE_IDLE -> "idle"
5249
ViewPager2.SCROLL_STATE_DRAGGING -> "dragging"
@@ -61,49 +58,39 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
6158
}
6259

6360
private fun setCurrentItem(view: ViewPager2, selectedTab: Int, scrollSmooth: Boolean) {
64-
view.post {
65-
view.measure(
66-
View.MeasureSpec.makeMeasureSpec(view.width, View.MeasureSpec.EXACTLY),
67-
View.MeasureSpec.makeMeasureSpec(view.height, View.MeasureSpec.EXACTLY))
68-
view.layout(view.left, view.top, view.right, view.bottom)
69-
}
61+
view.post { updateLayoutView(view) }
7062
view.setCurrentItem(selectedTab, scrollSmooth)
7163
}
7264

7365
override fun addView(parent: ViewPager2, child: View, index: Int) {
74-
if (child == null) {
75-
return
76-
}
77-
(parent.adapter as FragmentAdapter?)!!.addFragment(child, index)
66+
val adapter = parent.adapter as FragmentAdapter
67+
adapter.addReactView(child, index)
68+
postNewChanges(parent)
7869
}
7970

8071
override fun getChildCount(parent: ViewPager2): Int {
81-
return parent.adapter!!.itemCount
72+
return (parent.adapter as FragmentAdapter).getReactChildCount()
8273
}
8374

8475
override fun getChildAt(parent: ViewPager2, index: Int): View {
85-
return (parent.adapter as FragmentAdapter?)!!.getChildViewAt(index)
86-
}
87-
88-
override fun removeView(parent: ViewPager2, view: View) {
89-
(parent.adapter as FragmentAdapter?)!!.removeFragment(view)
90-
}
91-
92-
override fun removeAllViews(parent: ViewPager2) {
93-
parent.isUserInputEnabled = false
94-
val adapter = parent.adapter as FragmentAdapter?
95-
adapter!!.removeAll()
76+
return (parent.adapter as FragmentAdapter).getReactChildAt(index)
9677
}
9778

9879
override fun removeViewAt(parent: ViewPager2, index: Int) {
99-
val adapter = parent.adapter as FragmentAdapter?
100-
adapter!!.removeFragmentAt(index)
80+
val adapter = parent.adapter as FragmentAdapter
81+
adapter.removeReactViewAt(index)
82+
postNewChanges(parent)
10183
}
10284

10385
override fun needsCustomLayoutForChildren(): Boolean {
10486
return true
10587
}
10688

89+
@ReactProp(name = "count")
90+
fun setCount(view: ViewPager2, count: Int) {
91+
(view.adapter as FragmentAdapter).setCount(count)
92+
}
93+
10794
@ReactProp(name = "scrollEnabled", defaultBoolean = true)
10895
fun setScrollEnabled(viewPager: ViewPager2, value: Boolean) {
10996
viewPager.isUserInputEnabled = value
@@ -119,6 +106,11 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
119106
viewPager.offscreenPageLimit = value
120107
}
121108

109+
@ReactProp(name = "offset")
110+
fun setOffset(view: ViewPager2, offset: Int) {
111+
(view.adapter as FragmentAdapter).setOffset(offset)
112+
}
113+
122114
@ReactProp(name = "overScrollMode")
123115
fun setOverScrollMode(viewPager: ViewPager2, value: String) {
124116
val child = viewPager.getChildAt(0)
@@ -135,6 +127,13 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
135127
}
136128
}
137129

130+
override fun onAfterUpdateTransaction(view: ViewPager2) {
131+
super.onAfterUpdateTransaction(view)
132+
if ((view.adapter as FragmentAdapter).notifyAboutChanges()) {
133+
view.post { updateLayoutView(view) }
134+
}
135+
}
136+
138137
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Map<String, String>> {
139138
return MapBuilder.of(
140139
PageScrollEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPageScroll"),
@@ -195,6 +194,24 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
195194
}
196195
}
197196

197+
private fun postNewChanges(view: ViewPager2) {
198+
view.post {
199+
if ((view.adapter as FragmentAdapter).notifyAboutChanges()) {
200+
updateLayoutView(view)
201+
}
202+
}
203+
}
204+
205+
/**
206+
* Helper to trigger ViewPager2 to update.
207+
*/
208+
private fun updateLayoutView(view: View) {
209+
view.measure(
210+
View.MeasureSpec.makeMeasureSpec(view.width, View.MeasureSpec.EXACTLY),
211+
View.MeasureSpec.makeMeasureSpec(view.height, View.MeasureSpec.EXACTLY))
212+
view.layout(view.left, view.top, view.right, view.bottom)
213+
}
214+
198215
companion object {
199216
private const val REACT_CLASS = "RNCViewPager"
200217
private const val COMMAND_SET_PAGE = 1

src/LazyPagerView.tsx

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
6565
LazyPagerViewImplProps<ItemT>,
6666
LazyPagerViewImplState
6767
> {
68+
private isNavigatingToPage: number | null = null;
6869
private isScrolling = false;
6970

7071
constructor(props: LazyPagerViewImplProps<ItemT>) {
@@ -81,6 +82,7 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
8182
componentDidMount() {
8283
const initialPage = this.props.initialPage;
8384
if (initialPage != null && initialPage > 0) {
85+
this.isNavigatingToPage = initialPage;
8486
requestAnimationFrame(() => {
8587
// Send command directly; render window already contains destination.
8688
UIManager.dispatchViewManagerCommand(
@@ -181,21 +183,21 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
181183
* Currently will always yield `offset` of `0`.
182184
*/
183185
private computeRenderWindow(data: RenderWindowData): LazyPagerViewImplState {
184-
if (data.maxRenderWindow != null && data.maxRenderWindow !== 0) {
185-
console.warn('`maxRenderWindow` is not currently implemented.');
186-
}
187-
188186
const buffer = Math.max(data.buffer ?? 1, 1);
189-
// let offset = Math.max(Math.min(data.offset, data.currentPage - buffer), 0);
190-
let offset = 0;
187+
const maxRenderWindowLowerBound = 1 + 2 * buffer;
188+
let offset = Math.max(Math.min(data.offset, data.currentPage - buffer), 0);
191189
let windowLength =
192190
Math.max(data.offset + data.windowLength, data.currentPage + buffer + 1) -
193191
offset;
194192

195-
// let maxRenderWindow = data.maxRenderWindow ?? 0;
196-
let maxRenderWindow = 0;
193+
let maxRenderWindow = data.maxRenderWindow ?? 0;
197194
if (maxRenderWindow !== 0) {
198-
maxRenderWindow = Math.max(maxRenderWindow, 1 + 2 * buffer);
195+
if (maxRenderWindow < maxRenderWindowLowerBound) {
196+
console.warn(
197+
`maxRenderWindow too small. Increasing to ${maxRenderWindowLowerBound}`
198+
);
199+
maxRenderWindow = maxRenderWindowLowerBound;
200+
}
199201
if (windowLength > maxRenderWindow) {
200202
offset = data.currentPage - Math.floor(maxRenderWindow / 2);
201203
windowLength = maxRenderWindow;
@@ -222,8 +224,20 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
222224
};
223225

224226
private onPageSelected = (event: PagerViewOnPageSelectedEvent) => {
225-
// Queue renders for next needed pages (if not already available).
226227
const currentPage = event.nativeEvent.position;
228+
229+
// Ignore spurious events that can occur on mount with `initialPage`.
230+
// TODO: Is there a way to avoid triggering the events at all?
231+
if (this.isNavigatingToPage !== null) {
232+
if (this.isNavigatingToPage === currentPage) {
233+
this.isNavigatingToPage = null;
234+
} else {
235+
// Ignore event.
236+
return;
237+
}
238+
}
239+
240+
// Queue renders for next needed pages (if not already available).
227241
requestAnimationFrame(() => {
228242
this.setState((prevState) =>
229243
this.computeRenderWindow({
@@ -258,14 +272,14 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
258272
}
259273

260274
render() {
261-
// Note: current implementation does not support unmounting, so `offset`
262-
// is always `0`.
263275
const { offset, windowLength } = this.state;
264276
const { children } = this.renderChildren(offset, windowLength);
265277

266278
return (
267279
<PagerViewViewManager
280+
count={this.props.data.length}
268281
offscreenPageLimit={this.props.offscreenPageLimit}
282+
offset={offset}
269283
onMoveShouldSetResponderCapture={this.onMoveShouldSetResponderCapture}
270284
onPageScroll={this.onPageScroll}
271285
onPageScrollStateChanged={this.onPageScrollStateChanged}

src/PagerView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ export class PagerView
142142
return (
143143
<PagerViewViewManager
144144
{...this.props}
145+
count={React.Children.count(this.props.children)}
146+
offset={0}
145147
style={this.props.style}
146148
onPageScroll={this._onPageScroll}
147149
onPageScrollStateChanged={this._onPageScrollStateChanged}

0 commit comments

Comments
 (0)