@onekeyfe/react-native-pager-view 3.0.157-alpha.258 → 3.0.157-alpha.262
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/README.md +4 -0
- package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +69 -27
- package/android/src/main/java/com/reactnativepagerview/NativeScrollerPendingRestore.kt +26 -0
- package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +37 -10
- package/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +16 -1
- package/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt +31 -0
- package/docs/SPEC.md +19 -4
- package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +417 -4
- package/lib/module/CollapsiblePagerView.js +10 -1
- package/lib/typescript/src/CollapsiblePagerView.d.ts +1 -0
- package/package.json +2 -2
- package/src/CollapsiblePagerView.tsx +11 -1
package/README.md
CHANGED
|
@@ -73,6 +73,10 @@ non-adjacent presses jump directly to the target without passing intermediate
|
|
|
73
73
|
pages. Set `nativeTabPressAnimationEnabled={true}` to always animate, or `false`
|
|
74
74
|
to always jump. Swipe gestures and imperative page commands are unchanged.
|
|
75
75
|
|
|
76
|
+
> **Behavior change:** earlier versions animated every native tab press when
|
|
77
|
+
> `nativeTabPressAnimationEnabled` was omitted. Pass
|
|
78
|
+
> `nativeTabPressAnimationEnabled={true}` to keep that behavior.
|
|
79
|
+
|
|
76
80
|
Thank you again to Callstack and everyone who contributes to
|
|
77
81
|
`react-native-pager-view` 💙
|
|
78
82
|
|
|
@@ -135,8 +135,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
135
135
|
private var observedOwnedScroller: NativeScrollerViewport? = null
|
|
136
136
|
private var observedOwnedScrollerKey: String? = null
|
|
137
137
|
private var observedOwnedScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
|
|
138
|
+
private var observedOwnedScrollerTreeObserver: ViewTreeObserver? = null
|
|
138
139
|
private var observedNativeScroller: ScrollView? = null
|
|
139
140
|
private var observedNativeScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
|
|
141
|
+
private var observedNativeScrollerTreeObserver: ViewTreeObserver? = null
|
|
142
|
+
private var observedPageRemoved = false
|
|
140
143
|
private var attachmentGeneration = 0
|
|
141
144
|
private val touchSlopPx = ViewConfiguration.get(context).scaledTouchSlop
|
|
142
145
|
private val hostIdentity = System.identityHashCode(this)
|
|
@@ -340,6 +343,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
340
343
|
stickyHeaderView = null
|
|
341
344
|
}
|
|
342
345
|
else -> {
|
|
346
|
+
val observed = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
|
|
347
|
+
if (observed != null && isDescendant(observed, child)) {
|
|
348
|
+
// The page detaches only later; its scroller must not keep writing this key.
|
|
349
|
+
detachPrimaryScrollObserver(recordOffset = false)
|
|
350
|
+
observedPageRemoved = true
|
|
351
|
+
}
|
|
343
352
|
when (val scrollable =
|
|
344
353
|
findExplicitNativeScrollerInView(child) ?: findVerticalScrollableView(child)) {
|
|
345
354
|
is RecyclerView -> restoreRecyclerInsets(scrollable)
|
|
@@ -554,7 +563,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
554
563
|
nativeTabBarView.translationY = -offset
|
|
555
564
|
nativeSubHeaderView.translationY = -offset
|
|
556
565
|
observedRecyclerView?.let { publishConsumedOffset(it) }
|
|
557
|
-
|
|
566
|
+
// Adjacent owned pages are translated too; unchanged values are not republished.
|
|
567
|
+
for (scroller in ownedScrollerKeys.keys) publishConsumedOffset(scroller)
|
|
558
568
|
}
|
|
559
569
|
|
|
560
570
|
private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index"
|
|
@@ -647,7 +657,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
647
657
|
private fun attachPrimaryScrollObserver() {
|
|
648
658
|
if (selectedPage !in 0 until adapter.itemCount) return
|
|
649
659
|
val previous = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
|
|
650
|
-
if (previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
|
|
660
|
+
if (observedPageRemoved || previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
|
|
661
|
+
observedPageRemoved = false
|
|
651
662
|
detachPrimaryScrollObserver()
|
|
652
663
|
// A replacement scroller starts at the beginning while retaining header collapse.
|
|
653
664
|
pageOffsets[currentPageKey()] = 0
|
|
@@ -689,11 +700,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
689
700
|
)
|
|
690
701
|
}
|
|
691
702
|
|
|
692
|
-
private fun detachRecyclerObserver() {
|
|
703
|
+
private fun detachRecyclerObserver(recordOffset: Boolean) {
|
|
693
704
|
val recycler = observedRecyclerView
|
|
694
705
|
val listener = observedScrollListener
|
|
695
706
|
if (recycler != null) {
|
|
696
|
-
pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
|
|
707
|
+
if (recordOffset) pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
|
|
697
708
|
publishConsumedOffset(recycler, remove = true)
|
|
698
709
|
}
|
|
699
710
|
if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
|
|
@@ -716,27 +727,35 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
716
727
|
detachPrimaryScrollObserver()
|
|
717
728
|
observedNativeScroller = scroller
|
|
718
729
|
applyNativeScrollerInsets(scroller, selectedPage)
|
|
719
|
-
val listener = ViewTreeObserver.OnScrollChangedListener {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
730
|
+
val listener = object : ViewTreeObserver.OnScrollChangedListener {
|
|
731
|
+
override fun onScrollChanged() {
|
|
732
|
+
if (observedNativeScrollerListener !== this) {
|
|
733
|
+
removeScrollChangedListener(null, scroller, this)
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
val contentOffset = scroller.scrollY
|
|
737
|
+
pageOffsets[currentPageKey()] = contentOffset
|
|
738
|
+
if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
|
|
739
|
+
setHeaderOffset(headerHeightPx)
|
|
740
|
+
}
|
|
724
741
|
}
|
|
725
742
|
}
|
|
726
743
|
observedNativeScrollerListener = listener
|
|
727
|
-
scroller.viewTreeObserver.
|
|
744
|
+
observedNativeScrollerTreeObserver = scroller.viewTreeObserver.also {
|
|
745
|
+
it.addOnScrollChangedListener(listener)
|
|
746
|
+
}
|
|
728
747
|
log(
|
|
729
748
|
"scroller-observer-attach inner=$selectedPage key=${currentPageKey()} " +
|
|
730
749
|
"scroller=${System.identityHashCode(scroller)}",
|
|
731
750
|
)
|
|
732
751
|
}
|
|
733
752
|
|
|
734
|
-
private fun detachNativeScrollerObserver() {
|
|
753
|
+
private fun detachNativeScrollerObserver(recordOffset: Boolean) {
|
|
735
754
|
val scroller = observedNativeScroller
|
|
736
755
|
val listener = observedNativeScrollerListener
|
|
737
|
-
if (scroller != null) pageOffsets[currentPageKey()] = scroller.scrollY
|
|
738
|
-
if (scroller != null && listener != null
|
|
739
|
-
scroller
|
|
756
|
+
if (scroller != null && recordOffset) pageOffsets[currentPageKey()] = scroller.scrollY
|
|
757
|
+
if (scroller != null && listener != null) {
|
|
758
|
+
removeScrollChangedListener(observedNativeScrollerTreeObserver, scroller, listener)
|
|
740
759
|
}
|
|
741
760
|
if (scroller != null) {
|
|
742
761
|
log(
|
|
@@ -746,12 +765,25 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
746
765
|
}
|
|
747
766
|
observedNativeScroller = null
|
|
748
767
|
observedNativeScrollerListener = null
|
|
768
|
+
observedNativeScrollerTreeObserver = null
|
|
749
769
|
}
|
|
750
770
|
|
|
751
|
-
private fun detachPrimaryScrollObserver() {
|
|
752
|
-
detachOwnedScrollerObserver()
|
|
753
|
-
detachRecyclerObserver()
|
|
754
|
-
detachNativeScrollerObserver()
|
|
771
|
+
private fun detachPrimaryScrollObserver(recordOffset: Boolean = true) {
|
|
772
|
+
detachOwnedScrollerObserver(recordOffset)
|
|
773
|
+
detachRecyclerObserver(recordOffset)
|
|
774
|
+
detachNativeScrollerObserver(recordOffset)
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
private fun removeScrollChangedListener(
|
|
778
|
+
registered: ViewTreeObserver?,
|
|
779
|
+
scroller: View,
|
|
780
|
+
listener: ViewTreeObserver.OnScrollChangedListener,
|
|
781
|
+
) {
|
|
782
|
+
// A detached scroller returns a new floating observer, while the listener is
|
|
783
|
+
// still registered on the window observer. Try every instance that can own it.
|
|
784
|
+
for (observer in arrayOf(registered, scroller.viewTreeObserver, viewTreeObserver)) {
|
|
785
|
+
if (observer != null && observer.isAlive) observer.removeOnScrollChangedListener(listener)
|
|
786
|
+
}
|
|
755
787
|
}
|
|
756
788
|
|
|
757
789
|
private fun applyOwnedScrollerInsets(scroller: NativeScrollerViewport, pageIndex: Int) {
|
|
@@ -762,13 +794,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
762
794
|
ownedScrollerKeys[scroller] = key
|
|
763
795
|
postForCurrentAttachment {
|
|
764
796
|
if (ownedScrollerKeys[scroller] == key && scroller.isAttachedToWindow) {
|
|
765
|
-
scroller.
|
|
797
|
+
scroller.host.restoreScrollOffset(pageOffsets[key] ?: 0)
|
|
766
798
|
}
|
|
767
799
|
}
|
|
768
800
|
}
|
|
769
801
|
|
|
770
802
|
private fun restoreOwnedScrollerInsets(scroller: NativeScrollerViewport) {
|
|
771
803
|
scroller.stopMotion()
|
|
804
|
+
scroller.host.cancelPendingScrollRestore()
|
|
772
805
|
ownedScrollerKeys.remove(scroller)
|
|
773
806
|
publishConsumedOffset(scroller, remove = true)
|
|
774
807
|
scroller.host.setPagerInset(this, null)
|
|
@@ -784,26 +817,35 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
|
|
|
784
817
|
observedOwnedScrollerKey = currentPageKey()
|
|
785
818
|
val key = currentPageKey()
|
|
786
819
|
applyOwnedScrollerInsets(scroller, selectedPage)
|
|
787
|
-
val listener = ViewTreeObserver.OnScrollChangedListener {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
820
|
+
val listener = object : ViewTreeObserver.OnScrollChangedListener {
|
|
821
|
+
override fun onScrollChanged() {
|
|
822
|
+
if (observedOwnedScrollerListener !== this) {
|
|
823
|
+
removeScrollChangedListener(null, scroller, this)
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
val contentOffset = scroller.scrollY
|
|
827
|
+
pageOffsets[key] = contentOffset
|
|
828
|
+
if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx)
|
|
829
|
+
}
|
|
791
830
|
}
|
|
792
831
|
observedOwnedScrollerListener = listener
|
|
793
|
-
scroller.viewTreeObserver.
|
|
832
|
+
observedOwnedScrollerTreeObserver = scroller.viewTreeObserver.also {
|
|
833
|
+
it.addOnScrollChangedListener(listener)
|
|
834
|
+
}
|
|
794
835
|
}
|
|
795
836
|
|
|
796
|
-
private fun detachOwnedScrollerObserver() {
|
|
837
|
+
private fun detachOwnedScrollerObserver(recordOffset: Boolean) {
|
|
797
838
|
val scroller = observedOwnedScroller ?: return
|
|
798
839
|
scroller.stopMotion()
|
|
799
840
|
publishConsumedOffset(scroller, remove = true)
|
|
800
|
-
observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY }
|
|
841
|
+
if (recordOffset) observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY }
|
|
801
842
|
observedOwnedScrollerListener?.let {
|
|
802
|
-
|
|
843
|
+
removeScrollChangedListener(observedOwnedScrollerTreeObserver, scroller, it)
|
|
803
844
|
}
|
|
804
845
|
observedOwnedScroller = null
|
|
805
846
|
observedOwnedScrollerKey = null
|
|
806
847
|
observedOwnedScrollerListener = null
|
|
848
|
+
observedOwnedScrollerTreeObserver = null
|
|
807
849
|
}
|
|
808
850
|
|
|
809
851
|
private fun applyRecyclerInsets(recycler: RecyclerView, pageIndex: Int) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
package com.reactnativepagerview
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A pager-owned offset restoration that NestedScrollView clamped because the
|
|
5
|
+
* React content height had not reached native yet. It is reapplied on content
|
|
6
|
+
* layout until reached or cancelled by user/command/content replacement.
|
|
7
|
+
*/
|
|
8
|
+
internal class NativeScrollerPendingRestore {
|
|
9
|
+
var target: Int? = null
|
|
10
|
+
private set
|
|
11
|
+
|
|
12
|
+
fun request(target: Int, reached: Int) {
|
|
13
|
+
this.target = if (reached == target) null else target
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The offset to reapply after a content layout pass. */
|
|
17
|
+
fun anchor(current: Int): Int = target ?: current
|
|
18
|
+
|
|
19
|
+
fun settle(reached: Int) {
|
|
20
|
+
if (target == reached) target = null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
fun cancel() {
|
|
24
|
+
target = null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -64,6 +64,8 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
|
|
|
64
64
|
private var lastScrollEvent = 0L
|
|
65
65
|
private var laidOut = false
|
|
66
66
|
private var requestedRefresh = false
|
|
67
|
+
private var refreshStatePending = false
|
|
68
|
+
private val pendingRestore = NativeScrollerPendingRestore()
|
|
67
69
|
private var refreshOffset = 0
|
|
68
70
|
private var appliedRefreshOffset: Int? = null
|
|
69
71
|
private var appliedRefreshSize = SwipeRefreshLayout.DEFAULT
|
|
@@ -175,6 +177,7 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
|
|
|
175
177
|
|
|
176
178
|
internal fun removeReactChild() {
|
|
177
179
|
viewport.stopMotion()
|
|
180
|
+
pendingRestore.cancel()
|
|
178
181
|
reactChild?.let(contentOriginFrame::removeView)
|
|
179
182
|
reactChild = null
|
|
180
183
|
contentWidthPx = 0
|
|
@@ -221,19 +224,35 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
|
|
|
221
224
|
)
|
|
222
225
|
refresh.layout(0, 0, width, height)
|
|
223
226
|
// Reapply the current anchor after content shrink; NestedScrollView clamps it.
|
|
224
|
-
|
|
227
|
+
// A pager restoration clamped before content reached native resumes as it grows.
|
|
228
|
+
viewport.scrollTo(x, pendingRestore.anchor(y))
|
|
229
|
+
pendingRestore.settle(viewport.scrollY)
|
|
225
230
|
laidOut = true
|
|
226
231
|
publishContentViewport()
|
|
227
232
|
publishContentOrigin()
|
|
228
233
|
applyRefreshOffset()
|
|
229
|
-
|
|
234
|
+
// Apply only a prop received before the first layout. Reapplying it on later
|
|
235
|
+
// layouts would hide a gesture-started spinner before JS commits refreshing.
|
|
236
|
+
if (refreshStatePending) {
|
|
237
|
+
refreshStatePending = false
|
|
238
|
+
if (refresh.isRefreshing != requestedRefresh) refresh.isRefreshing = requestedRefresh
|
|
239
|
+
}
|
|
230
240
|
}
|
|
231
241
|
|
|
232
242
|
internal fun setRefreshing(value: Boolean) {
|
|
233
243
|
requestedRefresh = value
|
|
234
|
-
if (laidOut) refresh.isRefreshing = value
|
|
244
|
+
if (laidOut) refresh.isRefreshing = value else refreshStatePending = true
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Scrolls to a pager-owned saved offset, retrying while content is too short to reach it. */
|
|
248
|
+
internal fun restoreScrollOffset(y: Int) {
|
|
249
|
+
val target = y.coerceAtLeast(0)
|
|
250
|
+
viewport.scrollTo(0, target)
|
|
251
|
+
pendingRestore.request(target, viewport.scrollY)
|
|
235
252
|
}
|
|
236
253
|
|
|
254
|
+
internal fun cancelPendingScrollRestore() = pendingRestore.cancel()
|
|
255
|
+
|
|
237
256
|
internal fun setRefreshOffset(value: Int) {
|
|
238
257
|
refreshOffset = value
|
|
239
258
|
applyRefreshOffset()
|
|
@@ -273,6 +292,7 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
|
|
|
273
292
|
|
|
274
293
|
internal fun scrollToPosition(x: Int, y: Int, animated: Boolean) {
|
|
275
294
|
viewport.stopMotion()
|
|
295
|
+
pendingRestore.cancel()
|
|
276
296
|
if (animated) viewport.scrollToAnimated(y)
|
|
277
297
|
else viewport.scrollTo(0, y.coerceAtLeast(0))
|
|
278
298
|
emitScroll(force = true)
|
|
@@ -395,6 +415,7 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
|
|
|
395
415
|
// Record at the refresh boundary so interception does not truncate the pointer history.
|
|
396
416
|
internal fun recordTouchEvent(event: MotionEvent) {
|
|
397
417
|
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
|
418
|
+
host.cancelPendingScrollRestore()
|
|
398
419
|
stopMotion()
|
|
399
420
|
velocityTracker = VelocityTracker.obtain()
|
|
400
421
|
activePointerId = event.getPointerId(0)
|
|
@@ -422,6 +443,8 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
|
|
|
422
443
|
internal fun beginDrag(event: MotionEvent) {
|
|
423
444
|
if (dragging) return
|
|
424
445
|
dragging = true
|
|
446
|
+
// Header-forwarded touches bypass the refresh boundary's touch stream.
|
|
447
|
+
host.cancelPendingScrollRestore()
|
|
425
448
|
if (host.keyboardDismissMode != "none") host.dismissKeyboard()
|
|
426
449
|
NativeGestureUtil.notifyNativeGestureStarted(host, event)
|
|
427
450
|
host.emitLifecycle("topScrollBeginDrag")
|
|
@@ -481,13 +504,17 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
|
|
|
481
504
|
try { endDrag(cancel) } finally { cancel.recycle() }
|
|
482
505
|
}
|
|
483
506
|
releaseTouchTracking()
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
507
|
+
// An idle view has nothing to stop. A zero fling would still start and stop a
|
|
508
|
+
// non-touch nested scroll, which ancestors observe as a finished gesture.
|
|
509
|
+
if (momentum || hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
|
|
510
|
+
stopping = true
|
|
511
|
+
try {
|
|
512
|
+
// Replace the same AndroidX trajectory with zero velocity without touching its smooth-scroll clock.
|
|
513
|
+
super.fling(0)
|
|
514
|
+
super.stopNestedScroll(ViewCompat.TYPE_NON_TOUCH)
|
|
515
|
+
} finally {
|
|
516
|
+
stopping = false
|
|
517
|
+
}
|
|
491
518
|
}
|
|
492
519
|
finishMomentum()
|
|
493
520
|
}
|
|
@@ -2,7 +2,9 @@ package com.reactnativepagerview
|
|
|
2
2
|
|
|
3
3
|
import android.graphics.Color
|
|
4
4
|
import android.view.View
|
|
5
|
+
import com.facebook.react.bridge.ColorPropConverter
|
|
5
6
|
import com.facebook.react.bridge.ReadableArray
|
|
7
|
+
import com.facebook.react.bridge.ReadableType
|
|
6
8
|
import com.facebook.react.module.annotations.ReactModule
|
|
7
9
|
import com.facebook.react.uimanager.ReactStylesDiffMap
|
|
8
10
|
import com.facebook.react.uimanager.StateWrapper
|
|
@@ -100,7 +102,20 @@ class NativeScrollerViewManager : ViewGroupManager<NativeScrollerView>(),
|
|
|
100
102
|
}
|
|
101
103
|
@ReactProp(name = "refreshColors", customType = "ColorArray")
|
|
102
104
|
override fun setRefreshColors(view: NativeScrollerView, value: ReadableArray?) {
|
|
103
|
-
|
|
105
|
+
if (value == null) {
|
|
106
|
+
view.setRefreshColors(intArrayOf(Color.GRAY))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
val colors = ArrayList<Int>(value.size())
|
|
110
|
+
for (index in 0 until value.size()) {
|
|
111
|
+
// PlatformColor/DynamicColorIOS values arrive as maps, as in RN's SwipeRefreshLayoutManager.
|
|
112
|
+
when (value.getType(index)) {
|
|
113
|
+
ReadableType.Map -> ColorPropConverter.getColor(value.getMap(index), view.context)?.let(colors::add)
|
|
114
|
+
ReadableType.Number -> colors.add(value.getInt(index))
|
|
115
|
+
else -> Unit
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
view.setRefreshColors(colors.toIntArray())
|
|
104
119
|
}
|
|
105
120
|
@ReactProp(name = "refreshProgressBackgroundColor", customType = "Color")
|
|
106
121
|
override fun setRefreshProgressBackgroundColor(view: NativeScrollerView, value: Int?) =
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
package com.reactnativepagerview
|
|
2
|
+
|
|
3
|
+
/** Standalone JVM regression: compile together with the production helper. */
|
|
4
|
+
fun main() {
|
|
5
|
+
val restore = NativeScrollerPendingRestore()
|
|
6
|
+
|
|
7
|
+
// Reachable immediately: nothing remains pending and layout keeps the current anchor.
|
|
8
|
+
restore.request(target = 640, reached = 640)
|
|
9
|
+
check(restore.target == null)
|
|
10
|
+
check(restore.anchor(320) == 320)
|
|
11
|
+
|
|
12
|
+
// Content height has not reached native: NestedScrollView clamped the request to 0.
|
|
13
|
+
restore.request(target = 640, reached = 0)
|
|
14
|
+
check(restore.target == 640)
|
|
15
|
+
check(restore.anchor(0) == 640)
|
|
16
|
+
// Partial growth keeps retrying the original target, not the partially clamped offset.
|
|
17
|
+
restore.settle(reached = 300)
|
|
18
|
+
check(restore.target == 640)
|
|
19
|
+
check(restore.anchor(300) == 640)
|
|
20
|
+
// Once reached, later layouts keep the live position again.
|
|
21
|
+
restore.settle(reached = 640)
|
|
22
|
+
check(restore.target == null)
|
|
23
|
+
check(restore.anchor(900) == 900)
|
|
24
|
+
|
|
25
|
+
// User touch, commands and content replacement cancel a pending target.
|
|
26
|
+
restore.request(target = 480, reached = 120)
|
|
27
|
+
restore.cancel()
|
|
28
|
+
check(restore.target == null)
|
|
29
|
+
check(restore.anchor(120) == 120)
|
|
30
|
+
println("Native scroller pending restore: clamp retry, partial growth, settle and cancel passed")
|
|
31
|
+
}
|
package/docs/SPEC.md
CHANGED
|
@@ -96,7 +96,13 @@ position emits no new pair. A new command ends any previous active sequence
|
|
|
96
96
|
before starting its own, and non-animated movement does not start momentum;
|
|
97
97
|
recycled views MUST NOT emit into an old owner. Content shrink clamps unreachable
|
|
98
98
|
offsets without a blank tail; resize/inset changes MUST NOT accumulate offsets.
|
|
99
|
+
On Android, a pager offset restoration that is clamped because React content has
|
|
100
|
+
not reached native yet is reapplied on content layout until reached; a new touch,
|
|
101
|
+
drag, scroll command, content-root replacement or pager release cancels it.
|
|
102
|
+
Stopping an idle scroller MUST NOT start or end a non-touch nested scroll.
|
|
99
103
|
Refresh is controlled by the caller and emits once per qualifying gesture.
|
|
104
|
+
Android applies a refreshing value when it changes (or at first layout); later
|
|
105
|
+
layout passes do not reassert it over a gesture-started indicator.
|
|
100
106
|
|
|
101
107
|
Acceptance requires both native builds, focused contract checks and iOS/Android
|
|
102
108
|
recordings: expanded/collapsed Home Spot/Perps/DeFi, short/empty and dynamically
|
|
@@ -161,17 +167,22 @@ builds; the currently installed npm version does not yet contain this migration.
|
|
|
161
167
|
|
|
162
168
|
## Native tab-press animation default
|
|
163
169
|
|
|
170
|
+
Behavior change: previously an omitted `nativeTabPressAnimationEnabled` animated
|
|
171
|
+
every native tab press. Callers that relied on that default MUST pass `true`.
|
|
172
|
+
|
|
164
173
|
On iOS and Android, an omitted `nativeTabPressAnimationEnabled` MUST animate a
|
|
165
174
|
press to an adjacent page and jump directly to a non-adjacent page. Adjacency is
|
|
166
|
-
the absolute difference between the target index and the
|
|
167
|
-
|
|
175
|
+
the absolute difference between the target index and the current page. While a
|
|
176
|
+
tab command that native has already received is in flight, its target is the
|
|
177
|
+
current page; otherwise it is the selected index. A request superseded before it
|
|
178
|
+
reached native is ignored. Explicit `true` continues to animate all
|
|
168
179
|
tab presses, and explicit `false` continues to jump for all tab presses.
|
|
169
180
|
Page retention, selection callbacks, swipe gestures, imperative `setPage` /
|
|
170
181
|
`setPageWithoutAnimation` commands and Web behavior retain their contracts.
|
|
171
182
|
Callers rendering their own JS tab bar choose the matching imperative command.
|
|
172
183
|
|
|
173
|
-
Status: implemented; focused native-wrapper tests cover both directions
|
|
174
|
-
explicit overrides. Home applies this policy to its JS tab bar, with iOS and
|
|
184
|
+
Status: implemented; focused native-wrapper tests cover both directions,
|
|
185
|
+
explicit overrides and in-flight/superseded command targets. Home applies this policy to its JS tab bar, with iOS and
|
|
175
186
|
Android simulator recordings verifying distant jumps and adjacent transitions.
|
|
176
187
|
|
|
177
188
|
## Android refresh foreground coordination
|
|
@@ -355,6 +366,10 @@ A new child receives current header travel before threshold evaluation. Native
|
|
|
355
366
|
list offsets remain local to the leaf, while consumers of this protocol add all
|
|
356
367
|
ancestor contributions exactly once. Programmatic scrolling/header restoration
|
|
357
368
|
must publish the resulting header offset through the same path.
|
|
369
|
+
Every NativeScroller viewport the coordinator owns, including adjacent pages it
|
|
370
|
+
translates, receives each header offset change, not only the observed page.
|
|
371
|
+
Content-offset observers are removed from the observer instance they were
|
|
372
|
+
registered on, and a removed page's scroller stops writing offsets immediately.
|
|
358
373
|
|
|
359
374
|
## Acceptance
|
|
360
375
|
|
|
@@ -1,6 +1,202 @@
|
|
|
1
1
|
#import <XCTest/XCTest.h>
|
|
2
2
|
#import <UIKit/UIGestureRecognizerSubclass.h>
|
|
3
3
|
#import <React/RCTSurfaceTouchHandler.h>
|
|
4
|
+
#import <React/RCTViewComponentView.h>
|
|
5
|
+
#import <dlfcn.h>
|
|
6
|
+
#import <mach/mach_time.h>
|
|
7
|
+
#import <objc/message.h>
|
|
8
|
+
|
|
9
|
+
#import "RNCCollapsiblePagerViewComponentView.h"
|
|
10
|
+
#import <react/renderer/components/pagerview/Props.h>
|
|
11
|
+
|
|
12
|
+
using namespace facebook::react;
|
|
13
|
+
|
|
14
|
+
#pragma mark - UIKit gesture environment and touch delivery
|
|
15
|
+
|
|
16
|
+
// Pod unit tests run in xctest without an application. UIKit then has no
|
|
17
|
+
// gesture environment: every programmatic recognizer state change, including
|
|
18
|
+
// Began on a plain UIGestureRecognizer, is dropped. Create the application
|
|
19
|
+
// singleton (what UIApplicationMain does first) so recognizers run under the
|
|
20
|
+
// real UIGestureEnvironment. Touches below are delivered through
|
|
21
|
+
// -[UIWindow sendEvent:], the path UIApplication uses for touch events, using
|
|
22
|
+
// the same private UITouch/UIEvent/IOHIDEvent calls as KIF. These are
|
|
23
|
+
// test-only; a missing symbol fails the test instead of skipping it.
|
|
24
|
+
@interface RNCPressCancellationTestApplication : UIApplication
|
|
25
|
+
@end
|
|
26
|
+
|
|
27
|
+
@implementation RNCPressCancellationTestApplication
|
|
28
|
+
// UIApplicationMain never ran, so there is no event run loop. UIScrollView
|
|
29
|
+
// pushes a tracking run-loop mode when its real pan begins; scheduling is
|
|
30
|
+
// outside the recognizer semantics under test.
|
|
31
|
+
- (void)_pushRunLoopMode:(id)mode requester:(id)requester reason:(id)reason {}
|
|
32
|
+
- (void)_popRunLoopMode:(id)mode requester:(id)requester reason:(id)reason {}
|
|
33
|
+
@end
|
|
34
|
+
|
|
35
|
+
static BOOL RNCEnsureUIKitGestureEnvironment(void)
|
|
36
|
+
{
|
|
37
|
+
if (UIApplication.sharedApplication != nil) return YES;
|
|
38
|
+
void (*instantiate)(Class) =
|
|
39
|
+
(void (*)(Class))dlsym(RTLD_DEFAULT, "UIApplicationInstantiateSingleton");
|
|
40
|
+
if (instantiate == NULL) return NO;
|
|
41
|
+
instantiate(RNCPressCancellationTestApplication.class);
|
|
42
|
+
return UIApplication.sharedApplication != nil;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
typedef struct __IOHIDEvent *RNCTestHIDEventRef;
|
|
46
|
+
|
|
47
|
+
@interface UITouch (RNCPressCancellationSynthesis)
|
|
48
|
+
- (void)setWindow:(UIWindow *)window;
|
|
49
|
+
- (void)setView:(UIView *)view;
|
|
50
|
+
- (void)setGestureView:(UIView *)view;
|
|
51
|
+
- (void)setPhase:(UITouchPhase)phase;
|
|
52
|
+
- (void)setTimestamp:(NSTimeInterval)timestamp;
|
|
53
|
+
- (void)setTapCount:(NSUInteger)tapCount;
|
|
54
|
+
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
|
|
55
|
+
- (void)_setIsTapToClick:(BOOL)isTapToClick;
|
|
56
|
+
- (void)_setHidEvent:(RNCTestHIDEventRef)event;
|
|
57
|
+
@end
|
|
58
|
+
|
|
59
|
+
@interface UIEvent (RNCPressCancellationSynthesis)
|
|
60
|
+
- (instancetype)_init;
|
|
61
|
+
- (void)_clearTouches;
|
|
62
|
+
- (void)_addTouch:(UITouch *)touch forDelayedDelivery:(BOOL)delayed;
|
|
63
|
+
- (void)_setHIDEvent:(RNCTestHIDEventRef)event;
|
|
64
|
+
@end
|
|
65
|
+
|
|
66
|
+
// Delivers one finger or several simultaneous fingers through a window.
|
|
67
|
+
@interface RNCSynthesizedTouchDriver : NSObject
|
|
68
|
+
- (instancetype)initWithWindow:(UIWindow *)window;
|
|
69
|
+
@property (nonatomic, readonly) NSString *unavailableReason;
|
|
70
|
+
- (UITouch *)beginAt:(CGPoint)windowPoint;
|
|
71
|
+
- (void)move:(UITouch *)touch to:(CGPoint)windowPoint;
|
|
72
|
+
- (void)end:(UITouch *)touch;
|
|
73
|
+
@end
|
|
74
|
+
|
|
75
|
+
@implementation RNCSynthesizedTouchDriver {
|
|
76
|
+
UIWindow *_window;
|
|
77
|
+
UIEvent *_event;
|
|
78
|
+
NSMutableArray<UITouch *> *_activeTouches;
|
|
79
|
+
NSTimeInterval _timestamp;
|
|
80
|
+
RNCTestHIDEventRef (*_createHand)(CFAllocatorRef, uint64_t, uint32_t, uint32_t, uint32_t,
|
|
81
|
+
uint32_t, uint32_t, double, double, double, double, double,
|
|
82
|
+
Boolean, Boolean, uint32_t);
|
|
83
|
+
RNCTestHIDEventRef (*_createFinger)(CFAllocatorRef, uint64_t, uint32_t, uint32_t, uint32_t,
|
|
84
|
+
double, double, double, double, double, double, double,
|
|
85
|
+
double, double, double, Boolean, Boolean, uint32_t);
|
|
86
|
+
void (*_setIntegerValue)(RNCTestHIDEventRef, uint32_t, long, uint32_t);
|
|
87
|
+
void (*_appendEvent)(RNCTestHIDEventRef, RNCTestHIDEventRef, uint32_t);
|
|
88
|
+
void (*_setSenderID)(RNCTestHIDEventRef, uint64_t);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
- (instancetype)initWithWindow:(UIWindow *)window
|
|
92
|
+
{
|
|
93
|
+
if (self = [super init]) {
|
|
94
|
+
_window = window;
|
|
95
|
+
_activeTouches = [NSMutableArray new];
|
|
96
|
+
_timestamp = NSProcessInfo.processInfo.systemUptime;
|
|
97
|
+
void *iokit = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_NOW);
|
|
98
|
+
_createHand = (decltype(_createHand))dlsym(iokit, "IOHIDEventCreateDigitizerEvent");
|
|
99
|
+
_createFinger =
|
|
100
|
+
(decltype(_createFinger))dlsym(iokit, "IOHIDEventCreateDigitizerFingerEventWithQuality");
|
|
101
|
+
_setIntegerValue =
|
|
102
|
+
(decltype(_setIntegerValue))dlsym(iokit, "IOHIDEventSetIntegerValueWithOptions");
|
|
103
|
+
_appendEvent = (decltype(_appendEvent))dlsym(iokit, "IOHIDEventAppendEvent");
|
|
104
|
+
_setSenderID = (decltype(_setSenderID))dlsym(iokit, "IOHIDEventSetSenderID");
|
|
105
|
+
Class eventClass = NSClassFromString(@"UITouchesEvent");
|
|
106
|
+
if (!RNCEnsureUIKitGestureEnvironment()) {
|
|
107
|
+
_unavailableReason = @"UIApplication singleton unavailable";
|
|
108
|
+
} else if (_createHand == NULL || _createFinger == NULL || _setIntegerValue == NULL ||
|
|
109
|
+
_appendEvent == NULL || _setSenderID == NULL) {
|
|
110
|
+
_unavailableReason = @"IOHIDEvent symbols unavailable";
|
|
111
|
+
} else if (eventClass == Nil || ![eventClass instancesRespondToSelector:@selector(_init)] ||
|
|
112
|
+
![eventClass instancesRespondToSelector:@selector(_addTouch:forDelayedDelivery:)] ||
|
|
113
|
+
![UITouch instancesRespondToSelector:@selector(_setLocationInWindow:resetPrevious:)] ||
|
|
114
|
+
![UITouch instancesRespondToSelector:@selector(_setHidEvent:)]) {
|
|
115
|
+
_unavailableReason = @"UITouch/UITouchesEvent synthesis selectors unavailable";
|
|
116
|
+
} else {
|
|
117
|
+
_event = [[eventClass alloc] _init];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return self;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
- (RNCTestHIDEventRef)newHIDEvent
|
|
124
|
+
{
|
|
125
|
+
static const uint32_t kDigitizerDisplayIntegrated = (11 << 16) | 25;
|
|
126
|
+
static const uint32_t kIsBuiltIn = 4;
|
|
127
|
+
static const uint32_t kOptions = (uint32_t)-268435456;
|
|
128
|
+
uint64_t machTime = mach_absolute_time();
|
|
129
|
+
// Hand transducer, touch mask.
|
|
130
|
+
RNCTestHIDEventRef hand =
|
|
131
|
+
_createHand(kCFAllocatorDefault, machTime, 3, 0, 0, 1 << 1, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
|
132
|
+
_setIntegerValue(hand, kDigitizerDisplayIntegrated, 1, kOptions);
|
|
133
|
+
_setIntegerValue(hand, kIsBuiltIn, 1, kOptions);
|
|
134
|
+
_setSenderID(hand, 0x000000010000027F);
|
|
135
|
+
uint32_t index = 0;
|
|
136
|
+
for (UITouch *touch in _activeTouches) {
|
|
137
|
+
UITouchPhase phase = touch.phase;
|
|
138
|
+
// Position for moves; range and touch for contact changes.
|
|
139
|
+
uint32_t mask = phase == UITouchPhaseMoved ? (1 << 2) : ((1 << 0) | (1 << 1));
|
|
140
|
+
Boolean down = phase != UITouchPhaseEnded && phase != UITouchPhaseCancelled;
|
|
141
|
+
CGPoint point = [touch locationInView:_window];
|
|
142
|
+
RNCTestHIDEventRef finger = _createFinger(kCFAllocatorDefault, machTime, ++index, 2, mask,
|
|
143
|
+
point.x, point.y, 0, 0, 0, 5, 5, 1, 1, 1,
|
|
144
|
+
down, down, 0);
|
|
145
|
+
_setIntegerValue(finger, kDigitizerDisplayIntegrated, 1, kOptions);
|
|
146
|
+
_appendEvent(hand, finger, 0);
|
|
147
|
+
CFRelease(finger);
|
|
148
|
+
}
|
|
149
|
+
return hand;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
- (void)sendChangingTouch:(UITouch *)changed phase:(UITouchPhase)phase
|
|
153
|
+
{
|
|
154
|
+
NSAssert(_event != nil, @"%@", _unavailableReason);
|
|
155
|
+
_timestamp += 1.0 / 60.0;
|
|
156
|
+
for (UITouch *touch in _activeTouches) {
|
|
157
|
+
[touch setPhase:touch == changed ? phase : UITouchPhaseStationary];
|
|
158
|
+
[touch setTimestamp:_timestamp];
|
|
159
|
+
}
|
|
160
|
+
RNCTestHIDEventRef hid = [self newHIDEvent];
|
|
161
|
+
for (UITouch *touch in _activeTouches) [touch _setHidEvent:hid];
|
|
162
|
+
[_event _setHIDEvent:hid];
|
|
163
|
+
if (phase == UITouchPhaseBegan) [_event _addTouch:changed forDelayedDelivery:NO];
|
|
164
|
+
CFRelease(hid);
|
|
165
|
+
[_window sendEvent:_event];
|
|
166
|
+
if (phase == UITouchPhaseEnded || phase == UITouchPhaseCancelled) {
|
|
167
|
+
[_activeTouches removeObjectIdenticalTo:changed];
|
|
168
|
+
if (_activeTouches.count == 0) [_event _clearTouches];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
- (UITouch *)beginAt:(CGPoint)windowPoint
|
|
173
|
+
{
|
|
174
|
+
if (_activeTouches.count == 0) [_event _clearTouches];
|
|
175
|
+
UITouch *touch = [UITouch new];
|
|
176
|
+
UIView *view = [_window hitTest:windowPoint withEvent:nil];
|
|
177
|
+
[touch setWindow:_window];
|
|
178
|
+
[touch _setLocationInWindow:windowPoint resetPrevious:YES];
|
|
179
|
+
[touch setView:view];
|
|
180
|
+
if ([touch respondsToSelector:@selector(setGestureView:)]) [touch setGestureView:view];
|
|
181
|
+
[touch _setIsTapToClick:NO];
|
|
182
|
+
[touch setTapCount:1];
|
|
183
|
+
[_activeTouches addObject:touch];
|
|
184
|
+
[self sendChangingTouch:touch phase:UITouchPhaseBegan];
|
|
185
|
+
return touch;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
- (void)move:(UITouch *)touch to:(CGPoint)windowPoint
|
|
189
|
+
{
|
|
190
|
+
[touch _setLocationInWindow:windowPoint resetPrevious:NO];
|
|
191
|
+
[self sendChangingTouch:touch phase:UITouchPhaseMoved];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
- (void)end:(UITouch *)touch
|
|
195
|
+
{
|
|
196
|
+
[self sendChangingTouch:touch phase:UITouchPhaseEnded];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
@end
|
|
4
200
|
|
|
5
201
|
// Private declarations access the production class compiled into the pod.
|
|
6
202
|
@interface RNCCollapsiblePagerContentPressCancellationGestureRecognizer : UIGestureRecognizer
|
|
@@ -9,8 +205,8 @@
|
|
|
9
205
|
- (void)observeIncomingTouch:(UITouch *)touch;
|
|
10
206
|
@end
|
|
11
207
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
208
|
+
// Terminal states are reset by UIKit once it processes them. Observe requests
|
|
209
|
+
// while retaining the production implementation and UIKit setter.
|
|
14
210
|
@interface RNCPressCancellationObservedBridge : RNCCollapsiblePagerContentPressCancellationGestureRecognizer
|
|
15
211
|
@property (nonatomic, strong) NSMutableArray<NSNumber *> *requestedStates;
|
|
16
212
|
@end
|
|
@@ -23,8 +219,9 @@
|
|
|
23
219
|
}
|
|
24
220
|
@end
|
|
25
221
|
|
|
26
|
-
// These doubles supply input facts
|
|
27
|
-
//
|
|
222
|
+
// These doubles supply input facts to the bridge's decision logic directly.
|
|
223
|
+
// The production bridge owns its state transitions. End-to-end UIKit delivery
|
|
224
|
+
// through the real pager is covered by the delivery suite below.
|
|
28
225
|
@interface RNCPressCancellationTestTouch : UITouch
|
|
29
226
|
@property (nonatomic, strong) UIView *testView;
|
|
30
227
|
@property (nonatomic, assign) UITouchPhase testPhase;
|
|
@@ -49,6 +246,7 @@
|
|
|
49
246
|
@end
|
|
50
247
|
|
|
51
248
|
@interface RNCCollapsiblePagerPressCancellationTests : XCTestCase
|
|
249
|
+
@property (nonatomic, strong) UIWindow *window;
|
|
52
250
|
@property (nonatomic, strong) UIView *host;
|
|
53
251
|
@property (nonatomic, strong) UIView *surface;
|
|
54
252
|
@property (nonatomic, strong) UIView *content;
|
|
@@ -63,9 +261,16 @@
|
|
|
63
261
|
- (void)setUp
|
|
64
262
|
{
|
|
65
263
|
[super setUp];
|
|
264
|
+
XCTAssertTrue(NSThread.isMainThread);
|
|
265
|
+
// Without an application UIKit drops every programmatic recognizer state.
|
|
266
|
+
XCTAssertTrue(RNCEnsureUIKitGestureEnvironment());
|
|
267
|
+
// Register the recognizers with a UIKit gesture environment without taking focus.
|
|
268
|
+
self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 600)];
|
|
269
|
+
self.window.hidden = NO;
|
|
66
270
|
self.host = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)];
|
|
67
271
|
self.surface = [[UIView alloc] initWithFrame:self.host.bounds];
|
|
68
272
|
self.content = [[UIView alloc] initWithFrame:self.surface.bounds];
|
|
273
|
+
[self.window addSubview:self.host];
|
|
69
274
|
[self.host addSubview:self.surface];
|
|
70
275
|
[self.surface addSubview:self.content];
|
|
71
276
|
self.handler = [RCTSurfaceTouchHandler new];
|
|
@@ -75,6 +280,8 @@
|
|
|
75
280
|
self.bridge = [RNCPressCancellationObservedBridge new];
|
|
76
281
|
self.bridge.reactTouchHandler = self.handler;
|
|
77
282
|
[self.host addGestureRecognizer:self.bridge];
|
|
283
|
+
XCTAssertEqual(self.bridge.view.window, self.window);
|
|
284
|
+
XCTAssertEqual(self.handler.view.window, self.window);
|
|
78
285
|
self.pan = [RNCPressCancellationTestPan new];
|
|
79
286
|
self.pan.testState = UIGestureRecognizerStateBegan;
|
|
80
287
|
self.touch = [self touchInView:self.content phase:UITouchPhaseBegan];
|
|
@@ -90,7 +297,10 @@
|
|
|
90
297
|
self.pan = nil;
|
|
91
298
|
self.content = nil;
|
|
92
299
|
self.surface = nil;
|
|
300
|
+
[self.host removeFromSuperview];
|
|
93
301
|
self.host = nil;
|
|
302
|
+
self.window.hidden = YES;
|
|
303
|
+
self.window = nil;
|
|
94
304
|
[super tearDown];
|
|
95
305
|
}
|
|
96
306
|
|
|
@@ -143,6 +353,8 @@
|
|
|
143
353
|
}
|
|
144
354
|
self.pan.testState = UIGestureRecognizerStateBegan;
|
|
145
355
|
[self.bridge cancelPressForPan:self.pan];
|
|
356
|
+
XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)],
|
|
357
|
+
@"The production bridge must request recognition before UIKit retains Began");
|
|
146
358
|
XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan);
|
|
147
359
|
}
|
|
148
360
|
|
|
@@ -178,6 +390,8 @@
|
|
|
178
390
|
[self.bridge touchesBegan:[NSSet setWithObject:self.touch]
|
|
179
391
|
withEvent:[self eventWithTouches:[NSSet setWithObjects:self.touch, other, ended, cancelled, nil]]];
|
|
180
392
|
[self.bridge cancelPressForPan:self.pan];
|
|
393
|
+
XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)],
|
|
394
|
+
@"The production bridge must request recognition before UIKit retains Began");
|
|
181
395
|
XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan);
|
|
182
396
|
}
|
|
183
397
|
|
|
@@ -196,6 +410,8 @@
|
|
|
196
410
|
{
|
|
197
411
|
[self beginSingleTouch];
|
|
198
412
|
[self.bridge cancelPressForPan:self.pan];
|
|
413
|
+
XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)],
|
|
414
|
+
@"The production bridge must request recognition before UIKit retains Began");
|
|
199
415
|
XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan);
|
|
200
416
|
RNCPressCancellationTestTouch *second = [self touchInView:self.content phase:UITouchPhaseBegan];
|
|
201
417
|
RNCPressCancellationTestEvent *event =
|
|
@@ -247,6 +463,8 @@
|
|
|
247
463
|
[self.bridge reset];
|
|
248
464
|
[self beginSingleTouch];
|
|
249
465
|
[self.bridge cancelPressForPan:self.pan];
|
|
466
|
+
XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)],
|
|
467
|
+
@"The production bridge must request recognition before UIKit retains Began");
|
|
250
468
|
XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan);
|
|
251
469
|
}
|
|
252
470
|
|
|
@@ -258,6 +476,8 @@
|
|
|
258
476
|
[self beginSingleTouch];
|
|
259
477
|
[self.bridge observeIncomingTouch:second];
|
|
260
478
|
[self.bridge cancelPressForPan:self.pan];
|
|
479
|
+
XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)],
|
|
480
|
+
@"The production bridge must request recognition before UIKit retains Began");
|
|
261
481
|
XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan);
|
|
262
482
|
}
|
|
263
483
|
|
|
@@ -279,6 +499,8 @@
|
|
|
279
499
|
XCTAssertEqual(self.bridge.state, UIGestureRecognizerStatePossible);
|
|
280
500
|
[self beginSingleTouch];
|
|
281
501
|
[self.bridge cancelPressForPan:self.pan];
|
|
502
|
+
XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)],
|
|
503
|
+
@"The production bridge must request recognition before UIKit retains Began");
|
|
282
504
|
XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan);
|
|
283
505
|
}
|
|
284
506
|
|
|
@@ -290,6 +512,8 @@
|
|
|
290
512
|
weakEvent = event;
|
|
291
513
|
[self.bridge touchesBegan:[NSSet setWithObject:self.touch] withEvent:event];
|
|
292
514
|
[self.bridge cancelPressForPan:self.pan];
|
|
515
|
+
XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)],
|
|
516
|
+
@"The production bridge must request recognition before UIKit retains Began");
|
|
293
517
|
XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan);
|
|
294
518
|
self.touch.testPhase = UITouchPhaseEnded;
|
|
295
519
|
[self.bridge touchesEnded:[NSSet setWithObject:self.touch] withEvent:event];
|
|
@@ -318,6 +542,8 @@
|
|
|
318
542
|
{
|
|
319
543
|
[self beginSingleTouch];
|
|
320
544
|
[self.bridge cancelPressForPan:self.pan];
|
|
545
|
+
XCTAssertTrue([self.bridge.requestedStates containsObject:@(UIGestureRecognizerStateBegan)],
|
|
546
|
+
@"The production bridge must request recognition before UIKit retains Began");
|
|
321
547
|
XCTAssertEqual(self.bridge.state, UIGestureRecognizerStateBegan);
|
|
322
548
|
self.touch.testPhase = UITouchPhaseCancelled;
|
|
323
549
|
[self.bridge touchesCancelled:[NSSet setWithObject:self.touch]
|
|
@@ -344,3 +570,190 @@
|
|
|
344
570
|
}
|
|
345
571
|
|
|
346
572
|
@end
|
|
573
|
+
|
|
574
|
+
#pragma mark - Real UIKit delivery through the production pager
|
|
575
|
+
|
|
576
|
+
// Records what RN's Fabric touch handler observes. Its reset dispatches
|
|
577
|
+
// touchCancel for every still-registered touch, which is how a Pressable loses
|
|
578
|
+
// its press when an external recognizer prevents the handler.
|
|
579
|
+
@interface RNCRecordingSurfaceTouchHandler : RCTSurfaceTouchHandler
|
|
580
|
+
@property (nonatomic, strong) NSMutableArray<NSString *> *events;
|
|
581
|
+
@property (nonatomic, assign) NSInteger registeredTouches;
|
|
582
|
+
@end
|
|
583
|
+
|
|
584
|
+
@implementation RNCRecordingSurfaceTouchHandler
|
|
585
|
+
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
|
586
|
+
{
|
|
587
|
+
[super touchesBegan:touches withEvent:event];
|
|
588
|
+
self.registeredTouches += touches.count;
|
|
589
|
+
[self.events addObject:@"began"];
|
|
590
|
+
}
|
|
591
|
+
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
|
592
|
+
{
|
|
593
|
+
[super touchesMoved:touches withEvent:event];
|
|
594
|
+
if (![self.events.lastObject isEqualToString:@"moved"]) [self.events addObject:@"moved"];
|
|
595
|
+
}
|
|
596
|
+
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
|
597
|
+
{
|
|
598
|
+
[super touchesEnded:touches withEvent:event];
|
|
599
|
+
self.registeredTouches -= touches.count;
|
|
600
|
+
[self.events addObject:@"ended"];
|
|
601
|
+
}
|
|
602
|
+
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
|
603
|
+
{
|
|
604
|
+
[super touchesCancelled:touches withEvent:event];
|
|
605
|
+
self.registeredTouches -= touches.count;
|
|
606
|
+
[self.events addObject:@"cancelled"];
|
|
607
|
+
}
|
|
608
|
+
- (void)reset
|
|
609
|
+
{
|
|
610
|
+
NSInteger pending = self.registeredTouches;
|
|
611
|
+
[super reset];
|
|
612
|
+
self.registeredTouches = 0;
|
|
613
|
+
if (pending > 0) [self.events addObject:@"reset-cancelled"];
|
|
614
|
+
}
|
|
615
|
+
@end
|
|
616
|
+
|
|
617
|
+
@interface RNCCollapsiblePagerPressCancellationDeliveryTests : XCTestCase
|
|
618
|
+
@property (nonatomic, strong) UIWindow *window;
|
|
619
|
+
@property (nonatomic, strong) UIView *surface;
|
|
620
|
+
@property (nonatomic, strong) RNCRecordingSurfaceTouchHandler *handler;
|
|
621
|
+
@property (nonatomic, strong) RNCCollapsiblePagerViewComponentView *pager;
|
|
622
|
+
@property (nonatomic, strong) RNCSynthesizedTouchDriver *driver;
|
|
623
|
+
@property (nonatomic, strong) UIView *firstPage;
|
|
624
|
+
@end
|
|
625
|
+
|
|
626
|
+
@implementation RNCCollapsiblePagerPressCancellationDeliveryTests
|
|
627
|
+
|
|
628
|
+
- (void)setUp
|
|
629
|
+
{
|
|
630
|
+
[super setUp];
|
|
631
|
+
self.window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 800)];
|
|
632
|
+
self.window.hidden = NO;
|
|
633
|
+
UIView *host = [[UIView alloc] initWithFrame:self.window.bounds];
|
|
634
|
+
self.surface = [[UIView alloc] initWithFrame:host.bounds];
|
|
635
|
+
[self.window addSubview:host];
|
|
636
|
+
[host addSubview:self.surface];
|
|
637
|
+
self.handler = [RNCRecordingSurfaceTouchHandler new];
|
|
638
|
+
self.handler.events = [NSMutableArray new];
|
|
639
|
+
[self.handler attachToView:self.surface];
|
|
640
|
+
|
|
641
|
+
self.pager = [RNCCollapsiblePagerViewComponentView new];
|
|
642
|
+
auto props = std::make_shared<RNCCollapsiblePagerViewProps>();
|
|
643
|
+
props->nativeSmoothHeaderScrollEnabled = true;
|
|
644
|
+
props->headerHeight = 120;
|
|
645
|
+
props->stickyHeaderHeight = 44;
|
|
646
|
+
[self.pager updateProps:props oldProps:nullptr];
|
|
647
|
+
self.pager.frame = self.surface.bounds;
|
|
648
|
+
[self.surface addSubview:self.pager];
|
|
649
|
+
NSArray<UIView<RCTComponentViewProtocol> *> *children = @[
|
|
650
|
+
[RCTViewComponentView new], // header
|
|
651
|
+
[RCTViewComponentView new], // sticky header
|
|
652
|
+
[RCTViewComponentView new], // page 0
|
|
653
|
+
[RCTViewComponentView new], // page 1
|
|
654
|
+
];
|
|
655
|
+
children[0].frame = CGRectMake(0, 0, 390, 120);
|
|
656
|
+
children[1].frame = CGRectMake(0, 0, 390, 44);
|
|
657
|
+
[children enumerateObjectsUsingBlock:^(UIView<RCTComponentViewProtocol> *child,
|
|
658
|
+
NSUInteger index,
|
|
659
|
+
BOOL *stop) {
|
|
660
|
+
[self.pager mountChildComponentView:child index:(NSInteger)index];
|
|
661
|
+
}];
|
|
662
|
+
self.firstPage = children[2];
|
|
663
|
+
[self.window layoutIfNeeded];
|
|
664
|
+
[self drainMainQueue];
|
|
665
|
+
self.firstPage.frame = self.firstPage.superview.bounds;
|
|
666
|
+
self.driver = [[RNCSynthesizedTouchDriver alloc] initWithWindow:self.window];
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
- (void)tearDown
|
|
670
|
+
{
|
|
671
|
+
[self.pager removeFromSuperview];
|
|
672
|
+
[self.handler detachFromView:self.surface];
|
|
673
|
+
self.window.hidden = YES;
|
|
674
|
+
self.driver = nil;
|
|
675
|
+
self.firstPage = nil;
|
|
676
|
+
self.pager = nil;
|
|
677
|
+
self.handler = nil;
|
|
678
|
+
self.surface = nil;
|
|
679
|
+
self.window = nil;
|
|
680
|
+
[super tearDown];
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
- (void)drainMainQueue
|
|
684
|
+
{
|
|
685
|
+
XCTestExpectation *drained = [self expectationWithDescription:@"main queue drained"];
|
|
686
|
+
dispatch_async(dispatch_get_main_queue(), ^{ [drained fulfill]; });
|
|
687
|
+
[self waitForExpectations:@[drained] timeout:2];
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
- (void)assertFixtureIsReadyForDelivery
|
|
691
|
+
{
|
|
692
|
+
XCTAssertNil(self.driver.unavailableReason);
|
|
693
|
+
UIGestureRecognizer *bridge = [self.pager valueForKey:@"contentPressCancellationGesture"];
|
|
694
|
+
UIScrollView *pagerScrollView = [self.pager valueForKey:@"pagerScrollView"];
|
|
695
|
+
XCTAssertNotNil(bridge);
|
|
696
|
+
XCTAssertEqual([bridge valueForKey:@"reactTouchHandler"], self.handler);
|
|
697
|
+
XCTAssertEqual((id)pagerScrollView.delegate, self.pager);
|
|
698
|
+
XCTAssertGreaterThan(pagerScrollView.contentSize.width, CGRectGetWidth(pagerScrollView.bounds));
|
|
699
|
+
// The touch must start on React page content, outside the shared header.
|
|
700
|
+
CGPoint point = [self pressPoint];
|
|
701
|
+
UIView *hit = [self.window hitTest:point withEvent:nil];
|
|
702
|
+
XCTAssertTrue(hit == self.firstPage || [hit isDescendantOfView:self.firstPage],
|
|
703
|
+
@"hit view %@", hit);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
- (CGPoint)pressPoint
|
|
707
|
+
{
|
|
708
|
+
return CGPointMake(200, 600);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
- (void)testHorizontalPagerDragCancelsReactPressBeforeFingerLifts
|
|
712
|
+
{
|
|
713
|
+
[self assertFixtureIsReadyForDelivery];
|
|
714
|
+
UIScrollView *pagerScrollView = [self.pager valueForKey:@"pagerScrollView"];
|
|
715
|
+
CGPoint point = [self pressPoint];
|
|
716
|
+
UITouch *touch = [self.driver beginAt:point];
|
|
717
|
+
XCTAssertEqualObjects(self.handler.events, @[@"began"]);
|
|
718
|
+
XCTAssertEqual(self.handler.registeredTouches, 1);
|
|
719
|
+
|
|
720
|
+
for (NSInteger step = 1; step <= 8 && !pagerScrollView.isDragging; ++step) {
|
|
721
|
+
[self.driver move:touch to:CGPointMake(point.x - 12 * step, point.y)];
|
|
722
|
+
}
|
|
723
|
+
XCTAssertTrue(pagerScrollView.isDragging, @"the real pager pan must begin");
|
|
724
|
+
// Finger is still down: RN must already have cancelled its responder.
|
|
725
|
+
XCTAssertEqual(self.handler.registeredTouches, 0);
|
|
726
|
+
XCTAssertTrue([self.handler.events containsObject:@"reset-cancelled"] ||
|
|
727
|
+
[self.handler.events containsObject:@"cancelled"],
|
|
728
|
+
@"handler events %@", self.handler.events);
|
|
729
|
+
XCTAssertFalse([self.handler.events containsObject:@"ended"]);
|
|
730
|
+
NSUInteger eventsBeforeLift = self.handler.events.count;
|
|
731
|
+
|
|
732
|
+
[self.driver end:touch];
|
|
733
|
+
// UIKit must not deliver the lift to the prevented handler as a press end.
|
|
734
|
+
XCTAssertFalse([self.handler.events containsObject:@"ended"], @"events %@", self.handler.events);
|
|
735
|
+
XCTAssertEqual(self.handler.events.count, eventsBeforeLift);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
- (void)testStationaryTapKeepsReactPress
|
|
739
|
+
{
|
|
740
|
+
[self assertFixtureIsReadyForDelivery];
|
|
741
|
+
UITouch *touch = [self.driver beginAt:[self pressPoint]];
|
|
742
|
+
[self.driver end:touch];
|
|
743
|
+
XCTAssertEqualObjects(self.handler.events, (@[@"began", @"ended"]));
|
|
744
|
+
XCTAssertEqual(self.handler.registeredTouches, 0);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
- (void)testSmallMovementBelowPanThresholdKeepsReactPress
|
|
748
|
+
{
|
|
749
|
+
[self assertFixtureIsReadyForDelivery];
|
|
750
|
+
UIScrollView *pagerScrollView = [self.pager valueForKey:@"pagerScrollView"];
|
|
751
|
+
CGPoint point = [self pressPoint];
|
|
752
|
+
UITouch *touch = [self.driver beginAt:point];
|
|
753
|
+
[self.driver move:touch to:CGPointMake(point.x - 2, point.y)];
|
|
754
|
+
XCTAssertFalse(pagerScrollView.isDragging);
|
|
755
|
+
[self.driver end:touch];
|
|
756
|
+
XCTAssertEqualObjects(self.handler.events, (@[@"began", @"moved", @"ended"]));
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
@end
|
|
@@ -30,6 +30,10 @@ export class CollapsiblePagerView extends React.PureComponent {
|
|
|
30
30
|
lastMountSignature = null;
|
|
31
31
|
nativeTabCommandId = 0;
|
|
32
32
|
latestNativeTabTarget = null;
|
|
33
|
+
|
|
34
|
+
// Target of the last tab command native has received and not yet reported
|
|
35
|
+
// as selected. Commands superseded before their frame never reach native.
|
|
36
|
+
dispatchedNativeTabTarget = null;
|
|
33
37
|
latestNativeSelectedPage = this.state.selectedPage;
|
|
34
38
|
nativePagerScrollState = "idle";
|
|
35
39
|
nativeTabCommandQueuedDuringScroll = false;
|
|
@@ -89,6 +93,7 @@ export class CollapsiblePagerView extends React.PureComponent {
|
|
|
89
93
|
if (commandId !== this.nativeTabCommandId || !this.nativeRef) {
|
|
90
94
|
return;
|
|
91
95
|
}
|
|
96
|
+
this.dispatchedNativeTabTarget = position;
|
|
92
97
|
if (animated) {
|
|
93
98
|
CollapsiblePagerViewNativeCommands.setPage(this.nativeRef, position);
|
|
94
99
|
} else {
|
|
@@ -136,6 +141,7 @@ export class CollapsiblePagerView extends React.PureComponent {
|
|
|
136
141
|
const transitionComplete = pendingTarget === position;
|
|
137
142
|
if (transitionComplete) {
|
|
138
143
|
this.latestNativeTabTarget = null;
|
|
144
|
+
this.dispatchedNativeTabTarget = null;
|
|
139
145
|
this.nativeTabCommandQueuedDuringScroll = false;
|
|
140
146
|
}
|
|
141
147
|
this.setState({
|
|
@@ -154,6 +160,7 @@ export class CollapsiblePagerView extends React.PureComponent {
|
|
|
154
160
|
if (scrollState === "idle" && this.latestNativeTabTarget !== null && this.latestNativeTabTarget !== this.latestNativeSelectedPage && !queuedDuringPreviousScroll) {
|
|
155
161
|
++this.nativeTabCommandId;
|
|
156
162
|
this.latestNativeTabTarget = null;
|
|
163
|
+
this.dispatchedNativeTabTarget = null;
|
|
157
164
|
this.setState({
|
|
158
165
|
transientRetainedPages: []
|
|
159
166
|
}, this.notifyMountedPagesChanged);
|
|
@@ -166,7 +173,9 @@ export class CollapsiblePagerView extends React.PureComponent {
|
|
|
166
173
|
onNativeTabPress = event => {
|
|
167
174
|
const position = pageIndex(event.nativeEvent.position);
|
|
168
175
|
if (position !== null && position >= 0 && position < this.pages().length && (position !== this.state.selectedPage || this.latestNativeTabTarget !== null)) {
|
|
169
|
-
|
|
176
|
+
// Selection state lags an in-flight transition until native reports it.
|
|
177
|
+
const currentPage = this.dispatchedNativeTabTarget ?? this.state.selectedPage;
|
|
178
|
+
this.dispatchNativeTabPageCommand(position, this.props.nativeTabPressAnimationEnabled ?? Math.abs(position - currentPage) <= 1);
|
|
170
179
|
}
|
|
171
180
|
this.props.onNativeTabPress?.(event);
|
|
172
181
|
};
|
|
@@ -105,6 +105,7 @@ export declare class CollapsiblePagerView extends React.PureComponent<Collapsibl
|
|
|
105
105
|
private lastMountSignature;
|
|
106
106
|
private nativeTabCommandId;
|
|
107
107
|
private latestNativeTabTarget;
|
|
108
|
+
private dispatchedNativeTabTarget;
|
|
108
109
|
private latestNativeSelectedPage;
|
|
109
110
|
private nativePagerScrollState;
|
|
110
111
|
private nativeTabCommandQueuedDuringScroll;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/react-native-pager-view",
|
|
3
|
-
"version": "3.0.157-alpha.
|
|
3
|
+
"version": "3.0.157-alpha.262",
|
|
4
4
|
"description": "React Native wrapper for Android and iOS ViewPager",
|
|
5
5
|
"source": "./src/index.tsx",
|
|
6
6
|
"main": "./lib/module/index.js",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"typescript": "^5.9.2"
|
|
70
70
|
},
|
|
71
71
|
"peerDependencies": {
|
|
72
|
-
"@onekeyfe/react-native-native-logger": "3.0.157-alpha.
|
|
72
|
+
"@onekeyfe/react-native-native-logger": "3.0.157-alpha.262",
|
|
73
73
|
"react": "*",
|
|
74
74
|
"react-native": "*"
|
|
75
75
|
},
|
|
@@ -190,6 +190,10 @@ export class CollapsiblePagerView extends React.PureComponent<
|
|
|
190
190
|
|
|
191
191
|
private latestNativeTabTarget: number | null = null;
|
|
192
192
|
|
|
193
|
+
// Target of the last tab command native has received and not yet reported
|
|
194
|
+
// as selected. Commands superseded before their frame never reach native.
|
|
195
|
+
private dispatchedNativeTabTarget: number | null = null;
|
|
196
|
+
|
|
193
197
|
private latestNativeSelectedPage = this.state.selectedPage;
|
|
194
198
|
|
|
195
199
|
private nativePagerScrollState: OnPageScrollStateChangedEventData["pageScrollState"] =
|
|
@@ -282,6 +286,7 @@ export class CollapsiblePagerView extends React.PureComponent<
|
|
|
282
286
|
if (commandId !== this.nativeTabCommandId || !this.nativeRef) {
|
|
283
287
|
return;
|
|
284
288
|
}
|
|
289
|
+
this.dispatchedNativeTabTarget = position;
|
|
285
290
|
if (animated) {
|
|
286
291
|
CollapsiblePagerViewNativeCommands.setPage(
|
|
287
292
|
this.nativeRef,
|
|
@@ -354,6 +359,7 @@ export class CollapsiblePagerView extends React.PureComponent<
|
|
|
354
359
|
const transitionComplete = pendingTarget === position;
|
|
355
360
|
if (transitionComplete) {
|
|
356
361
|
this.latestNativeTabTarget = null;
|
|
362
|
+
this.dispatchedNativeTabTarget = null;
|
|
357
363
|
this.nativeTabCommandQueuedDuringScroll = false;
|
|
358
364
|
}
|
|
359
365
|
this.setState(
|
|
@@ -386,6 +392,7 @@ export class CollapsiblePagerView extends React.PureComponent<
|
|
|
386
392
|
) {
|
|
387
393
|
++this.nativeTabCommandId;
|
|
388
394
|
this.latestNativeTabTarget = null;
|
|
395
|
+
this.dispatchedNativeTabTarget = null;
|
|
389
396
|
this.setState(
|
|
390
397
|
{ transientRetainedPages: [] },
|
|
391
398
|
this.notifyMountedPagesChanged
|
|
@@ -408,10 +415,13 @@ export class CollapsiblePagerView extends React.PureComponent<
|
|
|
408
415
|
(position !== this.state.selectedPage ||
|
|
409
416
|
this.latestNativeTabTarget !== null)
|
|
410
417
|
) {
|
|
418
|
+
// Selection state lags an in-flight transition until native reports it.
|
|
419
|
+
const currentPage =
|
|
420
|
+
this.dispatchedNativeTabTarget ?? this.state.selectedPage;
|
|
411
421
|
this.dispatchNativeTabPageCommand(
|
|
412
422
|
position,
|
|
413
423
|
this.props.nativeTabPressAnimationEnabled ??
|
|
414
|
-
Math.abs(position -
|
|
424
|
+
Math.abs(position - currentPage) <= 1
|
|
415
425
|
);
|
|
416
426
|
}
|
|
417
427
|
this.props.onNativeTabPress?.(event);
|