@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 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
- observedOwnedScroller?.let { publishConsumedOffset(it) }
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
- val contentOffset = scroller.scrollY
721
- pageOffsets[currentPageKey()] = contentOffset
722
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
723
- setHeaderOffset(headerHeightPx)
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.addOnScrollChangedListener(listener)
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 && scroller.viewTreeObserver.isAlive) {
739
- scroller.viewTreeObserver.removeOnScrollChangedListener(listener)
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.scrollTo(0, pageOffsets[key] ?: 0)
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
- val contentOffset = scroller.scrollY
789
- pageOffsets[key] = contentOffset
790
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx)
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.addOnScrollChangedListener(listener)
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
- if (scroller.viewTreeObserver.isAlive) scroller.viewTreeObserver.removeOnScrollChangedListener(it)
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
- viewport.scrollTo(x, y)
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
- if (refresh.isRefreshing != requestedRefresh) refresh.isRefreshing = requestedRefresh
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
- stopping = true
485
- try {
486
- // Replace the same AndroidX trajectory with zero velocity without touching its smooth-scroll clock.
487
- super.fling(0)
488
- super.stopNestedScroll(ViewCompat.TYPE_NON_TOUCH)
489
- } finally {
490
- stopping = false
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
- view.setRefreshColors(if (value == null) intArrayOf(Color.GRAY) else IntArray(value.size()) { value.getInt(it) })
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 currently selected
167
- index, not the last requested target. Explicit `true` continues to animate all
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 and
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
- // UIKit can reset terminal states immediately outside real touch delivery.
13
- // Observe requests while retaining the production implementation and UIKit setter.
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 only. The production bridge owns its state
27
- // transitions; this suite does not claim to synthesize UIKit touch delivery.
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
- this.dispatchNativeTabPageCommand(position, this.props.nativeTabPressAnimationEnabled ?? Math.abs(position - this.state.selectedPage) <= 1);
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.258",
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.255",
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 - this.state.selectedPage) <= 1
424
+ Math.abs(position - currentPage) <= 1
415
425
  );
416
426
  }
417
427
  this.props.onNativeTabPress?.(event);