@onekeyfe/react-native-pager-view 3.0.165 → 3.0.167
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +8 -2
- package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +139 -6
- package/android/src/main/java/com/reactnativepagerview/NestedScrollableHost.kt +75 -0
- package/docs/SPEC.md +118 -0
- package/ios/RNCCollapsiblePagerViewComponentView.mm +230 -16
- package/ios/tests/RNCCollapsiblePagerFirstFrameTests.mm +150 -0
- package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +111 -1
- package/package.json +2 -2
|
@@ -1301,9 +1301,15 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
1301
1301
|
|
|
1302
1302
|
MotionEvent.ACTION_MOVE -> {
|
|
1303
1303
|
if (!headerTouchActive) return super.dispatchTouchEvent(event)
|
|
1304
|
+
val dx = event.x - headerDownX
|
|
1305
|
+
val dy = event.y - headerDownY
|
|
1306
|
+
// A horizontal header guard must not consume a later vertical drag.
|
|
1307
|
+
if (headerGestureOwner != HeaderGestureOwner.LIST &&
|
|
1308
|
+
kotlin.math.abs(dy) > touchSlopPx && kotlin.math.abs(dy) > kotlin.math.abs(dx)) {
|
|
1309
|
+
headerGestureOwner = HeaderGestureOwner.LIST
|
|
1310
|
+
log("direction-recover owner=list region=$headerTouchRegion dx=${dx.roundToInt()} dy=${dy.roundToInt()}")
|
|
1311
|
+
}
|
|
1304
1312
|
if (headerGestureOwner == HeaderGestureOwner.NONE) {
|
|
1305
|
-
val dx = event.x - headerDownX
|
|
1306
|
-
val dy = event.y - headerDownY
|
|
1307
1313
|
if (kotlin.math.abs(dx) <= touchSlopPx && kotlin.math.abs(dy) <= touchSlopPx) {
|
|
1308
1314
|
return super.dispatchTouchEvent(event)
|
|
1309
1315
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package com.reactnativepagerview
|
|
2
2
|
|
|
3
|
+
import com.margelo.nitro.nativelogger.OneKeyLog
|
|
4
|
+
|
|
3
5
|
import android.content.Context
|
|
4
6
|
import android.graphics.Color
|
|
5
7
|
import android.os.SystemClock
|
|
@@ -380,6 +382,11 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
|
|
|
380
382
|
private var stopping = false
|
|
381
383
|
private var startingProgrammaticScroll = false
|
|
382
384
|
private var downY = 0f
|
|
385
|
+
private var directionDownX = 0f
|
|
386
|
+
private var directionDownY = 0f
|
|
387
|
+
private var directionPointerId = MotionEvent.INVALID_POINTER_ID
|
|
388
|
+
private var smoothPagerTouch = false
|
|
389
|
+
private var touchAxis = 0
|
|
383
390
|
private var velocityTracker: VelocityTracker? = null
|
|
384
391
|
private var activePointerId = MotionEvent.INVALID_POINTER_ID
|
|
385
392
|
private val maximumVelocity = ViewConfiguration.get(context).scaledMaximumFlingVelocity
|
|
@@ -391,16 +398,110 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
|
|
|
391
398
|
overScrollMode = OVER_SCROLL_NEVER
|
|
392
399
|
}
|
|
393
400
|
|
|
401
|
+
private fun updateTouchDirection(event: MotionEvent) {
|
|
402
|
+
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
|
403
|
+
directionPointerId = event.getPointerId(0)
|
|
404
|
+
directionDownX = event.x
|
|
405
|
+
directionDownY = event.y
|
|
406
|
+
downY = event.y
|
|
407
|
+
touchAxis = 0
|
|
408
|
+
smoothPagerTouch = false
|
|
409
|
+
var ancestor = host.parent as? View
|
|
410
|
+
while (ancestor != null) {
|
|
411
|
+
if (ancestor is CollapsiblePagerHost && ancestor.nativeSmoothHeaderScrollEnabled) {
|
|
412
|
+
smoothPagerTouch = true
|
|
413
|
+
break
|
|
414
|
+
}
|
|
415
|
+
ancestor = ancestor.parent as? View
|
|
416
|
+
}
|
|
417
|
+
OneKeyLog.debug("NativeScroller", "viewport-down slop=$touchSlop density=${resources.displayMetrics.density} scrollY=$scrollY smooth=$smoothPagerTouch")
|
|
418
|
+
} else if (event.actionMasked == MotionEvent.ACTION_POINTER_UP &&
|
|
419
|
+
event.getPointerId(event.actionIndex) == directionPointerId) {
|
|
420
|
+
val replacement = if (event.actionIndex == 0) 1 else 0
|
|
421
|
+
directionPointerId = if (replacement < event.pointerCount) event.getPointerId(replacement)
|
|
422
|
+
else MotionEvent.INVALID_POINTER_ID
|
|
423
|
+
if (replacement < event.pointerCount) {
|
|
424
|
+
directionDownX = event.getX(replacement)
|
|
425
|
+
directionDownY = event.getY(replacement)
|
|
426
|
+
}
|
|
427
|
+
} else if (smoothPagerTouch && touchAxis == 0 && event.actionMasked == MotionEvent.ACTION_MOVE) {
|
|
428
|
+
val pointerIndex = event.findPointerIndex(directionPointerId)
|
|
429
|
+
if (pointerIndex < 0) return
|
|
430
|
+
val dx = abs(event.getX(pointerIndex) - directionDownX)
|
|
431
|
+
val dy = abs(event.getY(pointerIndex) - directionDownY)
|
|
432
|
+
if (dy > touchSlop && dy > dx) touchAxis = 1
|
|
433
|
+
else if (dx >= 32f * resources.displayMetrics.density && dx > dy) touchAxis = 2
|
|
434
|
+
if (touchAxis != 0) {
|
|
435
|
+
OneKeyLog.debug("NativeScroller", "viewport-direction axis=$touchAxis dx=$dx dy=$dy scrollY=$scrollY")
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private var childDisallowsIntercept = false
|
|
441
|
+
private var loggedBlockedVertical = false
|
|
442
|
+
|
|
443
|
+
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
|
444
|
+
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
|
445
|
+
childDisallowsIntercept = false
|
|
446
|
+
loggedBlockedVertical = false
|
|
447
|
+
}
|
|
448
|
+
if (scrollEnabled) updateTouchDirection(event)
|
|
449
|
+
if (smoothPagerTouch && touchAxis == 1 && childDisallowsIntercept &&
|
|
450
|
+
event.actionMasked == MotionEvent.ACTION_MOVE) {
|
|
451
|
+
childDisallowsIntercept = false
|
|
452
|
+
OneKeyLog.debug("NativeScroller", "viewport-recover-vertical scrollY=$scrollY")
|
|
453
|
+
super.requestDisallowInterceptTouchEvent(false)
|
|
454
|
+
}
|
|
455
|
+
if (smoothPagerTouch && event.actionMasked == MotionEvent.ACTION_MOVE && !loggedBlockedVertical) {
|
|
456
|
+
val pointerIndex = event.findPointerIndex(directionPointerId)
|
|
457
|
+
if (pointerIndex >= 0) {
|
|
458
|
+
val dx = abs(event.getX(pointerIndex) - directionDownX)
|
|
459
|
+
val dy = abs(event.getY(pointerIndex) - directionDownY)
|
|
460
|
+
if (dy > touchSlop && dy > dx) {
|
|
461
|
+
loggedBlockedVertical = true
|
|
462
|
+
OneKeyLog.debug("NativeScroller", "viewport-dispatch-vertical dx=$dx dy=$dy axis=$touchAxis disallow=$childDisallowsIntercept scrollY=$scrollY")
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
try { return super.dispatchTouchEvent(event) } finally {
|
|
467
|
+
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
|
468
|
+
releaseTouchDirection()
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
|
|
474
|
+
childDisallowsIntercept = disallowIntercept
|
|
475
|
+
if (smoothPagerTouch) OneKeyLog.debug("NativeScroller", "viewport-child-disallow value=$disallowIntercept axis=$touchAxis scrollY=$scrollY")
|
|
476
|
+
super.requestDisallowInterceptTouchEvent(disallowIntercept)
|
|
477
|
+
}
|
|
478
|
+
|
|
394
479
|
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
|
395
480
|
if (!scrollEnabled) return false
|
|
396
|
-
|
|
481
|
+
updateTouchDirection(event)
|
|
482
|
+
if (smoothPagerTouch && event.actionMasked == MotionEvent.ACTION_MOVE && touchAxis != 1) return false
|
|
397
483
|
val intercepted = super.onInterceptTouchEvent(event)
|
|
398
|
-
if (intercepted && event.actionMasked == MotionEvent.ACTION_MOVE)
|
|
484
|
+
if (intercepted && event.actionMasked == MotionEvent.ACTION_MOVE) {
|
|
485
|
+
OneKeyLog.debug("NativeScroller", "viewport-intercept dy=${event.y - downY} slop=$touchSlop scrollY=$scrollY")
|
|
486
|
+
beginDrag(event)
|
|
487
|
+
}
|
|
399
488
|
return intercepted
|
|
400
489
|
}
|
|
401
490
|
|
|
402
491
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
403
492
|
if (!scrollEnabled) return false
|
|
493
|
+
updateTouchDirection(event)
|
|
494
|
+
if (smoothPagerTouch && touchAxis != 1) {
|
|
495
|
+
if (event.actionMasked == MotionEvent.ACTION_MOVE) return true
|
|
496
|
+
if (event.actionMasked == MotionEvent.ACTION_UP) {
|
|
497
|
+
val cancel = MotionEvent.obtain(event)
|
|
498
|
+
cancel.action = MotionEvent.ACTION_CANCEL
|
|
499
|
+
try { return super.onTouchEvent(cancel) } finally {
|
|
500
|
+
cancel.recycle()
|
|
501
|
+
releaseTouchDirection()
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
404
505
|
when (event.actionMasked) {
|
|
405
506
|
MotionEvent.ACTION_DOWN -> downY = event.y
|
|
406
507
|
MotionEvent.ACTION_MOVE -> {
|
|
@@ -409,7 +510,19 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
|
|
|
409
510
|
}
|
|
410
511
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> endDrag(event)
|
|
411
512
|
}
|
|
412
|
-
return super.onTouchEvent(event)
|
|
513
|
+
try { return super.onTouchEvent(event) } finally {
|
|
514
|
+
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
|
|
515
|
+
releaseTouchDirection()
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private fun releaseTouchDirection() {
|
|
521
|
+
childDisallowsIntercept = false
|
|
522
|
+
loggedBlockedVertical = false
|
|
523
|
+
directionPointerId = MotionEvent.INVALID_POINTER_ID
|
|
524
|
+
smoothPagerTouch = false
|
|
525
|
+
touchAxis = 0
|
|
413
526
|
}
|
|
414
527
|
|
|
415
528
|
// Record at the refresh boundary so interception does not truncate the pointer history.
|
|
@@ -435,6 +548,7 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
|
|
|
435
548
|
}
|
|
436
549
|
|
|
437
550
|
internal fun releaseTouchTracking() {
|
|
551
|
+
releaseTouchDirection()
|
|
438
552
|
velocityTracker?.recycle()
|
|
439
553
|
velocityTracker = null
|
|
440
554
|
activePointerId = MotionEvent.INVALID_POINTER_ID
|
|
@@ -443,6 +557,7 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
|
|
|
443
557
|
internal fun beginDrag(event: MotionEvent) {
|
|
444
558
|
if (dragging) return
|
|
445
559
|
dragging = true
|
|
560
|
+
OneKeyLog.debug("NativeScroller", "viewport-begin-drag dy=${event.y - downY} slop=$touchSlop scrollY=$scrollY")
|
|
446
561
|
// Header-forwarded touches bypass the refresh boundary's touch stream.
|
|
447
562
|
host.cancelPendingScrollRestore()
|
|
448
563
|
if (host.keyboardDismissMode != "none") host.dismissKeyboard()
|
|
@@ -543,6 +658,8 @@ private class NativeScrollerRefreshLayout(context: Context) : SwipeRefreshLayout
|
|
|
543
658
|
private var downX = 0f
|
|
544
659
|
private var downY = 0f
|
|
545
660
|
private var horizontal = false
|
|
661
|
+
private var vertical = false
|
|
662
|
+
private var smoothPagerGesture = false
|
|
546
663
|
private var intercepted = false
|
|
547
664
|
private val slop = ViewConfiguration.get(context).scaledTouchSlop
|
|
548
665
|
|
|
@@ -567,10 +684,26 @@ private class NativeScrollerRefreshLayout(context: Context) : SwipeRefreshLayout
|
|
|
567
684
|
downX = event.x
|
|
568
685
|
downY = event.y
|
|
569
686
|
horizontal = false
|
|
687
|
+
vertical = false
|
|
688
|
+
smoothPagerGesture = false
|
|
689
|
+
var ancestor = parent as? View
|
|
690
|
+
while (ancestor != null) {
|
|
691
|
+
if (ancestor is CollapsiblePagerHost && ancestor.nativeSmoothHeaderScrollEnabled) {
|
|
692
|
+
smoothPagerGesture = true
|
|
693
|
+
break
|
|
694
|
+
}
|
|
695
|
+
ancestor = ancestor.parent as? View
|
|
696
|
+
}
|
|
570
697
|
intercepted = false
|
|
571
|
-
} else if (event.actionMasked == MotionEvent.ACTION_MOVE &&
|
|
572
|
-
|
|
573
|
-
|
|
698
|
+
} else if (event.actionMasked == MotionEvent.ACTION_MOVE && (!smoothPagerGesture || !vertical)) {
|
|
699
|
+
val dx = abs(event.x - downX)
|
|
700
|
+
val dy = abs(event.y - downY)
|
|
701
|
+
if (smoothPagerGesture && dy > slop && dy > dx) {
|
|
702
|
+
vertical = true
|
|
703
|
+
horizontal = false
|
|
704
|
+
} else if (dx > (if (smoothPagerGesture) 32f * resources.displayMetrics.density else slop.toFloat()) && dx > dy) {
|
|
705
|
+
horizontal = true
|
|
706
|
+
}
|
|
574
707
|
}
|
|
575
708
|
if (horizontal) return false
|
|
576
709
|
val result = super.onInterceptTouchEvent(event)
|
|
@@ -5,11 +5,15 @@ import android.util.AttributeSet
|
|
|
5
5
|
import android.view.Choreographer
|
|
6
6
|
import android.view.MotionEvent
|
|
7
7
|
import android.view.View
|
|
8
|
+
import android.view.ViewGroup
|
|
8
9
|
import android.view.ViewConfiguration
|
|
10
|
+
import android.widget.HorizontalScrollView
|
|
9
11
|
import android.widget.FrameLayout
|
|
12
|
+
import androidx.recyclerview.widget.RecyclerView
|
|
10
13
|
import androidx.viewpager2.widget.ViewPager2
|
|
11
14
|
import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
|
|
12
15
|
import com.facebook.react.uimanager.events.NativeGestureUtil
|
|
16
|
+
import com.margelo.nitro.nativelogger.OneKeyLog
|
|
13
17
|
import kotlin.math.absoluteValue
|
|
14
18
|
import kotlin.math.sign
|
|
15
19
|
|
|
@@ -32,6 +36,11 @@ open class NestedScrollableHost : FrameLayout {
|
|
|
32
36
|
private var touchSlop = 0
|
|
33
37
|
private var initialX = 0f
|
|
34
38
|
private var initialY = 0f
|
|
39
|
+
private var smoothTouchActive = false
|
|
40
|
+
private var smoothTouchAxis = 0
|
|
41
|
+
private var smoothDownX = 0f
|
|
42
|
+
private var smoothDownY = 0f
|
|
43
|
+
private var nestedHorizontalOwner: View? = null
|
|
35
44
|
protected var nestedNativeGestureStarted: Boolean = false
|
|
36
45
|
private val parentViewPager: ViewPager2?
|
|
37
46
|
get() {
|
|
@@ -57,8 +66,74 @@ open class NestedScrollableHost : FrameLayout {
|
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
68
|
|
|
69
|
+
private fun hasSmoothHeaderOwner(): Boolean {
|
|
70
|
+
var ancestor: View? = this
|
|
71
|
+
while (ancestor != null) {
|
|
72
|
+
if (ancestor is CollapsiblePagerHost && ancestor.nativeSmoothHeaderScrollEnabled) return true
|
|
73
|
+
ancestor = ancestor.parent as? View
|
|
74
|
+
}
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private fun horizontalOwnerAt(view: View, x: Float, y: Float): View? {
|
|
79
|
+
if (view is ViewGroup) {
|
|
80
|
+
for (index in view.childCount - 1 downTo 0) {
|
|
81
|
+
val nested = view.getChildAt(index)
|
|
82
|
+
if (nested.visibility != View.VISIBLE || nested.alpha <= 0f) continue
|
|
83
|
+
val localX = x + view.scrollX - nested.left - nested.translationX
|
|
84
|
+
val localY = y + view.scrollY - nested.top - nested.translationY
|
|
85
|
+
if (localX < 0 || localY < 0 || localX >= nested.width || localY >= nested.height) continue
|
|
86
|
+
horizontalOwnerAt(nested, localX, localY)?.let { return it }
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Exclude this host's own pager; only an inner horizontal viewport has priority.
|
|
91
|
+
if (view.parent !== child && (view is HorizontalScrollView || view is RecyclerView) &&
|
|
92
|
+
(view.canScrollHorizontally(-1) || view.canScrollHorizontally(1))) return view
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private fun coordinateSmoothTouch(e: MotionEvent) {
|
|
97
|
+
val pager = child as? ViewPager2 ?: return
|
|
98
|
+
if (e.actionMasked == MotionEvent.ACTION_DOWN) {
|
|
99
|
+
smoothTouchActive = hasSmoothHeaderOwner()
|
|
100
|
+
smoothTouchAxis = 0
|
|
101
|
+
smoothDownX = e.x
|
|
102
|
+
smoothDownY = e.y
|
|
103
|
+
nestedHorizontalOwner = if (smoothTouchActive) horizontalOwnerAt(this, e.x, e.y) else null
|
|
104
|
+
}
|
|
105
|
+
if (!smoothTouchActive) return
|
|
106
|
+
val dx = e.x - smoothDownX
|
|
107
|
+
val dy = e.y - smoothDownY
|
|
108
|
+
if (e.actionMasked == MotionEvent.ACTION_MOVE && smoothTouchAxis == 0) {
|
|
109
|
+
smoothTouchAxis = when {
|
|
110
|
+
dy.absoluteValue > touchSlop && dy.absoluteValue > dx.absoluteValue -> 1
|
|
111
|
+
dx.absoluteValue >= 32f * resources.displayMetrics.density && dx.absoluteValue > dy.absoluteValue -> 2
|
|
112
|
+
else -> 0
|
|
113
|
+
}
|
|
114
|
+
if (smoothTouchAxis != 0) {
|
|
115
|
+
OneKeyLog.debug("CollapsiblePager", "content-direction owner=${if (smoothTouchAxis == 1) "list" else "horizontal"} dx=$dx dy=$dy nested=${nestedHorizontalOwner != null}")
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
val innerCanScroll = nestedHorizontalOwner?.canScrollHorizontally(-dx.sign.toInt()) == true
|
|
119
|
+
// Preserve DOWN in the real pager, but defer horizontal interception while
|
|
120
|
+
// intent is pending. Ancestor vertical lists must still be able to intercept.
|
|
121
|
+
(pager.getChildAt(0) as? RecyclerView)?.requestDisallowInterceptTouchEvent(smoothTouchAxis != 2 || innerCanScroll)
|
|
122
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
123
|
+
}
|
|
124
|
+
|
|
60
125
|
override fun dispatchTouchEvent(e: MotionEvent): Boolean {
|
|
126
|
+
coordinateSmoothTouch(e)
|
|
61
127
|
val handled = super.dispatchTouchEvent(e)
|
|
128
|
+
if (smoothTouchActive && (e.actionMasked == MotionEvent.ACTION_UP || e.actionMasked == MotionEvent.ACTION_CANCEL)) {
|
|
129
|
+
if (nestedNativeGestureStarted) {
|
|
130
|
+
NativeGestureUtil.notifyNativeGestureEnded(this, e)
|
|
131
|
+
nestedNativeGestureStarted = false
|
|
132
|
+
}
|
|
133
|
+
smoothTouchActive = false
|
|
134
|
+
smoothTouchAxis = 0
|
|
135
|
+
nestedHorizontalOwner = null
|
|
136
|
+
}
|
|
62
137
|
|
|
63
138
|
// After the full child dispatch cycle, a deeply-nested NestedScrollableHost may
|
|
64
139
|
// have called requestDisallowInterceptTouchEvent(false) which propagates all the
|
package/docs/SPEC.md
CHANGED
|
@@ -187,6 +187,72 @@ unchanged. Acceptance requires a real iOS button-origin drag that does not fire
|
|
|
187
187
|
the button, a button-origin fling that scrolls, an ordinary tap that fires, and
|
|
188
188
|
focused native touch-delivery coverage.
|
|
189
189
|
|
|
190
|
+
## Smooth-header touch direction and press retention
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
Status: implemented in module source. Equivalent native gesture behavior was
|
|
194
|
+
independently verified in the Home consumer's recorded round3 simulator cases.
|
|
195
|
+
The module transplant preserves those decisions and excludes application-specific
|
|
196
|
+
acceptance probes. Package compilation and packaging are checked separately;
|
|
197
|
+
consumer evidence does not establish acceptance of a subsequently installed
|
|
198
|
+
release. Physical-device feel and semantic accessibility activation remain
|
|
199
|
+
unverified. No public API is added.
|
|
200
|
+
|
|
201
|
+
Existing smooth-header callers MUST receive this behavior automatically, with no
|
|
202
|
+
additional caller options.
|
|
203
|
+
|
|
204
|
+
On iOS and Android, smooth-header mode MUST distinguish pending intent from a
|
|
205
|
+
confirmed vertical or horizontal drag. A small horizontal start followed by
|
|
206
|
+
vertical movement MUST be allowed to become a vertical scroll while intent is
|
|
207
|
+
pending. A confirmed vertical drag MUST retain vertical ownership until end or
|
|
208
|
+
cancellation, even if later movement becomes horizontal. This applies to page
|
|
209
|
+
content, the shared header, and touches beginning on an inner horizontal banner.
|
|
210
|
+
Normal taps MUST remain functional. Press-retention behavior in application
|
|
211
|
+
controls belongs to the consuming responder implementation; the Pager package
|
|
212
|
+
does not contain or publish Tamagui helpers. The native application tab bar has
|
|
213
|
+
a separate contract in react-native-tab-view/docs/SPEC.md.
|
|
214
|
+
|
|
215
|
+
The current native implementation confirms horizontal intent after at least
|
|
216
|
+
32 points on iOS or 32 dp on Android, with horizontal displacement exceeding
|
|
217
|
+
vertical displacement. Vertical intent uses the native vertical scroll threshold
|
|
218
|
+
(Android touch slop; iOS guard 10 points) and vertical-dominant displacement.
|
|
219
|
+
There is no timed wait and no percentage-of-page-width threshold. After horizontal
|
|
220
|
+
intent is confirmed, page content may retain horizontal ownership: the contract
|
|
221
|
+
does not require transferring an already established page swipe to a vertical
|
|
222
|
+
scroller. Header forwarding can recover to the vertical list when vertical
|
|
223
|
+
movement becomes dominant. An inner horizontal viewport that can consume a
|
|
224
|
+
confirmed horizontal drag MUST have priority over the Home page pager.
|
|
225
|
+
|
|
226
|
+
This intent confirmation MUST NOT add a second lazy-mount progress threshold.
|
|
227
|
+
Once native horizontal dragging begins, the first nonzero page progress MUST
|
|
228
|
+
prepare the target page on both platforms, as specified below.
|
|
229
|
+
|
|
230
|
+
Acceptance MUST cover an initial small horizontal movement followed by vertical
|
|
231
|
+
scrolling from ordinary content, header buttons, Prime/support controls and inner
|
|
232
|
+
banners; vertical-first gestures that later turn horizontal; stationary taps;
|
|
233
|
+
normal page swipes; inner banner swipes; and iOS fixed-control drag cancellation.
|
|
234
|
+
Native logger traces and before/after frames MUST distinguish scrolling, unintended
|
|
235
|
+
page changes and unintended control activation. Simulator acceptance establishes only the recorded touch sequences; it does not
|
|
236
|
+
establish physical-device feel or untested multi-touch combinations.
|
|
237
|
+
|
|
238
|
+
On Smooth Home, Android NativeScrollerViewport MUST use the same pending /
|
|
239
|
+
vertical / horizontal direction decision on interception and on its own touch
|
|
240
|
+
path. It MUST deliver the original DOWN to NestedScrollView, defer pending and
|
|
241
|
+
horizontal MOVE scrolling, and retain confirmed vertical ownership until the
|
|
242
|
+
sequence ends. Header-forwarded DOWN MUST initialize this decision independently
|
|
243
|
+
of the refresh boundary. Dispatch MUST still observe child-owned movement; only
|
|
244
|
+
a confirmed vertical Smooth Home gesture may release the viewport's child
|
|
245
|
+
interception prohibition. Pending and horizontal movement MUST preserve it.
|
|
246
|
+
|
|
247
|
+
iOS nested horizontal ownership MUST include enabled nonpaging horizontal-only
|
|
248
|
+
ScrollViews, with content wider than bounds and no vertical content overflow.
|
|
249
|
+
Both pan holding and failure dependencies MUST use the same eligibility. A
|
|
250
|
+
failure dependency on the Pager guard MUST apply only to the horizontal pan;
|
|
251
|
+
independent non-pan recognizers, including stationary native long presses, MUST
|
|
252
|
+
remain able to recognize before finger-up while Pager intent is pending. A
|
|
253
|
+
second touch in the pending Pager guard fails recognition; an already active
|
|
254
|
+
Pager guard cancels and restores each held pan's original minimum touch count.
|
|
255
|
+
|
|
190
256
|
## Native tab-press animation default
|
|
191
257
|
|
|
192
258
|
Behavior change: previously an omitted `nativeTabPressAnimationEnabled` animated
|
|
@@ -523,3 +589,55 @@ introduced. This change makes no frame-rate claim.
|
|
|
523
589
|
The newly merged NativeScroller viewport tracking uses the same weak-key
|
|
524
590
|
snapshot rule. Its cleanup has source and focused regression coverage; the
|
|
525
591
|
pre-migration runtime checks above do not establish its device acceptance.
|
|
592
|
+
|
|
593
|
+
## iOS lazy-page first-frame preparation
|
|
594
|
+
|
|
595
|
+
Status: implemented and runtime verified in the consuming app's local native
|
|
596
|
+
build on iOS 26.5 (2026-10-10). This package change preserves the same functional
|
|
597
|
+
source; app-specific diagnostic logs remain in that local build.
|
|
598
|
+
|
|
599
|
+
In smooth-header mode, a newly mounted, owned NativeScroller or live NativeList
|
|
600
|
+
on the current page, either adjacent page, or a programmatic destination MUST
|
|
601
|
+
receive its pager insets and restored offset in Fabric's did-mount callback,
|
|
602
|
+
before the next frame. This also applies during a horizontal drag or animation.
|
|
603
|
+
Preparation MUST NOT change the selected page, move the shared header out of its
|
|
604
|
+
transition owner, attach an offscreen page's observer, or repeatedly restore an
|
|
605
|
+
already managed scroll view.
|
|
606
|
+
|
|
607
|
+
A scroll view removed in a Fabric transaction MUST be excluded from discovery
|
|
608
|
+
until that transaction finishes. The exclusion uses weak native identities,
|
|
609
|
+
is cleared after did-mount and on recycle, and allows a moved view to be found
|
|
610
|
+
again in its next owner. A NativeList collection is usable only while its nearest
|
|
611
|
+
Fabric component owns it through contentView, has a registered tag, and retains
|
|
612
|
+
that content view as collection delegate. A disposed or unregistered NativeList
|
|
613
|
+
MUST NOT become a generic collection fallback.
|
|
614
|
+
|
|
615
|
+
Verified NativeList collections bypass the existing 0.5-second generic collection
|
|
616
|
+
fallback debounce, including replacement-state restoration. Other collection
|
|
617
|
+
fallbacks keep that safety delay. Caller insets and retained logical offsets
|
|
618
|
+
remain governed by the existing restoration policy.
|
|
619
|
+
|
|
620
|
+
This is a UI-thread, main-runtime native coordination change with no new props,
|
|
621
|
+
events, background-runtime work, persistence or retained strong view cache.
|
|
622
|
+
Android already prepares discovered scroll children before draw and is unchanged;
|
|
623
|
+
Web is unchanged. Consumers own the decision to lazily mount or prepare a target
|
|
624
|
+
page; this package does not import scene code or start business requests.
|
|
625
|
+
|
|
626
|
+
The Home consumer uses the same lazy-mount trigger on iOS and Android: after
|
|
627
|
+
entering the horizontal dragging state, the first received page-scroll progress
|
|
628
|
+
with nonzero displacement relative to the starting page MUST trigger early
|
|
629
|
+
mounting of the adjacent target page in that direction. There is no fixed
|
|
630
|
+
distance or percentage threshold. Measured trigger percentages, such as 0.65%,
|
|
631
|
+
are event samples rather than configured thresholds. Early mounting MUST NOT
|
|
632
|
+
change the selected page; selection follows the existing native selection event.
|
|
633
|
+
|
|
634
|
+
Conformance: RNCCollapsiblePagerViewComponentView.mm and the focused ownership /
|
|
635
|
+
first-frame XCTest cases. Consumer acceptance confirmed a cold Spot-to-Perps
|
|
636
|
+
swipe: the native skeleton received inset 340 and offset -340 at t=1791603349124
|
|
637
|
+
while transition=1, 427 ms before native drag settlement. Its window y=514 was
|
|
638
|
+
below the header bottom y=502. These values are measured evidence, not package
|
|
639
|
+
constants. Native view geometry is confirmed. Later finite consumer round3 simulator
|
|
640
|
+
cases additionally verified recorded refresh, header, restoration and gesture
|
|
641
|
+
paths; they do not establish every possible sequence or runtime acceptance of a
|
|
642
|
+
newly installed module release. Physical-device feel and semantic accessibility
|
|
643
|
+
activation remain unverified.
|
|
@@ -158,6 +158,28 @@ static UIView *RNCScrollComponentView(UIScrollView *scrollView)
|
|
|
158
158
|
return nil;
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// NativeList owns its collection through a Fabric contentView and its delegate.
|
|
162
|
+
// A dropped Nitro view can remain in the hierarchy after its delegate is cleared.
|
|
163
|
+
static RCTViewComponentView *RNCNativeListComponentForScrollView(UIScrollView *scrollView)
|
|
164
|
+
{
|
|
165
|
+
Class nativeListClass = NSClassFromString(@"HybridNativeListComponent");
|
|
166
|
+
for (UIView *component = scrollView; component != nil; component = component.superview) {
|
|
167
|
+
if (![component isKindOfClass:RCTViewComponentView.class]) continue;
|
|
168
|
+
return nativeListClass != Nil && [component isKindOfClass:nativeListClass]
|
|
169
|
+
? (RCTViewComponentView *)component : nil;
|
|
170
|
+
}
|
|
171
|
+
return nil;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
static BOOL RNCIsMountedNativeListScrollView(UIScrollView *scrollView)
|
|
175
|
+
{
|
|
176
|
+
if (![scrollView isKindOfClass:UICollectionView.class]) return NO;
|
|
177
|
+
RCTViewComponentView *component = RNCNativeListComponentForScrollView(scrollView);
|
|
178
|
+
UIView *content = component.contentView;
|
|
179
|
+
return component.tag > 0 && content != nil && [scrollView isDescendantOfView:content] &&
|
|
180
|
+
((UICollectionView *)scrollView).delegate == (id)content;
|
|
181
|
+
}
|
|
182
|
+
|
|
161
183
|
typedef void (^RNCCollapsiblePagerNativeTabPressHandler)(NSInteger index, NSString *key);
|
|
162
184
|
|
|
163
185
|
static CGFloat RNCClamp(CGFloat value, CGFloat minimum, CGFloat maximum)
|
|
@@ -165,6 +187,24 @@ static CGFloat RNCClamp(CGFloat value, CGFloat minimum, CGFloat maximum)
|
|
|
165
187
|
return MIN(MAX(value, minimum), maximum);
|
|
166
188
|
}
|
|
167
189
|
|
|
190
|
+
static BOOL RNCIsEnabledHorizontalScrollOwner(UIScrollView *scroll)
|
|
191
|
+
{
|
|
192
|
+
return scroll.scrollEnabled && scroll.panGestureRecognizer.enabled &&
|
|
193
|
+
scroll.contentSize.width > CGRectGetWidth(scroll.bounds) && !scroll.alwaysBounceVertical &&
|
|
194
|
+
scroll.contentSize.height <= CGRectGetHeight(scroll.bounds) + 0.5;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
static void RNCGuardNestedHorizontalPans(UIView *view, UIGestureRecognizer *guard)
|
|
198
|
+
{
|
|
199
|
+
if ([view isKindOfClass:UIScrollView.class]) {
|
|
200
|
+
UIScrollView *scroll = (UIScrollView *)view;
|
|
201
|
+
if (RNCIsEnabledHorizontalScrollOwner(scroll)) {
|
|
202
|
+
[scroll.panGestureRecognizer requireGestureRecognizerToFail:guard];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (UIView *child in view.subviews) RNCGuardNestedHorizontalPans(child, guard);
|
|
206
|
+
}
|
|
207
|
+
|
|
168
208
|
static void RNCCollapsiblePagerLog(NSString *message)
|
|
169
209
|
{
|
|
170
210
|
Class logClass = NSClassFromString(@"ReactNativeNativeLogger.OneKeyLog");
|
|
@@ -995,6 +1035,15 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
995
1035
|
intent.y]);
|
|
996
1036
|
return NO;
|
|
997
1037
|
}
|
|
1038
|
+
UIView *target = [self hitTest:[pan locationInView:self] withEvent:nil];
|
|
1039
|
+
for (UIView *ancestor = target; ancestor != nil && ancestor != self; ancestor = ancestor.superview) {
|
|
1040
|
+
if (![ancestor isKindOfClass:UIScrollView.class]) continue;
|
|
1041
|
+
UIScrollView *inner = (UIScrollView *)ancestor;
|
|
1042
|
+
CGFloat maximum = MAX(0, inner.contentSize.width - CGRectGetWidth(inner.bounds));
|
|
1043
|
+
BOOL canScroll = maximum > 0 && (intent.x < 0
|
|
1044
|
+
? inner.contentOffset.x < maximum - 0.5 : inner.contentOffset.x > 0.5);
|
|
1045
|
+
if (canScroll) return NO;
|
|
1046
|
+
}
|
|
998
1047
|
}
|
|
999
1048
|
if (gestureRecognizer == self.panGestureRecognizer && self.excludedHeaderView != nil) {
|
|
1000
1049
|
CGPoint point = [gestureRecognizer locationInView:self.excludedHeaderView];
|
|
@@ -1187,16 +1236,134 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
1187
1236
|
|
|
1188
1237
|
@end
|
|
1189
1238
|
|
|
1190
|
-
@interface RNCCollapsiblePagerVerticalPagerGuardGestureRecognizer :
|
|
1239
|
+
@interface RNCCollapsiblePagerVerticalPagerGuardGestureRecognizer : UIGestureRecognizer
|
|
1240
|
+
@property (nonatomic, weak) UIPanGestureRecognizer *pagerPan;
|
|
1241
|
+
- (CGPoint)translationInView:(UIView *)view;
|
|
1242
|
+
- (CGPoint)velocityInView:(UIView *)view;
|
|
1191
1243
|
@end
|
|
1192
1244
|
|
|
1193
|
-
@implementation RNCCollapsiblePagerVerticalPagerGuardGestureRecognizer
|
|
1245
|
+
@implementation RNCCollapsiblePagerVerticalPagerGuardGestureRecognizer {
|
|
1246
|
+
__weak UITouch *_trackedTouch;
|
|
1247
|
+
NSMapTable<UIPanGestureRecognizer *, NSNumber *> *_heldPans;
|
|
1248
|
+
CGPoint _startPoint;
|
|
1249
|
+
CGPoint _lastPoint;
|
|
1250
|
+
CGPoint _velocity;
|
|
1251
|
+
NSTimeInterval _lastTimestamp;
|
|
1252
|
+
}
|
|
1194
1253
|
|
|
1195
|
-
- (
|
|
1254
|
+
- (void)holdPan:(UIPanGestureRecognizer *)pan
|
|
1196
1255
|
{
|
|
1197
|
-
return
|
|
1256
|
+
if (pan == nil || [_heldPans objectForKey:pan] != nil) return;
|
|
1257
|
+
if (_heldPans == nil) _heldPans = [NSMapTable weakToStrongObjectsMapTable];
|
|
1258
|
+
[_heldPans setObject:@(pan.minimumNumberOfTouches) forKey:pan];
|
|
1259
|
+
// A failure dependency alone lets UIKit's pager pan cancel a descendant pan
|
|
1260
|
+
// before intent resolves. Keep DOWN tracked while deferring one-finger begin.
|
|
1261
|
+
pan.minimumNumberOfTouches = MAX(2, pan.minimumNumberOfTouches);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
- (void)restoreHeldPans
|
|
1265
|
+
{
|
|
1266
|
+
for (UIPanGestureRecognizer *pan in _heldPans.keyEnumerator) {
|
|
1267
|
+
pan.minimumNumberOfTouches = [_heldPans objectForKey:pan].unsignedIntegerValue;
|
|
1268
|
+
UIScrollView *scroll = [pan.view isKindOfClass:UIScrollView.class] ? (UIScrollView *)pan.view : nil;
|
|
1269
|
+
RNCCollapsiblePagerLog([NSString stringWithFormat:
|
|
1270
|
+
@"gesture-restore pan=%@ min-touches=%lu offset=%@",
|
|
1271
|
+
NSStringFromClass(pan.view.class), (unsigned long)pan.minimumNumberOfTouches,
|
|
1272
|
+
scroll != nil ? NSStringFromCGPoint(scroll.contentOffset) : @"none"]);
|
|
1273
|
+
}
|
|
1274
|
+
[_heldPans removeAllObjects];
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
- (CGPoint)translationInView:(UIView *)view
|
|
1278
|
+
{
|
|
1279
|
+
CGPoint start = [view convertPoint:_startPoint fromView:self.view];
|
|
1280
|
+
CGPoint last = [view convertPoint:_lastPoint fromView:self.view];
|
|
1281
|
+
return CGPointMake(last.x - start.x, last.y - start.y);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
- (CGPoint)velocityInView:(UIView *)view { return _velocity; }
|
|
1285
|
+
|
|
1286
|
+
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
|
1287
|
+
{
|
|
1288
|
+
if (_trackedTouch != nil || touches.count != 1) {
|
|
1289
|
+
RNCCollapsiblePagerLog([NSString stringWithFormat:
|
|
1290
|
+
@"gesture-second-touch previous-state=%ld incoming=%lu held-pans=%lu",
|
|
1291
|
+
(long)self.state, (unsigned long)touches.count, (unsigned long)_heldPans.count]);
|
|
1292
|
+
[self restoreHeldPans];
|
|
1293
|
+
if (self.state == UIGestureRecognizerStatePossible) {
|
|
1294
|
+
self.state = UIGestureRecognizerStateFailed;
|
|
1295
|
+
} else if (self.state == UIGestureRecognizerStateBegan || self.state == UIGestureRecognizerStateChanged) {
|
|
1296
|
+
self.state = UIGestureRecognizerStateCancelled;
|
|
1297
|
+
}
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
_trackedTouch = touches.anyObject;
|
|
1301
|
+
_startPoint = _lastPoint = [touches.anyObject locationInView:self.view];
|
|
1302
|
+
_lastTimestamp = event.timestamp;
|
|
1303
|
+
_velocity = CGPointZero;
|
|
1304
|
+
[self holdPan:self.pagerPan];
|
|
1305
|
+
for (UIView *view = _trackedTouch.view; view != nil && view != self.view; view = view.superview) {
|
|
1306
|
+
if (![view isKindOfClass:UIScrollView.class]) continue;
|
|
1307
|
+
UIScrollView *scroll = (UIScrollView *)view;
|
|
1308
|
+
if (RNCIsEnabledHorizontalScrollOwner(scroll)) {
|
|
1309
|
+
RNCCollapsiblePagerLog([NSString stringWithFormat:
|
|
1310
|
+
@"gesture-hold inner=%@ paging=%d offset=%@ content=%@ bounds=%@",
|
|
1311
|
+
NSStringFromClass(scroll.class), scroll.pagingEnabled, NSStringFromCGPoint(scroll.contentOffset),
|
|
1312
|
+
NSStringFromCGSize(scroll.contentSize), NSStringFromCGSize(scroll.bounds.size)]);
|
|
1313
|
+
[self holdPan:scroll.panGestureRecognizer];
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
|
1319
|
+
{
|
|
1320
|
+
CGPoint point = [touches.anyObject locationInView:self.view];
|
|
1321
|
+
NSTimeInterval elapsed = event.timestamp - _lastTimestamp;
|
|
1322
|
+
if (elapsed > 0) {
|
|
1323
|
+
_velocity = CGPointMake((point.x - _lastPoint.x) / elapsed,
|
|
1324
|
+
(point.y - _lastPoint.y) / elapsed);
|
|
1325
|
+
}
|
|
1326
|
+
_lastPoint = point;
|
|
1327
|
+
_lastTimestamp = event.timestamp;
|
|
1328
|
+
if (self.state == UIGestureRecognizerStatePossible) {
|
|
1329
|
+
CGFloat dx = fabs(point.x - _startPoint.x), dy = fabs(point.y - _startPoint.y);
|
|
1330
|
+
// Keep intent pending until the explicit distance decision, instead of
|
|
1331
|
+
// relying on UIPanGestureRecognizer's earlier recognition.
|
|
1332
|
+
if ((dy >= 10 && dy > dx) || (dx >= 32 && dx > dy)) {
|
|
1333
|
+
RNCCollapsiblePagerLog([NSString stringWithFormat:
|
|
1334
|
+
@"gesture-intent axis=%@ dx=%.2f dy=%.2f held-pans=%lu",
|
|
1335
|
+
dy > dx ? @"vertical" : @"horizontal", dx, dy, (unsigned long)_heldPans.count]);
|
|
1336
|
+
self.state = UIGestureRecognizerStateBegan;
|
|
1337
|
+
if (self.state == UIGestureRecognizerStateFailed) [self restoreHeldPans];
|
|
1338
|
+
}
|
|
1339
|
+
} else if (self.state == UIGestureRecognizerStateBegan || self.state == UIGestureRecognizerStateChanged) {
|
|
1340
|
+
self.state = UIGestureRecognizerStateChanged;
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
|
1345
|
+
{
|
|
1346
|
+
[self restoreHeldPans];
|
|
1347
|
+
self.state = self.state == UIGestureRecognizerStatePossible
|
|
1348
|
+
? UIGestureRecognizerStateFailed : UIGestureRecognizerStateEnded;
|
|
1198
1349
|
}
|
|
1199
1350
|
|
|
1351
|
+
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
|
1352
|
+
{
|
|
1353
|
+
[self restoreHeldPans];
|
|
1354
|
+
self.state = UIGestureRecognizerStateCancelled;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
- (void)reset
|
|
1358
|
+
{
|
|
1359
|
+
[self restoreHeldPans];
|
|
1360
|
+
[super reset];
|
|
1361
|
+
_trackedTouch = nil;
|
|
1362
|
+
_velocity = CGPointZero;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
- (BOOL)canBePreventedByGestureRecognizer:(UIGestureRecognizer *)other { return NO; }
|
|
1366
|
+
- (BOOL)canPreventGestureRecognizer:(UIGestureRecognizer *)other { return YES; }
|
|
1200
1367
|
@end
|
|
1201
1368
|
|
|
1202
1369
|
@interface RNCCollapsiblePagerViewComponentView () <
|
|
@@ -1282,10 +1449,12 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
1282
1449
|
NSMapTable<UIScrollView *, NSNumber *> *_appliedTopInsets;
|
|
1283
1450
|
NSMapTable<UIScrollView *, NSValue *> *_originalIndicatorInsets;
|
|
1284
1451
|
NSMapTable<UIScrollView *, NSNumber *> *_originalAlwaysBounceVertical;
|
|
1452
|
+
NSMapTable<UIScrollView *, NSNumber *> *_originalDirectionalLockEnabled;
|
|
1285
1453
|
NSMapTable<UIScrollView *, NSNumber *> *_originalInsetAdjustmentBehavior;
|
|
1286
1454
|
// OneKey patch: pair pre-mount release with recovery, including deferred mounts.
|
|
1287
1455
|
NSMutableDictionary<NSString *, RNCCollapsiblePagerReleasedScrollState *> *_releasedPageScrollStates;
|
|
1288
1456
|
BOOL _needsScrollObserverReattachAfterMount;
|
|
1457
|
+
NSHashTable<UIScrollView *> *_mountingRemovedScrollViews;
|
|
1289
1458
|
__weak UIScrollView *_observedScrollView;
|
|
1290
1459
|
__weak UIScrollView *_sharedHeaderScrollView;
|
|
1291
1460
|
__weak UIScrollView *_pendingFallbackScrollView;
|
|
@@ -1294,7 +1463,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
1294
1463
|
UIPanGestureRecognizer *_sharedHeaderPressCancellationGesture;
|
|
1295
1464
|
RNCCollapsiblePagerContentPressCancellationGestureRecognizer *_contentPressCancellationGesture;
|
|
1296
1465
|
RNCCollapsiblePagerOuterPagerPanGestureRecognizer *_sharedHeaderOuterPagerGesture;
|
|
1297
|
-
|
|
1466
|
+
RNCCollapsiblePagerVerticalPagerGuardGestureRecognizer *_verticalPagerGesture;
|
|
1298
1467
|
BOOL _observingContentOffset;
|
|
1299
1468
|
BOOL _scrollResolveRetryScheduled;
|
|
1300
1469
|
CFTimeInterval _pendingFallbackScrollViewDetectedAt;
|
|
@@ -1322,12 +1491,14 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
1322
1491
|
_pageControllers = [NSMutableArray new];
|
|
1323
1492
|
_pageOffsets = [NSMutableDictionary new];
|
|
1324
1493
|
_releasedPageScrollStates = [NSMutableDictionary new];
|
|
1494
|
+
_mountingRemovedScrollViews = [NSHashTable weakObjectsHashTable];
|
|
1325
1495
|
_originalInsets = [NSMapTable weakToStrongObjectsMapTable];
|
|
1326
1496
|
_managedRefreshControls = [NSMapTable weakToWeakObjectsMapTable];
|
|
1327
1497
|
_refreshControlObservedScrollViews = [NSHashTable weakObjectsHashTable];
|
|
1328
1498
|
_appliedTopInsets = [NSMapTable weakToStrongObjectsMapTable];
|
|
1329
1499
|
_originalIndicatorInsets = [NSMapTable weakToStrongObjectsMapTable];
|
|
1330
1500
|
_originalAlwaysBounceVertical = [NSMapTable weakToStrongObjectsMapTable];
|
|
1501
|
+
_originalDirectionalLockEnabled = [NSMapTable weakToStrongObjectsMapTable];
|
|
1331
1502
|
_originalInsetAdjustmentBehavior = [NSMapTable weakToStrongObjectsMapTable];
|
|
1332
1503
|
_pageKeys = @[];
|
|
1333
1504
|
_retainedPages = @"[]";
|
|
@@ -1365,7 +1536,6 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
1365
1536
|
_verticalPagerGesture.cancelsTouchesInView = NO;
|
|
1366
1537
|
_verticalPagerGesture.enabled = NO;
|
|
1367
1538
|
[self addGestureRecognizer:_verticalPagerGesture];
|
|
1368
|
-
|
|
1369
1539
|
_nativeTabBarView = [RNCCollapsiblePagerNativeTabBarView new];
|
|
1370
1540
|
_nativeTabBarView.hidden = YES;
|
|
1371
1541
|
__weak __typeof__(self) weakSelf = self;
|
|
@@ -1758,6 +1928,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
1758
1928
|
}
|
|
1759
1929
|
break;
|
|
1760
1930
|
}
|
|
1931
|
+
// Quarantine only this mount transaction; moved views may be valid again.
|
|
1932
|
+
[_mountingRemovedScrollViews addObject:scrollView];
|
|
1761
1933
|
_needsScrollObserverReattachAfterMount = YES;
|
|
1762
1934
|
[self restoreScrollViewState:scrollView resetSavedOffset:!belongsToPage];
|
|
1763
1935
|
break;
|
|
@@ -1768,9 +1940,25 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
1768
1940
|
- (void)mountingTransactionDidMount:(const MountingTransaction &)transaction
|
|
1769
1941
|
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
|
|
1770
1942
|
{
|
|
1771
|
-
|
|
1772
|
-
|
|
1943
|
+
[_mountingRemovedScrollViews removeAllObjects];
|
|
1944
|
+
if (!_nativeSmoothHeaderScrollEnabled || _isBeingRecycled || self.window == nil ||
|
|
1773
1945
|
_currentIndex < 0 || _currentIndex >= _pageControllers.count) return;
|
|
1946
|
+
// Prepare newly mounted owned scrollers before their first frame, even while
|
|
1947
|
+
// horizontal selection still owns the shared header and observer.
|
|
1948
|
+
NSInteger first = MAX(0, _currentIndex - 1);
|
|
1949
|
+
NSInteger last = MIN((NSInteger)_pageControllers.count - 1, _currentIndex + 1);
|
|
1950
|
+
for (NSInteger index = 0; index < _pageControllers.count; index++) {
|
|
1951
|
+
if ((index < first || index > last) && index != _destinationIndex) continue;
|
|
1952
|
+
UIScrollView *scrollView = [self verticalScrollViewForPageAtIndex:index];
|
|
1953
|
+
BOOL isOwnedScroller = [scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class] &&
|
|
1954
|
+
((RNCNativeScrollerComponentView *)scrollView.superview).scrollView == scrollView &&
|
|
1955
|
+
[scrollView isDescendantOfView:_pageControllers[index].view];
|
|
1956
|
+
if ((RNCIsMountedNativeListScrollView(scrollView) || isOwnedScroller) &&
|
|
1957
|
+
[_originalInsets objectForKey:scrollView] == nil) {
|
|
1958
|
+
[self applyInsetsToScrollView:scrollView pageIndex:index restore:YES];
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
if (_transitioning || _isPagerDragging) return;
|
|
1774
1962
|
|
|
1775
1963
|
// OneKey patch: a release must recover even when the changed parent is above
|
|
1776
1964
|
// the page subtree. Missing lists keep their identity for a later insertion.
|
|
@@ -2002,6 +2190,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2002
2190
|
{
|
|
2003
2191
|
_isBeingRecycled = YES;
|
|
2004
2192
|
_generation++;
|
|
2193
|
+
[_mountingRemovedScrollViews removeAllObjects];
|
|
2005
2194
|
_needsScrollObserverReattachAfterMount = NO;
|
|
2006
2195
|
[_releasedPageScrollStates removeAllObjects];
|
|
2007
2196
|
[self detachSharedHeaderPressCancellationGesture];
|
|
@@ -2027,6 +2216,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2027
2216
|
}
|
|
2028
2217
|
NSNumber *alwaysBounce = [_originalAlwaysBounceVertical objectForKey:scrollView];
|
|
2029
2218
|
if (alwaysBounce != nil) scrollView.alwaysBounceVertical = alwaysBounce.boolValue;
|
|
2219
|
+
NSNumber *directionalLock = [_originalDirectionalLockEnabled objectForKey:scrollView];
|
|
2220
|
+
if (directionalLock != nil) scrollView.directionalLockEnabled = directionalLock.boolValue;
|
|
2030
2221
|
NSNumber *adjustment = [_originalInsetAdjustmentBehavior objectForKey:scrollView];
|
|
2031
2222
|
if (adjustment != nil) {
|
|
2032
2223
|
scrollView.contentInsetAdjustmentBehavior = (UIScrollViewContentInsetAdjustmentBehavior)adjustment.integerValue;
|
|
@@ -2036,6 +2227,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2036
2227
|
[_appliedTopInsets removeAllObjects];
|
|
2037
2228
|
[_originalIndicatorInsets removeAllObjects];
|
|
2038
2229
|
[_originalAlwaysBounceVertical removeAllObjects];
|
|
2230
|
+
[_originalDirectionalLockEnabled removeAllObjects];
|
|
2039
2231
|
[_originalInsetAdjustmentBehavior removeAllObjects];
|
|
2040
2232
|
// OneKey patch: Fabric reuses these views without resetting transforms
|
|
2041
2233
|
// written by a native parent. Release our collapse translation with the slot.
|
|
@@ -2280,13 +2472,15 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2280
2472
|
currentVerticalScrollViewForParentPager];
|
|
2281
2473
|
}
|
|
2282
2474
|
if ([view isKindOfClass:RNCNativeScrollerComponentView.class]) {
|
|
2283
|
-
|
|
2475
|
+
UIScrollView *scrollView = ((RNCNativeScrollerComponentView *)view).scrollView;
|
|
2476
|
+
return [_mountingRemovedScrollViews containsObject:scrollView] ? nil : scrollView;
|
|
2284
2477
|
}
|
|
2285
2478
|
if ([view isKindOfClass:RCTScrollViewComponentView.class]) {
|
|
2286
2479
|
RCTScrollViewComponentView *componentView =
|
|
2287
2480
|
(RCTScrollViewComponentView *)view;
|
|
2288
2481
|
if ([componentView.nativeId hasPrefix:RNCCollapsiblePagerNativeScrollerIDPrefix]) {
|
|
2289
|
-
|
|
2482
|
+
UIScrollView *scrollView = componentView.scrollView;
|
|
2483
|
+
return [_mountingRemovedScrollViews containsObject:scrollView] ? nil : scrollView;
|
|
2290
2484
|
}
|
|
2291
2485
|
}
|
|
2292
2486
|
for (UIView *subview in view.subviews) {
|
|
@@ -2307,7 +2501,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2307
2501
|
if ([view isKindOfClass:RCTScrollViewComponentView.class]) {
|
|
2308
2502
|
UIScrollView *scrollView =
|
|
2309
2503
|
((RCTScrollViewComponentView *)view).scrollView;
|
|
2310
|
-
if (!scrollView.alwaysBounceHorizontal
|
|
2504
|
+
if (!scrollView.alwaysBounceHorizontal &&
|
|
2505
|
+
![_mountingRemovedScrollViews containsObject:scrollView]) {
|
|
2311
2506
|
return scrollView;
|
|
2312
2507
|
}
|
|
2313
2508
|
}
|
|
@@ -2320,7 +2515,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2320
2515
|
|
|
2321
2516
|
- (UIScrollView *)findVerticalScrollViewInView:(UIView *)view
|
|
2322
2517
|
{
|
|
2323
|
-
if (view == _sharedHeaderHostView
|
|
2518
|
+
if (view == _sharedHeaderHostView ||
|
|
2519
|
+
([view isKindOfClass:UIScrollView.class] &&
|
|
2520
|
+
[_mountingRemovedScrollViews containsObject:(UIScrollView *)view])) return nil;
|
|
2324
2521
|
// A parent pager must follow the nested pager's active page instead of the
|
|
2325
2522
|
// first retained page found by recursive subview order.
|
|
2326
2523
|
if (view != self &&
|
|
@@ -2332,6 +2529,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2332
2529
|
}
|
|
2333
2530
|
if ([view isKindOfClass:UICollectionView.class]) {
|
|
2334
2531
|
UICollectionView *collectionView = (UICollectionView *)view;
|
|
2532
|
+
if (RNCNativeListComponentForScrollView(collectionView) != nil &&
|
|
2533
|
+
!RNCIsMountedNativeListScrollView(collectionView)) return nil;
|
|
2335
2534
|
UICollectionViewLayout *layout = collectionView.collectionViewLayout;
|
|
2336
2535
|
BOOL hasUsableBounds = CGRectGetWidth(collectionView.bounds) > 0 &&
|
|
2337
2536
|
CGRectGetHeight(collectionView.bounds) > 0;
|
|
@@ -2407,6 +2606,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2407
2606
|
_pendingFallbackScrollViewDetectedAt = 0;
|
|
2408
2607
|
}
|
|
2409
2608
|
UIScrollView *candidate = [self verticalScrollViewForPageAtIndex:_currentIndex];
|
|
2609
|
+
if (_nativeSmoothHeaderScrollEnabled && _currentIndex >= 0 && _currentIndex < _pageControllers.count) {
|
|
2610
|
+
RNCGuardNestedHorizontalPans(_pageControllers[_currentIndex].view, _verticalPagerGesture);
|
|
2611
|
+
}
|
|
2410
2612
|
if (candidate == nil) {
|
|
2411
2613
|
[self detachScrollObserver];
|
|
2412
2614
|
[self restoreSharedHeadersToContainer];
|
|
@@ -2425,7 +2627,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2425
2627
|
return;
|
|
2426
2628
|
}
|
|
2427
2629
|
if ([candidate isKindOfClass:UICollectionView.class] &&
|
|
2428
|
-
candidate != _observedScrollView) {
|
|
2630
|
+
candidate != _observedScrollView && !RNCIsMountedNativeListScrollView(candidate)) {
|
|
2429
2631
|
if (_pendingFallbackScrollView != candidate) {
|
|
2430
2632
|
_pendingFallbackScrollView = candidate;
|
|
2431
2633
|
_pendingFallbackScrollViewDetectedAt = CACurrentMediaTime();
|
|
@@ -2531,8 +2733,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2531
2733
|
- (void)connectVerticalPagerGuard
|
|
2532
2734
|
{
|
|
2533
2735
|
if (_pagerScrollView != nil && _verticalPagerGesture != nil) {
|
|
2534
|
-
|
|
2535
|
-
|
|
2736
|
+
_verticalPagerGesture.pagerPan = _pagerScrollView.panGestureRecognizer;
|
|
2737
|
+
for (UIGestureRecognizer *gesture in _pagerScrollView.gestureRecognizers) {
|
|
2738
|
+
[gesture requireGestureRecognizerToFail:_verticalPagerGesture];
|
|
2739
|
+
}
|
|
2536
2740
|
}
|
|
2537
2741
|
}
|
|
2538
2742
|
|
|
@@ -2787,6 +2991,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2787
2991
|
[_originalIndicatorInsets objectForKey:scrollView].UIEdgeInsetsValue;
|
|
2788
2992
|
scrollView.alwaysBounceVertical =
|
|
2789
2993
|
[_originalAlwaysBounceVertical objectForKey:scrollView].boolValue;
|
|
2994
|
+
scrollView.directionalLockEnabled =
|
|
2995
|
+
[_originalDirectionalLockEnabled objectForKey:scrollView].boolValue;
|
|
2790
2996
|
scrollView.contentInsetAdjustmentBehavior = (UIScrollViewContentInsetAdjustmentBehavior)
|
|
2791
2997
|
[_originalInsetAdjustmentBehavior objectForKey:scrollView].integerValue;
|
|
2792
2998
|
[scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, logicalOffset - original.top)
|
|
@@ -2795,6 +3001,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2795
3001
|
[_appliedTopInsets removeObjectForKey:scrollView];
|
|
2796
3002
|
[_originalIndicatorInsets removeObjectForKey:scrollView];
|
|
2797
3003
|
[_originalAlwaysBounceVertical removeObjectForKey:scrollView];
|
|
3004
|
+
[_originalDirectionalLockEnabled removeObjectForKey:scrollView];
|
|
2798
3005
|
[_originalInsetAdjustmentBehavior removeObjectForKey:scrollView];
|
|
2799
3006
|
}
|
|
2800
3007
|
|
|
@@ -2831,7 +3038,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2831
3038
|
// debounce each replacement candidate independently before rejecting the
|
|
2832
3039
|
// released identity. A surviving native list is safe to accept immediately.
|
|
2833
3040
|
BOOL needsFallbackDebounce = !sameList &&
|
|
2834
|
-
[scrollView isKindOfClass:UICollectionView.class] && scrollView != _observedScrollView
|
|
3041
|
+
[scrollView isKindOfClass:UICollectionView.class] && scrollView != _observedScrollView &&
|
|
3042
|
+
!RNCIsMountedNativeListScrollView(scrollView);
|
|
2835
3043
|
if (needsFallbackDebounce) {
|
|
2836
3044
|
CFTimeInterval now = CACurrentMediaTime();
|
|
2837
3045
|
if (released.pendingFallbackScrollView != scrollView) {
|
|
@@ -2879,12 +3087,17 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
2879
3087
|
setObject:[NSValue valueWithUIEdgeInsets:scrollView.verticalScrollIndicatorInsets]
|
|
2880
3088
|
forKey:scrollView];
|
|
2881
3089
|
[_originalAlwaysBounceVertical setObject:@(scrollView.alwaysBounceVertical) forKey:scrollView];
|
|
3090
|
+
[_originalDirectionalLockEnabled setObject:@(scrollView.directionalLockEnabled) forKey:scrollView];
|
|
2882
3091
|
[_originalInsetAdjustmentBehavior setObject:@(scrollView.contentInsetAdjustmentBehavior) forKey:scrollView];
|
|
2883
3092
|
}
|
|
2884
3093
|
|
|
2885
3094
|
// This host already includes the fixed header and safe area in its frame.
|
|
2886
3095
|
// UIKit automatic adjustment would add them a second time to short pages.
|
|
2887
3096
|
scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
|
|
3097
|
+
// This viewport is vertical-only; UIKit's first horizontal lock must not
|
|
3098
|
+
// suppress its later vertical movement while the pager is still pending.
|
|
3099
|
+
scrollView.directionalLockEnabled = _nativeSmoothHeaderScrollEnabled
|
|
3100
|
+
? NO : [_originalDirectionalLockEnabled objectForKey:scrollView].boolValue;
|
|
2888
3101
|
[self updateNativeScrollerViewport:scrollView stickyHeight:_stickyHeaderHeight];
|
|
2889
3102
|
[self updateRefreshControlForScrollView:scrollView];
|
|
2890
3103
|
CGFloat previousTopInset = scrollView.contentInset.top;
|
|
@@ -3543,6 +3756,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
|
|
|
3543
3756
|
: velocity;
|
|
3544
3757
|
BOOL hasMotion = MAX(fabs(intent.x), fabs(intent.y)) >= 1;
|
|
3545
3758
|
BOOL vertical = hasMotion && fabs(intent.y) > fabs(intent.x);
|
|
3759
|
+
if (gestureRecognizer == _sharedHeaderPressCancellationGesture) vertical = hasMotion;
|
|
3546
3760
|
NSString *role = gestureRecognizer == _sharedHeaderPressCancellationGesture
|
|
3547
3761
|
? @"vertical-cancel"
|
|
3548
3762
|
: @"vertical-pager-guard";
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#import <XCTest/XCTest.h>
|
|
2
|
+
#import "RNCCollapsiblePagerViewComponentView.h"
|
|
3
|
+
#import "RNCNativeScrollerComponentView.h"
|
|
4
|
+
#import <react/renderer/mounting/MountingTransaction.h>
|
|
5
|
+
|
|
6
|
+
using namespace facebook::react;
|
|
7
|
+
|
|
8
|
+
@interface RNCCollapsiblePagerViewComponentView (FirstFrameTesting)
|
|
9
|
+
- (void)mountingTransactionDidMount:(const MountingTransaction &)transaction
|
|
10
|
+
withSurfaceTelemetry:(const SurfaceTelemetry &)telemetry;
|
|
11
|
+
- (UIScrollView *)findExplicitNativeScrollerInView:(UIView *)view;
|
|
12
|
+
- (UIScrollView *)findVerticalScrollViewInView:(UIView *)view;
|
|
13
|
+
- (void)attachScrollObserverForCurrentPage;
|
|
14
|
+
@end
|
|
15
|
+
|
|
16
|
+
@interface RNCCollapsiblePagerFirstFrameTests : XCTestCase
|
|
17
|
+
@end
|
|
18
|
+
|
|
19
|
+
@implementation RNCCollapsiblePagerFirstFrameTests
|
|
20
|
+
|
|
21
|
+
- (RNCCollapsiblePagerViewComponentView *)pagerWithPageCount:(NSInteger)count
|
|
22
|
+
{
|
|
23
|
+
RNCCollapsiblePagerViewComponentView *pager =
|
|
24
|
+
[[RNCCollapsiblePagerViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 320, 844)];
|
|
25
|
+
[pager setValue:@YES forKey:@"nativeSmoothHeaderScrollEnabled"];
|
|
26
|
+
[pager setValue:@120 forKey:@"headerHeight"];
|
|
27
|
+
[pager setValue:@44 forKey:@"stickyHeaderHeight"];
|
|
28
|
+
UIWindow *window = [[UIWindow alloc] initWithFrame:pager.frame];
|
|
29
|
+
UIViewController *root = [UIViewController new];
|
|
30
|
+
window.rootViewController = root;
|
|
31
|
+
[root.view addSubview:pager];
|
|
32
|
+
window.hidden = NO;
|
|
33
|
+
[self addTeardownBlock:^{
|
|
34
|
+
[pager prepareForRecycle];
|
|
35
|
+
window.hidden = YES;
|
|
36
|
+
}];
|
|
37
|
+
NSMutableArray *pages = [pager valueForKey:@"pageControllers"];
|
|
38
|
+
NSMutableArray<NSString *> *keys = [NSMutableArray new];
|
|
39
|
+
for (NSInteger index = 0; index < count; index++) {
|
|
40
|
+
UIViewController *page = [UIViewController new];
|
|
41
|
+
page.view = [[UIView alloc] initWithFrame:CGRectMake(index * 320, 0, 320, 600)];
|
|
42
|
+
[pager addSubview:page.view];
|
|
43
|
+
[pages addObject:page];
|
|
44
|
+
[keys addObject:[NSString stringWithFormat:@"page-%ld", (long)index]];
|
|
45
|
+
}
|
|
46
|
+
[pager setValue:keys forKey:@"pageKeys"];
|
|
47
|
+
return pager;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
- (RNCNativeScrollerComponentView *)addScrollerToPager:(RNCCollapsiblePagerViewComponentView *)pager
|
|
51
|
+
atIndex:(NSInteger)index
|
|
52
|
+
{
|
|
53
|
+
RNCNativeScrollerComponentView *scroller = [RNCNativeScrollerComponentView new];
|
|
54
|
+
scroller.frame = CGRectMake(0, 0, 320, 600);
|
|
55
|
+
scroller.scrollView.frame = scroller.bounds;
|
|
56
|
+
scroller.scrollView.contentSize = CGSizeMake(320, 1500);
|
|
57
|
+
UIViewController *page = [pager valueForKey:@"pageControllers"][index];
|
|
58
|
+
[page.view addSubview:scroller];
|
|
59
|
+
return scroller;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
- (void)finishMount:(RNCCollapsiblePagerViewComponentView *)pager
|
|
63
|
+
{
|
|
64
|
+
MountingTransaction transaction(1, 1, {}, {});
|
|
65
|
+
SurfaceTelemetry telemetry;
|
|
66
|
+
[pager mountingTransactionDidMount:transaction withSurfaceTelemetry:telemetry];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
- (void)testInteractiveTargetGetsInsetsBeforeSelectionWithoutTakingObserver
|
|
70
|
+
{
|
|
71
|
+
RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:2];
|
|
72
|
+
RNCNativeScrollerComponentView *target = [self addScrollerToPager:pager atIndex:1];
|
|
73
|
+
[pager setValue:@YES forKey:@"transitioning"];
|
|
74
|
+
[pager setValue:@YES forKey:@"isPagerDragging"];
|
|
75
|
+
UIView *header = [pager valueForKey:@"sharedHeaderHostView"];
|
|
76
|
+
UIView *headerOwner = header.superview;
|
|
77
|
+
[self finishMount:pager];
|
|
78
|
+
XCTAssertEqualWithAccuracy(target.scrollView.contentInset.top, 164, 0.01);
|
|
79
|
+
XCTAssertEqualWithAccuracy(target.scrollView.contentOffset.y, -164, 0.01);
|
|
80
|
+
XCTAssertEqualObjects([pager valueForKey:@"currentIndex"], @0);
|
|
81
|
+
XCTAssertNil([pager valueForKey:@"observedScrollView"]);
|
|
82
|
+
XCTAssertEqual(header.superview, headerOwner);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
- (void)testRepeatedMountDoesNotRestoreAnAlreadyPreparedTarget
|
|
86
|
+
{
|
|
87
|
+
RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:2];
|
|
88
|
+
RNCNativeScrollerComponentView *target = [self addScrollerToPager:pager atIndex:1];
|
|
89
|
+
[pager setValue:@YES forKey:@"transitioning"];
|
|
90
|
+
[self finishMount:pager];
|
|
91
|
+
target.scrollView.contentOffset = CGPointMake(0, 230);
|
|
92
|
+
[self finishMount:pager];
|
|
93
|
+
XCTAssertEqualWithAccuracy(target.scrollView.contentInset.top, 164, 0.01);
|
|
94
|
+
XCTAssertEqualWithAccuracy(target.scrollView.contentOffset.y, 230, 0.01);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
- (void)testProgrammaticDestinationOutsideAdjacentRangeIsPrepared
|
|
98
|
+
{
|
|
99
|
+
RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:4];
|
|
100
|
+
RNCNativeScrollerComponentView *unrelated = [self addScrollerToPager:pager atIndex:2];
|
|
101
|
+
RNCNativeScrollerComponentView *target = [self addScrollerToPager:pager atIndex:3];
|
|
102
|
+
[pager setValue:@3 forKey:@"destinationIndex"];
|
|
103
|
+
[pager setValue:@YES forKey:@"transitioning"];
|
|
104
|
+
[self finishMount:pager];
|
|
105
|
+
XCTAssertEqualWithAccuracy(target.scrollView.contentInset.top, 164, 0.01);
|
|
106
|
+
XCTAssertEqualWithAccuracy(target.scrollView.contentOffset.y, -164, 0.01);
|
|
107
|
+
XCTAssertEqualWithAccuracy(unrelated.scrollView.contentInset.top, 0, 0.01);
|
|
108
|
+
XCTAssertEqualObjects([pager valueForKey:@"currentIndex"], @0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
- (void)testRemovedScrollerIsExcludedUntilDidMount
|
|
112
|
+
{
|
|
113
|
+
RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:2];
|
|
114
|
+
RNCNativeScrollerComponentView *target = [self addScrollerToPager:pager atIndex:1];
|
|
115
|
+
NSHashTable *removed = [pager valueForKey:@"mountingRemovedScrollViews"];
|
|
116
|
+
[removed addObject:target.scrollView];
|
|
117
|
+
XCTAssertNil([pager findExplicitNativeScrollerInView:target]);
|
|
118
|
+
XCTAssertNil([pager findVerticalScrollViewInView:target.scrollView]);
|
|
119
|
+
[pager setValue:@YES forKey:@"transitioning"];
|
|
120
|
+
[self finishMount:pager];
|
|
121
|
+
XCTAssertEqual(removed.count, 0u);
|
|
122
|
+
XCTAssertEqual([pager findExplicitNativeScrollerInView:target], target.scrollView);
|
|
123
|
+
XCTAssertEqualWithAccuracy(target.scrollView.contentInset.top, 164, 0.01);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
- (void)testGenericCollectionStillRequiresFallbackDebounce
|
|
127
|
+
{
|
|
128
|
+
RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
|
|
129
|
+
UICollectionView *collection = [[UICollectionView alloc]
|
|
130
|
+
initWithFrame:CGRectMake(0, 0, 320, 600)
|
|
131
|
+
collectionViewLayout:[UICollectionViewFlowLayout new]];
|
|
132
|
+
UIViewController *page = [pager valueForKey:@"pageControllers"][0];
|
|
133
|
+
[page.view addSubview:collection];
|
|
134
|
+
[pager attachScrollObserverForCurrentPage];
|
|
135
|
+
XCTAssertNil([pager valueForKey:@"observedScrollView"]);
|
|
136
|
+
XCTAssertEqualObjects([pager valueForKey:@"pendingFallbackScrollView"], collection);
|
|
137
|
+
XCTAssertEqualWithAccuracy(collection.contentInset.top, 0, 0.01);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
- (void)testRecycleClearsTransactionExclusion
|
|
141
|
+
{
|
|
142
|
+
RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
|
|
143
|
+
RNCNativeScrollerComponentView *scroller = [self addScrollerToPager:pager atIndex:0];
|
|
144
|
+
NSHashTable *removed = [pager valueForKey:@"mountingRemovedScrollViews"];
|
|
145
|
+
[removed addObject:scroller.scrollView];
|
|
146
|
+
[pager prepareForRecycle];
|
|
147
|
+
XCTAssertEqual(removed.count, 0u);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
@end
|
|
@@ -165,7 +165,9 @@ typedef struct __IOHIDEvent *RNCTestHIDEventRef;
|
|
|
165
165
|
[_window sendEvent:_event];
|
|
166
166
|
if (phase == UITouchPhaseEnded || phase == UITouchPhaseCancelled) {
|
|
167
167
|
[_activeTouches removeObjectIdenticalTo:changed];
|
|
168
|
-
|
|
168
|
+
// A finished finger must not be delivered again when another finger ends.
|
|
169
|
+
[_event _clearTouches];
|
|
170
|
+
for (UITouch *touch in _activeTouches) [_event _addTouch:touch forDelayedDelivery:NO];
|
|
169
171
|
}
|
|
170
172
|
}
|
|
171
173
|
|
|
@@ -621,6 +623,7 @@ typedef struct __IOHIDEvent *RNCTestHIDEventRef;
|
|
|
621
623
|
@property (nonatomic, strong) RNCCollapsiblePagerViewComponentView *pager;
|
|
622
624
|
@property (nonatomic, strong) RNCSynthesizedTouchDriver *driver;
|
|
623
625
|
@property (nonatomic, strong) UIView *firstPage;
|
|
626
|
+
@property (nonatomic) NSUInteger longPressBegins;
|
|
624
627
|
@end
|
|
625
628
|
|
|
626
629
|
@implementation RNCCollapsiblePagerPressCancellationDeliveryTests
|
|
@@ -809,4 +812,111 @@ typedef struct __IOHIDEvent *RNCTestHIDEventRef;
|
|
|
809
812
|
XCTAssertEqualObjects(self.handler.events, (@[@"began", @"moved", @"ended"]));
|
|
810
813
|
}
|
|
811
814
|
|
|
815
|
+
- (UIScrollView *)mountNestedHorizontalScrollView
|
|
816
|
+
{
|
|
817
|
+
UIScrollView *vertical = [[UIScrollView alloc] initWithFrame:self.firstPage.bounds];
|
|
818
|
+
vertical.contentSize = CGSizeMake(CGRectGetWidth(vertical.bounds), 1800);
|
|
819
|
+
[self.firstPage addSubview:vertical];
|
|
820
|
+
UIScrollView *horizontal = [[UIScrollView alloc] initWithFrame:CGRectMake(40, 350, 300, 120)];
|
|
821
|
+
horizontal.contentSize = CGSizeMake(900, 120);
|
|
822
|
+
horizontal.contentOffset = CGPointMake(200, 0);
|
|
823
|
+
[vertical addSubview:horizontal];
|
|
824
|
+
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc]
|
|
825
|
+
initWithTarget:self action:@selector(nestedLongPress:)];
|
|
826
|
+
longPress.minimumPressDuration = 0.1;
|
|
827
|
+
[horizontal addGestureRecognizer:longPress];
|
|
828
|
+
// Re-run the production attachment path after the descendant mounts.
|
|
829
|
+
((void (*)(id, SEL))objc_msgSend)(self.pager, NSSelectorFromString(@"attachScrollObserverForCurrentPage"));
|
|
830
|
+
[self.window layoutIfNeeded];
|
|
831
|
+
[self drainMainQueue];
|
|
832
|
+
XCTAssertEqual([self.pager valueForKey:@"observedScrollView"], vertical);
|
|
833
|
+
return horizontal;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
- (void)nestedLongPress:(UILongPressGestureRecognizer *)recognizer
|
|
837
|
+
{
|
|
838
|
+
if (recognizer.state == UIGestureRecognizerStateBegan) self.longPressBegins += 1;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
- (CGPoint)pressPointInNestedScrollView:(UIScrollView *)scroll
|
|
842
|
+
{
|
|
843
|
+
CGPoint point = [scroll convertPoint:CGPointMake(scroll.contentOffset.x + 150, 60) toView:self.window];
|
|
844
|
+
XCTAssertEqual([self.window hitTest:point withEvent:nil], scroll);
|
|
845
|
+
return point;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
- (void)testNestedStationaryNativeLongPressRecognizesBeforeFingerUp
|
|
849
|
+
{
|
|
850
|
+
UIScrollView *scroll = [self mountNestedHorizontalScrollView];
|
|
851
|
+
UITouch *touch = [self.driver beginAt:[self pressPointInNestedScrollView:scroll]];
|
|
852
|
+
UIGestureRecognizer *guard = [self.pager valueForKey:@"verticalPagerGesture"];
|
|
853
|
+
XCTAssertEqual(guard.state, UIGestureRecognizerStatePossible);
|
|
854
|
+
XCTAssertEqual(scroll.panGestureRecognizer.minimumNumberOfTouches, 2);
|
|
855
|
+
XCTestExpectation *hold = [self expectationWithDescription:@"stationary finger held"];
|
|
856
|
+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.25 * NSEC_PER_SEC)),
|
|
857
|
+
dispatch_get_main_queue(), ^{ [hold fulfill]; });
|
|
858
|
+
[self waitForExpectations:@[hold] timeout:2];
|
|
859
|
+
// The real UIKit target action must arrive while the finger is still down.
|
|
860
|
+
XCTAssertEqual(self.longPressBegins, 1);
|
|
861
|
+
XCTAssertEqual(touch.phase, UITouchPhaseBegan);
|
|
862
|
+
XCTAssertEqual(guard.state, UIGestureRecognizerStatePossible);
|
|
863
|
+
[self.driver end:touch];
|
|
864
|
+
XCTAssertEqual(scroll.panGestureRecognizer.minimumNumberOfTouches, 1);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
- (void)testNestedHorizontalPanConsumesConfirmedHorizontalDrag
|
|
868
|
+
{
|
|
869
|
+
UIScrollView *scroll = [self mountNestedHorizontalScrollView];
|
|
870
|
+
CGPoint point = [self pressPointInNestedScrollView:scroll];
|
|
871
|
+
CGFloat originalOffset = scroll.contentOffset.x;
|
|
872
|
+
UITouch *touch = [self.driver beginAt:point];
|
|
873
|
+
for (NSInteger step = 1; step <= 10; ++step) {
|
|
874
|
+
[self.driver move:touch to:CGPointMake(point.x - 12 * step, point.y)];
|
|
875
|
+
}
|
|
876
|
+
XCTAssertTrue(scroll.isDragging);
|
|
877
|
+
XCTAssertGreaterThan(scroll.contentOffset.x, originalOffset);
|
|
878
|
+
UIScrollView *pager = [self.pager valueForKey:@"pagerScrollView"];
|
|
879
|
+
XCTAssertFalse(pager.isDragging);
|
|
880
|
+
XCTAssertEqual(self.longPressBegins, 0);
|
|
881
|
+
[self.driver end:touch];
|
|
882
|
+
XCTAssertEqual(scroll.panGestureRecognizer.minimumNumberOfTouches, 1);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
- (void)testNestedPendingHorizontalStartCanBecomeVertical
|
|
886
|
+
{
|
|
887
|
+
UIScrollView *scroll = [self mountNestedHorizontalScrollView];
|
|
888
|
+
CGPoint point = [self pressPointInNestedScrollView:scroll];
|
|
889
|
+
UITouch *touch = [self.driver beginAt:point];
|
|
890
|
+
[self.driver move:touch to:CGPointMake(point.x - 8, point.y)];
|
|
891
|
+
UIGestureRecognizer *guard = [self.pager valueForKey:@"verticalPagerGesture"];
|
|
892
|
+
XCTAssertEqual(guard.state, UIGestureRecognizerStatePossible);
|
|
893
|
+
for (NSInteger step = 1; step <= 8; ++step) {
|
|
894
|
+
[self.driver move:touch to:CGPointMake(point.x - 8, point.y - 12 * step)];
|
|
895
|
+
}
|
|
896
|
+
UIScrollView *vertical = [self.pager valueForKey:@"observedScrollView"];
|
|
897
|
+
UIScrollView *pager = [self.pager valueForKey:@"pagerScrollView"];
|
|
898
|
+
XCTAssertTrue(vertical.isDragging);
|
|
899
|
+
XCTAssertFalse(scroll.isDragging);
|
|
900
|
+
XCTAssertFalse(pager.isDragging);
|
|
901
|
+
XCTAssertEqual(self.longPressBegins, 0);
|
|
902
|
+
[self.driver end:touch];
|
|
903
|
+
XCTAssertEqual(scroll.panGestureRecognizer.minimumNumberOfTouches, 1);
|
|
904
|
+
XCTAssertEqual(pager.panGestureRecognizer.minimumNumberOfTouches, 1);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
- (void)testNestedHeldPanTouchCountRestoresOnSecondTouch
|
|
908
|
+
{
|
|
909
|
+
UIScrollView *scroll = [self mountNestedHorizontalScrollView];
|
|
910
|
+
CGPoint point = [self pressPointInNestedScrollView:scroll];
|
|
911
|
+
UITouch *first = [self.driver beginAt:point];
|
|
912
|
+
XCTAssertEqual(scroll.panGestureRecognizer.minimumNumberOfTouches, 2);
|
|
913
|
+
UITouch *second = [self.driver beginAt:CGPointMake(point.x + 20, point.y)];
|
|
914
|
+
UIScrollView *pager = [self.pager valueForKey:@"pagerScrollView"];
|
|
915
|
+
XCTAssertEqual(scroll.panGestureRecognizer.minimumNumberOfTouches, 1);
|
|
916
|
+
XCTAssertEqual(pager.panGestureRecognizer.minimumNumberOfTouches, 1);
|
|
917
|
+
[self.driver end:second];
|
|
918
|
+
[self.driver end:first];
|
|
919
|
+
XCTAssertEqual(scroll.panGestureRecognizer.minimumNumberOfTouches, 1);
|
|
920
|
+
}
|
|
921
|
+
|
|
812
922
|
@end
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/react-native-pager-view",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.167",
|
|
4
4
|
"description": "React Native wrapper for Android and iOS ViewPager",
|
|
5
5
|
"source": "./src/index.tsx",
|
|
6
6
|
"main": "./lib/module/index.js",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"typescript": "^5.9.2"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
|
-
"@onekeyfe/react-native-native-logger": "3.0.
|
|
75
|
+
"@onekeyfe/react-native-native-logger": "3.0.167",
|
|
76
76
|
"react": "*",
|
|
77
77
|
"react-native": "*"
|
|
78
78
|
},
|