Skip to content

Commit 42ef5a0

Browse files
authored
* fix(android): Nested ViewPagers with same orientation are not scrollable(#303)
1 parent fa816af commit 42ef5a0

File tree

4 files changed

+276
-42
lines changed

4 files changed

+276
-42
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.reactnativepagerview
2+
3+
import android.content.Context
4+
import android.util.AttributeSet
5+
import android.view.MotionEvent
6+
import android.view.View
7+
import android.view.ViewConfiguration
8+
import android.widget.FrameLayout
9+
import androidx.viewpager2.widget.ViewPager2
10+
import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
11+
import kotlin.math.absoluteValue
12+
import kotlin.math.sign
13+
14+
/**
15+
* Layout to wrap a scrollable component inside a ViewPager2. Provided as a solution to the problem
16+
* where pages of ViewPager2 have nested scrollable elements that scroll in the same direction as
17+
* ViewPager2. The scrollable element needs to be the immediate and only child of this host layout.
18+
*
19+
* This solution has limitations when using multiple levels of nested scrollable elements
20+
* (e.g. a horizontal RecyclerView in a vertical RecyclerView in a horizontal ViewPager2).
21+
*/
22+
class NestedScrollableHost : FrameLayout {
23+
constructor(context: Context) : super(context)
24+
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
25+
26+
private var touchSlop = 0
27+
private var initialX = 0f
28+
private var initialY = 0f
29+
private val parentViewPager: ViewPager2?
30+
get() {
31+
var v: View? = parent as? View
32+
while (v != null && v !is ViewPager2) {
33+
v = v.parent as? View
34+
}
35+
return v as? ViewPager2
36+
}
37+
38+
private val child: View? get() = if (childCount > 0) getChildAt(0) else null
39+
40+
init {
41+
touchSlop = ViewConfiguration.get(context).scaledTouchSlop
42+
}
43+
44+
private fun canChildScroll(orientation: Int, delta: Float): Boolean {
45+
val direction = -delta.sign.toInt()
46+
return when (orientation) {
47+
0 -> child?.canScrollHorizontally(direction) ?: false
48+
1 -> child?.canScrollVertically(direction) ?: false
49+
else -> throw IllegalArgumentException()
50+
}
51+
}
52+
53+
override fun onInterceptTouchEvent(e: MotionEvent): Boolean {
54+
handleInterceptTouchEvent(e)
55+
return super.onInterceptTouchEvent(e)
56+
}
57+
58+
private fun handleInterceptTouchEvent(e: MotionEvent) {
59+
val orientation = parentViewPager?.orientation ?: return
60+
61+
// Early return if child can't scroll in same direction as parent
62+
if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) {
63+
return
64+
}
65+
66+
if (e.action == MotionEvent.ACTION_DOWN) {
67+
initialX = e.x
68+
initialY = e.y
69+
parent.requestDisallowInterceptTouchEvent(true)
70+
} else if (e.action == MotionEvent.ACTION_MOVE) {
71+
val dx = e.x - initialX
72+
val dy = e.y - initialY
73+
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL
74+
75+
// assuming ViewPager2 touch-slop is 2x touch-slop of child
76+
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f
77+
val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f
78+
79+
if (scaledDx > touchSlop || scaledDy > touchSlop) {
80+
if (isVpHorizontal == (scaledDy > scaledDx)) {
81+
// Gesture is perpendicular, allow all parents to intercept
82+
parent.requestDisallowInterceptTouchEvent(false)
83+
} else {
84+
// Gesture is parallel, query child if movement in that direction is possible
85+
if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) {
86+
// Child can scroll, disallow all parents to intercept
87+
parent.requestDisallowInterceptTouchEvent(true)
88+
} else {
89+
// Child cannot scroll, allow all parents to intercept
90+
parent.requestDisallowInterceptTouchEvent(false)
91+
}
92+
}
93+
}
94+
}
95+
}
96+
}

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

Lines changed: 61 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.reactnativepagerview
22

33
import android.view.View
4+
import android.view.ViewGroup
45
import androidx.viewpager2.widget.ViewPager2
56
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
67
import com.facebook.infer.annotation.Assertions
@@ -17,14 +18,17 @@ import com.reactnativepagerview.event.PageScrollStateChangedEvent
1718
import com.reactnativepagerview.event.PageSelectedEvent
1819

1920

20-
class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
21+
class PagerViewViewManager : ViewGroupManager<NestedScrollableHost>() {
2122
private lateinit var eventDispatcher: EventDispatcher
2223

2324
override fun getName(): String {
2425
return REACT_CLASS
2526
}
2627

27-
override fun createViewInstance(reactContext: ThemedReactContext): ViewPager2 {
28+
override fun createViewInstance(reactContext: ThemedReactContext): NestedScrollableHost {
29+
val host = NestedScrollableHost(reactContext)
30+
host.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
31+
host.isSaveEnabled = false
2832
val vp = ViewPager2(reactContext)
2933
vp.adapter = ViewPagerAdapter()
3034
//https://github.com/callstack/react-native-viewpager/issues/183
@@ -36,13 +40,13 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
3640
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
3741
super.onPageScrolled(position, positionOffset, positionOffsetPixels)
3842
eventDispatcher.dispatchEvent(
39-
PageScrollEvent(vp.id, position, positionOffset))
43+
PageScrollEvent(host.id, position, positionOffset))
4044
}
4145

4246
override fun onPageSelected(position: Int) {
4347
super.onPageSelected(position)
4448
eventDispatcher.dispatchEvent(
45-
PageSelectedEvent(vp.id, position))
49+
PageSelectedEvent(host.id, position))
4650
}
4751

4852
override fun onPageScrollStateChanged(state: Int) {
@@ -54,25 +58,34 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
5458
else -> throw IllegalStateException("Unsupported pageScrollState")
5559
}
5660
eventDispatcher.dispatchEvent(
57-
PageScrollStateChangedEvent(vp.id, pageScrollState))
61+
PageScrollStateChangedEvent(host.id, pageScrollState))
5862
}
5963
})
6064

61-
eventDispatcher.dispatchEvent(PageSelectedEvent(vp.id, vp.currentItem))
65+
eventDispatcher.dispatchEvent(PageSelectedEvent(host.id, vp.currentItem))
6266
}
67+
host.addView(vp)
68+
return host
69+
}
6370

64-
return vp
71+
private fun getViewPager(view: NestedScrollableHost): ViewPager2 {
72+
if (view.getChildAt(0) is ViewPager2) {
73+
return view.getChildAt(0) as ViewPager2
74+
} else {
75+
throw ClassNotFoundException("Could not retrieve ViewPager2 instance")
76+
}
6577
}
6678

6779
private fun setCurrentItem(view: ViewPager2, selectedTab: Int, scrollSmooth: Boolean) {
6880
refreshViewChildrenLayout(view)
6981
view.setCurrentItem(selectedTab, scrollSmooth)
7082
}
7183

72-
override fun addView(parent: ViewPager2, child: View?, index: Int) {
84+
override fun addView(host: NestedScrollableHost, child: View?, index: Int) {
7385
if (child == null) {
7486
return
7587
}
88+
val parent = getViewPager(host)
7689

7790
(parent.adapter as ViewPagerAdapter?)?.addChild(child, index);
7891

@@ -85,68 +98,71 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
8598
}
8699
}
87100

88-
override fun getChildCount(parent: ViewPager2): Int {
89-
return parent.adapter?.itemCount ?: 0;
90-
}
101+
override fun getChildCount(parent: NestedScrollableHost) = getViewPager(parent).adapter?.itemCount ?: 0
91102

92-
override fun getChildAt(parent: ViewPager2, index: Int): View {
93-
return (parent.adapter as ViewPagerAdapter?)!!.getChildAt(index)
103+
override fun getChildAt(parent: NestedScrollableHost, index: Int): View {
104+
val view = getViewPager(parent)
105+
return (view.adapter as ViewPagerAdapter?)!!.getChildAt(index)
94106
}
95107

96-
override fun removeView(parent: ViewPager2, view: View) {
97-
(parent.adapter as ViewPagerAdapter?)?.removeChild(view)
108+
override fun removeView(parent: NestedScrollableHost, view: View) {
109+
val pager = getViewPager(parent)
110+
(pager.adapter as ViewPagerAdapter?)?.removeChild(view)
98111

99112
// Required so ViewPager actually animates the removed view right away (otherwise
100113
// a white screen is shown until the next user interaction).
101114
// https://github.com/facebook/react-native/issues/17968#issuecomment-697136929
102-
refreshViewChildrenLayout(parent)
115+
refreshViewChildrenLayout(pager)
103116
}
104117

105-
override fun removeAllViews(parent: ViewPager2) {
106-
parent.isUserInputEnabled = false
107-
val adapter = parent.adapter as ViewPagerAdapter?
118+
override fun removeAllViews(parent: NestedScrollableHost) {
119+
val pager = getViewPager(parent)
120+
pager.isUserInputEnabled = false
121+
val adapter = pager.adapter as ViewPagerAdapter?
108122
adapter?.removeAll()
109123
}
110124

111-
override fun removeViewAt(parent: ViewPager2, index: Int) {
112-
val adapter = parent.adapter as ViewPagerAdapter?
125+
override fun removeViewAt(parent: NestedScrollableHost, index: Int) {
126+
val pager = getViewPager(parent)
127+
val adapter = pager.adapter as ViewPagerAdapter?
113128
adapter?.removeChildAt(index)
114129

115130
// Required so ViewPager actually animates the removed view right away (otherwise
116131
// a white screen is shown until the next user interaction).
117132
// https://github.com/facebook/react-native/issues/17968#issuecomment-697136929
118-
refreshViewChildrenLayout(parent)
133+
refreshViewChildrenLayout(pager)
119134
}
120135

121136
override fun needsCustomLayoutForChildren(): Boolean {
122137
return true
123138
}
124139

125140
@ReactProp(name = "scrollEnabled", defaultBoolean = true)
126-
fun setScrollEnabled(viewPager: ViewPager2, value: Boolean) {
127-
viewPager.isUserInputEnabled = value
141+
fun setScrollEnabled(host: NestedScrollableHost, value: Boolean) {
142+
getViewPager(host).isUserInputEnabled = value
128143
}
129144

130145
@ReactProp(name = "initialPage", defaultInt = 0)
131-
fun setInitialPage(viewPager: ViewPager2, value: Int) {
132-
viewPager.post {
133-
setCurrentItem(viewPager, value, false)
146+
fun setInitialPage(host: NestedScrollableHost, value: Int) {
147+
val view = getViewPager(host)
148+
view.post {
149+
setCurrentItem(view, value, false)
134150
}
135151
}
136152

137153
@ReactProp(name = "orientation")
138-
fun setOrientation(viewPager: ViewPager2, value: String) {
139-
viewPager.orientation = if (value == "vertical") ViewPager2.ORIENTATION_VERTICAL else ViewPager2.ORIENTATION_HORIZONTAL
154+
fun setOrientation(host: NestedScrollableHost, value: String) {
155+
getViewPager(host).orientation = if (value == "vertical") ViewPager2.ORIENTATION_VERTICAL else ViewPager2.ORIENTATION_HORIZONTAL
140156
}
141157

142158
@ReactProp(name = "offscreenPageLimit", defaultInt = ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT)
143-
operator fun set(viewPager: ViewPager2, value: Int) {
144-
viewPager.offscreenPageLimit = value
159+
operator fun set(host: NestedScrollableHost, value: Int) {
160+
getViewPager(host).offscreenPageLimit = value
145161
}
146162

147163
@ReactProp(name = "overScrollMode")
148-
fun setOverScrollMode(viewPager: ViewPager2, value: String) {
149-
val child = viewPager.getChildAt(0)
164+
fun setOverScrollMode(host: NestedScrollableHost, value: String) {
165+
val child = getViewPager(host).getChildAt(0)
150166
when (value) {
151167
"never" -> {
152168
child.overScrollMode = ViewPager2.OVER_SCROLL_NEVER
@@ -161,13 +177,14 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
161177
}
162178

163179
@ReactProp(name = "layoutDirection")
164-
fun setLayoutDirection(viewPager: ViewPager2, value: String) {
180+
fun setLayoutDirection(host: NestedScrollableHost, value: String) {
181+
val view = getViewPager(host)
165182
when (value) {
166183
"rtl" -> {
167-
viewPager.layoutDirection = View.LAYOUT_DIRECTION_RTL
184+
view.layoutDirection = View.LAYOUT_DIRECTION_RTL
168185
}
169186
else -> {
170-
viewPager.layoutDirection = View.LAYOUT_DIRECTION_LTR
187+
view.layoutDirection = View.LAYOUT_DIRECTION_LTR
171188
}
172189
}
173190
}
@@ -189,24 +206,25 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
189206
COMMAND_SET_SCROLL_ENABLED)
190207
}
191208

192-
override fun receiveCommand(root: ViewPager2, commandId: Int, args: ReadableArray?) {
209+
override fun receiveCommand(root: NestedScrollableHost, commandId: Int, args: ReadableArray?) {
193210
super.receiveCommand(root, commandId, args)
194-
Assertions.assertNotNull(root)
211+
val view = getViewPager(root)
212+
Assertions.assertNotNull(view)
195213
Assertions.assertNotNull(args)
196-
val childCount = root.adapter?.itemCount
214+
val childCount = view.adapter?.itemCount
197215

198216
when (commandId) {
199217
COMMAND_SET_PAGE, COMMAND_SET_PAGE_WITHOUT_ANIMATION -> {
200218
val pageIndex = args!!.getInt(0)
201219
val canScroll = childCount != null && childCount > 0 && pageIndex >= 0 && pageIndex < childCount
202220
if (canScroll) {
203221
val scrollWithAnimation = commandId == COMMAND_SET_PAGE
204-
setCurrentItem(root, pageIndex, scrollWithAnimation)
222+
setCurrentItem(view, pageIndex, scrollWithAnimation)
205223
eventDispatcher.dispatchEvent(PageSelectedEvent(root.id, pageIndex))
206224
}
207225
}
208226
COMMAND_SET_SCROLL_ENABLED -> {
209-
root.isUserInputEnabled = args!!.getBoolean(0)
227+
view.isUserInputEnabled = args!!.getBoolean(0)
210228
}
211229
else -> throw IllegalArgumentException(String.format(
212230
"Unsupported command %d received by %s.",
@@ -216,7 +234,8 @@ class PagerViewViewManager : ViewGroupManager<ViewPager2>() {
216234
}
217235

218236
@ReactProp(name = "pageMargin", defaultFloat = 0F)
219-
fun setPageMargin(pager: ViewPager2, margin: Float) {
237+
fun setPageMargin(host: NestedScrollableHost, margin: Float) {
238+
val pager = getViewPager(host)
220239
val pageMargin = PixelUtil.toPixelFromDIP(margin).toInt()
221240
/**
222241
* Don't use MarginPageTransformer to be able to support negative margins

example/src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ScrollablePagerViewExample } from './ScrollablePagerViewExample';
1010
import { ScrollViewInsideExample } from './ScrollViewInsideExample';
1111
import HeadphonesCarouselExample from './HeadphonesCarouselExample';
1212
import PaginationDotsExample from './PaginationDotsExample';
13+
import { NestPagerView } from './NestPagerView';
1314

1415
const examples = [
1516
{ component: BasicPagerViewExample, name: 'Basic Example' },
@@ -26,6 +27,10 @@ const examples = [
2627
component: ScrollViewInsideExample,
2728
name: 'ScrollView inside PagerView Example',
2829
},
30+
{
31+
component: NestPagerView,
32+
name: 'Nest PagerView Example',
33+
},
2934
];
3035

3136
// const examples = [{ component: BasicPagerViewExample, name: 'Basic Example' }];

0 commit comments

Comments
 (0)