Skip to content

Commit

Permalink
feat(android): lazy view (#331)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
alpha0010 committed May 16, 2021
1 parent bd0a058 commit 680b458
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 66 deletions.
98 changes: 73 additions & 25 deletions android/src/main/java/com/reactnativepagerview/FragmentAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,102 @@ package com.reactnativepagerview
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.recyclerview.widget.DiffUtil
import androidx.viewpager2.adapter.FragmentStateAdapter
import java.util.*


class FragmentAdapter(fragmentActivity: FragmentActivity) : FragmentStateAdapter(fragmentActivity) {
private val childrenViews: MutableList<View> = ArrayList()
private val childrenViews = mutableListOf<View>()
private var count = 0
private var offset = 0
private var isDirty = false
private val prevItemIds = mutableListOf<Long>()

override fun createFragment(position: Int): Fragment {
return ViewPagerFragment(childrenViews[position])
return ViewPagerFragment(getViewAtPosition(position))
}

override fun getItemCount(): Int {
return childrenViews.size
}
override fun getItemCount() = count

override fun getItemId(position: Int): Long {
return childrenViews[position].id.toLong()
val view = getViewAtPosition(position)
return view?.id?.toLong() ?: UNRENDERED_ID_OFFSET + position
}

override fun containsItem(itemId: Long): Boolean {
for (child in childrenViews) {
if (itemId.toInt() == child.id) {
return true
}
if (itemId >= UNRENDERED_ID_OFFSET) {
val position = itemId - UNRENDERED_ID_OFFSET
return getViewAtPosition(position.toInt()) == null
}
return false
return childrenViews.any { it.id.toLong() == itemId }
}

fun addFragment(child: View, index: Int) {
childrenViews.add(index, child)
notifyItemInserted(index)
/**
* Returns true if any changes were applied.
*/
fun notifyAboutChanges(): Boolean {
if (!isDirty) {
return false
}

isDirty = false
val diff = DiffUtil.calculateDiff(
PagerDiffCallback(prevItemIds, this),
false
)
diff.dispatchUpdatesTo(this)
return true
}

fun setCount(count: Int) {
if (this.count != count) {
markDirty()
this.count = count
}
}

fun setOffset(offset: Int) {
if (this.offset != offset) {
markDirty()
this.offset = offset
}
}

fun removeFragment(child: View) {
val index = childrenViews.indexOf(child)
removeFragmentAt(index)
fun addReactView(child: View, index: Int) {
markDirty()
childrenViews.add(index, child)
}

fun removeFragmentAt(index: Int) {
fun getReactChildAt(index: Int) = childrenViews[index]

fun getReactChildCount() = childrenViews.size

fun removeReactViewAt(index: Int) {
markDirty()
childrenViews.removeAt(index)
notifyItemRemoved(index)
}

fun removeAll() {
childrenViews.clear()
notifyDataSetChanged()
private fun getViewAtPosition(position: Int): View? {
val index = position - offset
return if (index >= 0 && index < childrenViews.size) childrenViews[index] else null
}

private fun markDirty() {
if (isDirty) {
return
}
isDirty = true
prevItemIds.clear()
for (position in 0 until itemCount) {
prevItemIds.add(getItemId(position))
}
}

fun getChildViewAt(index: Int): View {
return childrenViews[index]
companion object {
/**
* If an id is `UNRENDERED_ID_OFFSET` or higher, it represents a view
* that is not currently rendered.
*/
const val UNRENDERED_ID_OFFSET = 0xffffffffL
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.reactnativepagerview

import androidx.recyclerview.widget.DiffUtil
import com.reactnativepagerview.FragmentAdapter.Companion.UNRENDERED_ID_OFFSET

class PagerDiffCallback(private val oldList: List<Long>, private val adapter: FragmentAdapter) : DiffUtil.Callback() {
override fun getOldListSize() = oldList.size

override fun getNewListSize() = adapter.itemCount

override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldId = oldList[oldItemPosition]
val newId = adapter.getItemId(newItemPosition)
// An unrendered item is assumed the same as any item in the same position.
return if (oldId >= UNRENDERED_ID_OFFSET || newId >= UNRENDERED_ID_OFFSET) oldItemPosition == newItemPosition else oldId == newId
}

override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
return oldList[oldItemPosition] == adapter.getItemId(newItemPosition)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,16 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
eventDispatcher = reactContext.getNativeModule(UIManagerModule::class.java)!!.eventDispatcher
vp.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels)
eventDispatcher.dispatchEvent(
PageScrollEvent(vp.id, position, positionOffset))
}

override fun onPageSelected(position: Int) {
super.onPageSelected(position)
eventDispatcher.dispatchEvent(
PageSelectedEvent(vp.id, position))
}

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

private fun setCurrentItem(view: ViewPager2, selectedTab: Int, scrollSmooth: Boolean) {
view.post {
view.measure(
View.MeasureSpec.makeMeasureSpec(view.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(view.height, View.MeasureSpec.EXACTLY))
view.layout(view.left, view.top, view.right, view.bottom)
}
view.post { updateLayoutView(view) }
view.setCurrentItem(selectedTab, scrollSmooth)
}

override fun addView(parent: ViewPager2, child: View, index: Int) {
if (child == null) {
return
}
(parent.adapter as FragmentAdapter?)!!.addFragment(child, index)
val adapter = parent.adapter as FragmentAdapter
adapter.addReactView(child, index)
postNewChanges(parent)
}

override fun getChildCount(parent: ViewPager2): Int {
return parent.adapter!!.itemCount
return (parent.adapter as FragmentAdapter).getReactChildCount()
}

override fun getChildAt(parent: ViewPager2, index: Int): View {
return (parent.adapter as FragmentAdapter?)!!.getChildViewAt(index)
}

override fun removeView(parent: ViewPager2, view: View) {
(parent.adapter as FragmentAdapter?)!!.removeFragment(view)
}

override fun removeAllViews(parent: ViewPager2) {
parent.isUserInputEnabled = false
val adapter = parent.adapter as FragmentAdapter?
adapter!!.removeAll()
return (parent.adapter as FragmentAdapter).getReactChildAt(index)
}

override fun removeViewAt(parent: ViewPager2, index: Int) {
val adapter = parent.adapter as FragmentAdapter?
adapter!!.removeFragmentAt(index)
val adapter = parent.adapter as FragmentAdapter
adapter.removeReactViewAt(index)
postNewChanges(parent)
}

override fun needsCustomLayoutForChildren(): Boolean {
return true
}

@ReactProp(name = "count")
fun setCount(view: ViewPager2, count: Int) {
(view.adapter as FragmentAdapter).setCount(count)
}

@ReactProp(name = "scrollEnabled", defaultBoolean = true)
fun setScrollEnabled(viewPager: ViewPager2, value: Boolean) {
viewPager.isUserInputEnabled = value
Expand All @@ -119,6 +106,11 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
viewPager.offscreenPageLimit = value
}

@ReactProp(name = "offset")
fun setOffset(view: ViewPager2, offset: Int) {
(view.adapter as FragmentAdapter).setOffset(offset)
}

@ReactProp(name = "overScrollMode")
fun setOverScrollMode(viewPager: ViewPager2, value: String) {
val child = viewPager.getChildAt(0)
Expand All @@ -135,6 +127,13 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
}
}

override fun onAfterUpdateTransaction(view: ViewPager2) {
super.onAfterUpdateTransaction(view)
if ((view.adapter as FragmentAdapter).notifyAboutChanges()) {
view.post { updateLayoutView(view) }
}
}

override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Map<String, String>> {
return MapBuilder.of(
PageScrollEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPageScroll"),
Expand Down Expand Up @@ -195,6 +194,24 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
}
}

private fun postNewChanges(view: ViewPager2) {
view.post {
if ((view.adapter as FragmentAdapter).notifyAboutChanges()) {
updateLayoutView(view)
}
}
}

/**
* Helper to trigger ViewPager2 to update.
*/
private fun updateLayoutView(view: View) {
view.measure(
View.MeasureSpec.makeMeasureSpec(view.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(view.height, View.MeasureSpec.EXACTLY))
view.layout(view.left, view.top, view.right, view.bottom)
}

companion object {
private const val REACT_CLASS = "RNCViewPager"
private const val COMMAND_SET_PAGE = 1
Expand Down
38 changes: 26 additions & 12 deletions src/LazyPagerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
LazyPagerViewImplProps<ItemT>,
LazyPagerViewImplState
> {
private isNavigatingToPage: number | null = null;
private isScrolling = false;

constructor(props: LazyPagerViewImplProps<ItemT>) {
Expand All @@ -81,6 +82,7 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
componentDidMount() {
const initialPage = this.props.initialPage;
if (initialPage != null && initialPage > 0) {
this.isNavigatingToPage = initialPage;
requestAnimationFrame(() => {
// Send command directly; render window already contains destination.
UIManager.dispatchViewManagerCommand(
Expand Down Expand Up @@ -181,21 +183,21 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
* Currently will always yield `offset` of `0`.
*/
private computeRenderWindow(data: RenderWindowData): LazyPagerViewImplState {
if (data.maxRenderWindow != null && data.maxRenderWindow !== 0) {
console.warn('`maxRenderWindow` is not currently implemented.');
}

const buffer = Math.max(data.buffer ?? 1, 1);
// let offset = Math.max(Math.min(data.offset, data.currentPage - buffer), 0);
let offset = 0;
const maxRenderWindowLowerBound = 1 + 2 * buffer;
let offset = Math.max(Math.min(data.offset, data.currentPage - buffer), 0);
let windowLength =
Math.max(data.offset + data.windowLength, data.currentPage + buffer + 1) -
offset;

// let maxRenderWindow = data.maxRenderWindow ?? 0;
let maxRenderWindow = 0;
let maxRenderWindow = data.maxRenderWindow ?? 0;
if (maxRenderWindow !== 0) {
maxRenderWindow = Math.max(maxRenderWindow, 1 + 2 * buffer);
if (maxRenderWindow < maxRenderWindowLowerBound) {
console.warn(
`maxRenderWindow too small. Increasing to ${maxRenderWindowLowerBound}`
);
maxRenderWindow = maxRenderWindowLowerBound;
}
if (windowLength > maxRenderWindow) {
offset = data.currentPage - Math.floor(maxRenderWindow / 2);
windowLength = maxRenderWindow;
Expand All @@ -222,8 +224,20 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
};

private onPageSelected = (event: PagerViewOnPageSelectedEvent) => {
// Queue renders for next needed pages (if not already available).
const currentPage = event.nativeEvent.position;

// Ignore spurious events that can occur on mount with `initialPage`.
// TODO: Is there a way to avoid triggering the events at all?
if (this.isNavigatingToPage !== null) {
if (this.isNavigatingToPage === currentPage) {
this.isNavigatingToPage = null;
} else {
// Ignore event.
return;
}
}

// Queue renders for next needed pages (if not already available).
requestAnimationFrame(() => {
this.setState((prevState) =>
this.computeRenderWindow({
Expand Down Expand Up @@ -258,14 +272,14 @@ class LazyPagerViewImpl<ItemT> extends React.Component<
}

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

return (
<PagerViewViewManager
count={this.props.data.length}
offscreenPageLimit={this.props.offscreenPageLimit}
offset={offset}
onMoveShouldSetResponderCapture={this.onMoveShouldSetResponderCapture}
onPageScroll={this.onPageScroll}
onPageScrollStateChanged={this.onPageScrollStateChanged}
Expand Down
2 changes: 2 additions & 0 deletions src/PagerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ export class PagerView
return (
<PagerViewViewManager
{...this.props}
count={React.Children.count(this.props.children)}
offset={0}
style={this.props.style}
onPageScroll={this._onPageScroll}
onPageScrollStateChanged={this._onPageScrollStateChanged}
Expand Down

0 comments on commit 680b458

Please sign in to comment.