@onekeyfe/react-native-pager-view 3.0.157-alpha.258 → 3.0.157-alpha.273

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
@@ -159,19 +165,46 @@ warnings were observed. Keyboard, multi-touch, physical-device performance and
159
165
  the full lifecycle/interaction matrix remain unverified. These are local source
160
166
  builds; the currently installed npm version does not yet contain this migration.
161
167
 
168
+ ## iOS vertical-scroll press cancellation
169
+
170
+ Status: implemented in source. A source-identical `node_modules` build was
171
+ runtime verified on a dedicated iOS 26.5 HomePager simulator on 2026-09-30:
172
+ a slow drag from the Show more button did not activate it, a fling from the
173
+ same button scrolled the list, and a stationary tap activated it. The Pager
174
+ XCTest suite passed 47/47 tests on a separate iOS 26.5 simulator with its data
175
+ on the external drive on 2026-09-30.
176
+
177
+ In `CollapsiblePagerView` smooth-header mode, a single-finger
178
+ vertical drag beginning on React page content MUST cancel that Surface's active
179
+ React press before finger-up once vertical displacement reaches 10 points and
180
+ exceeds horizontal displacement. Cancellation MUST leave the native vertical
181
+ scroller's pan and momentum intact. Stationary taps, movement below the threshold,
182
+ horizontal drags, and multi-touch sequences MUST retain their existing behavior.
183
+ The iOS content press bridge owns cancellation on the UI thread; the pager's
184
+ existing horizontal-drag cancellation remains independent. The bridge MUST clear
185
+ per-touch state on end, cancellation, and reuse. Android and Web behavior is
186
+ unchanged. Acceptance requires a real iOS button-origin drag that does not fire
187
+ the button, a button-origin fling that scrolls, an ordinary tap that fires, and
188
+ focused native touch-delivery coverage.
189
+
162
190
  ## Native tab-press animation default
163
191
 
192
+ Behavior change: previously an omitted `nativeTabPressAnimationEnabled` animated
193
+ every native tab press. Callers that relied on that default MUST pass `true`.
194
+
164
195
  On iOS and Android, an omitted `nativeTabPressAnimationEnabled` MUST animate a
165
196
  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
197
+ the absolute difference between the target index and the current page. While a
198
+ tab command that native has already received is in flight, its target is the
199
+ current page; otherwise it is the selected index. A request superseded before it
200
+ reached native is ignored. Explicit `true` continues to animate all
168
201
  tab presses, and explicit `false` continues to jump for all tab presses.
169
202
  Page retention, selection callbacks, swipe gestures, imperative `setPage` /
170
203
  `setPageWithoutAnimation` commands and Web behavior retain their contracts.
171
204
  Callers rendering their own JS tab bar choose the matching imperative command.
172
205
 
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
206
+ Status: implemented; focused native-wrapper tests cover both directions,
207
+ explicit overrides and in-flight/superseded command targets. Home applies this policy to its JS tab bar, with iOS and
175
208
  Android simulator recordings verifying distant jumps and adjacent transitions.
176
209
 
177
210
  ## Android refresh foreground coordination
@@ -295,6 +328,24 @@ and refresh completion, caller offset changes, late installation/replacement,
295
328
  retained-page switching, clipping and nested-owner cleanup. Programmatic UIKit
296
329
  unit tests do not substitute for real pull-gesture visibility acceptance.
297
330
 
331
+ When a smooth shared header is attached to the active iOS scroll view, UIKit's
332
+ temporary refresh top inset MUST keep the header and content aligned. Both are
333
+ children of that scroll view and move together with its content offset; the
334
+ pager MUST NOT apply a separate refresh translation to the header. The scroll
335
+ view's inset and offset, the refresh indicator, and refresh callback ownership
336
+ remain unchanged. Removing the refresh inset or control preserves this alignment
337
+ even without a scroll-offset event. Caller inset changes update the pager
338
+ baseline. The sticky tab row MUST pin against the baseline top inset; the
339
+ refresh control's temporary inset MUST NOT move it below the content, including
340
+ rapid direction reversals while the spinner is active. Detaching the header
341
+ restores its normal transform. This applies to RN NativeScroller and NativeList
342
+ pages; non-smooth and detached headers keep
343
+ their existing positioning. The 2026-09-30 Home Spot simulator checkpoint covers
344
+ shared header/content alignment and inset removal. A separate source-identical
345
+ local package build verified the sticky row during active-refresh vertical
346
+ reversals; published-package, other Home pages and physical-device acceptance
347
+ remain open.
348
+
298
349
  ## iOS shared-header hit testing
299
350
 
300
351
  Smooth-header hit forwarding is restricted to the pager's own interactive
@@ -355,6 +406,10 @@ A new child receives current header travel before threshold evaluation. Native
355
406
  list offsets remain local to the leaf, while consumers of this protocol add all
356
407
  ancestor contributions exactly once. Programmatic scrolling/header restoration
357
408
  must publish the resulting header offset through the same path.
409
+ Every NativeScroller viewport the coordinator owns, including adjacent pages it
410
+ translates, receives each header offset change, not only the observed page.
411
+ Content-offset observers are removed from the observer instance they were
412
+ registered on, and a removed page's scroller stops writing offsets immediately.
358
413
 
359
414
  ## Acceptance
360
415
 
@@ -1033,6 +1033,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1033
1033
  __weak UITouch *_trackedTouch;
1034
1034
  UIEvent *_touchEvent;
1035
1035
  BOOL _multipleSurfaceTouchesObserved;
1036
+ CGPoint _trackedTouchStartPoint;
1037
+ BOOL _didCancelReactPressForVerticalMovement;
1036
1038
  }
1037
1039
 
1038
1040
  - (BOOL)canPreventGestureRecognizer:(UIGestureRecognizer *)other
@@ -1069,12 +1071,37 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1069
1071
  return;
1070
1072
  }
1071
1073
  _trackedTouch = touches.anyObject;
1074
+ _trackedTouchStartPoint = [_trackedTouch locationInView:self.view];
1075
+ _didCancelReactPressForVerticalMovement = NO;
1072
1076
  [self recordEvent:event];
1073
1077
  }
1074
1078
 
1075
1079
  - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
1076
1080
  {
1077
1081
  [self recordEvent:event];
1082
+ UITouch *touch = _trackedTouch;
1083
+ UIGestureRecognizer *handler = self.reactTouchHandler;
1084
+ if (touch == nil || ![touches containsObject:touch] ||
1085
+ self.state != UIGestureRecognizerStatePossible ||
1086
+ _didCancelReactPressForVerticalMovement || _multipleSurfaceTouchesObserved ||
1087
+ handler.view == nil || self.view != handler.view.superview ||
1088
+ handler.state == UIGestureRecognizerStateFailed ||
1089
+ handler.state == UIGestureRecognizerStateCancelled ||
1090
+ handler.state == UIGestureRecognizerStateEnded) return;
1091
+ CGPoint point = [touch locationInView:self.view];
1092
+ CGFloat dx = point.x - _trackedTouchStartPoint.x;
1093
+ CGFloat dy = point.y - _trackedTouchStartPoint.y;
1094
+ if (fabs(dy) < 10.0 || fabs(dy) <= fabs(dx)) return;
1095
+ NSUInteger activeTouches = 0;
1096
+ for (UITouch *activeTouch in event.allTouches) {
1097
+ if (activeTouch.phase == UITouchPhaseEnded ||
1098
+ activeTouch.phase == UITouchPhaseCancelled) continue;
1099
+ if ([activeTouch.view isDescendantOfView:handler.view]) ++activeTouches;
1100
+ }
1101
+ if (activeTouches != 1) return;
1102
+ _didCancelReactPressForVerticalMovement = YES;
1103
+ handler.enabled = NO;
1104
+ handler.enabled = YES;
1078
1105
  }
1079
1106
 
1080
1107
  - (void)cancelPressForPan:(UIPanGestureRecognizer *)pan
@@ -1108,6 +1135,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1108
1135
  ? UIGestureRecognizerStateEnded : UIGestureRecognizerStateFailed;
1109
1136
  _trackedTouch = nil;
1110
1137
  _touchEvent = nil;
1138
+ _didCancelReactPressForVerticalMovement = NO;
1111
1139
  }
1112
1140
 
1113
1141
  - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
@@ -1117,6 +1145,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1117
1145
  ? UIGestureRecognizerStateCancelled : UIGestureRecognizerStateFailed;
1118
1146
  _trackedTouch = nil;
1119
1147
  _touchEvent = nil;
1148
+ _didCancelReactPressForVerticalMovement = NO;
1120
1149
  }
1121
1150
 
1122
1151
  - (void)reset
@@ -1125,6 +1154,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1125
1154
  _trackedTouch = nil;
1126
1155
  _touchEvent = nil;
1127
1156
  _multipleSurfaceTouchesObserved = NO;
1157
+ _didCancelReactPressForVerticalMovement = NO;
1128
1158
  }
1129
1159
 
1130
1160
  @end
@@ -2437,6 +2467,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2437
2467
  forKeyPath:@"contentOffset"
2438
2468
  options:NSKeyValueObservingOptionNew
2439
2469
  context:RNCCollapsiblePagerContentOffsetContext];
2470
+ [candidate addObserver:self
2471
+ forKeyPath:@"contentInset"
2472
+ options:NSKeyValueObservingOptionNew
2473
+ context:RNCCollapsiblePagerContentOffsetContext];
2440
2474
  [candidate addObserver:self
2441
2475
  forKeyPath:@"contentSize"
2442
2476
  options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
@@ -2459,6 +2493,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2459
2493
  [scrollView removeObserver:self
2460
2494
  forKeyPath:@"contentOffset"
2461
2495
  context:RNCCollapsiblePagerContentOffsetContext];
2496
+ [scrollView removeObserver:self
2497
+ forKeyPath:@"contentInset"
2498
+ context:RNCCollapsiblePagerContentOffsetContext];
2462
2499
  [scrollView removeObserver:self
2463
2500
  forKeyPath:@"contentSize"
2464
2501
  context:RNCCollapsiblePagerContentOffsetContext];
@@ -2932,6 +2969,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2932
2969
  if (appliedTop != nil) {
2933
2970
  [_appliedTopInsets setObject:@(appliedTop.doubleValue + next.top - previous.top) forKey:scrollView];
2934
2971
  }
2972
+ if (scrollView == _observedScrollView) [self updateHeaderForScrollView:scrollView];
2935
2973
  }
2936
2974
 
2937
2975
  - (void)reapplyInsetsToObservedScrollView
@@ -2964,10 +3002,16 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2964
3002
  context:(void *)context
2965
3003
  {
2966
3004
  if (context == RNCScrollRefreshControlContext) {
2967
- [self updateRefreshControlForScrollView:(UIScrollView *)object];
3005
+ UIScrollView *scrollView = (UIScrollView *)object;
3006
+ [self updateRefreshControlForScrollView:scrollView];
3007
+ if (scrollView == _observedScrollView) [self updateHeaderForScrollView:scrollView];
2968
3008
  return;
2969
3009
  }
2970
3010
  if (context == RNCCollapsiblePagerContentOffsetContext && object == _observedScrollView) {
3011
+ if ([keyPath isEqualToString:@"contentInset"]) {
3012
+ [self updateHeaderForScrollView:(UIScrollView *)object];
3013
+ return;
3014
+ }
2971
3015
  if ([keyPath isEqualToString:@"contentSize"]) {
2972
3016
  NSValue *oldSize = change[NSKeyValueChangeOldKey];
2973
3017
  NSValue *newSize = change[NSKeyValueChangeNewKey];
@@ -2998,9 +3042,15 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2998
3042
  BOOL attachedToList = _nativeSmoothHeaderScrollEnabled &&
2999
3043
  _sharedHeaderScrollView != nil && !_sharedHeadersLiftedForPagerTransition;
3000
3044
  if (attachedToList) {
3045
+ // The header and content share this scroll view. UIKit's refresh inset
3046
+ // moves both together, so translating the header would separate them.
3001
3047
  _sharedHeaderHostView.transform = CGAffineTransformIdentity;
3002
3048
  _headerView.transform = CGAffineTransformIdentity;
3003
- CGFloat pinnedTranslation = MAX(0, _currentLogicalOffset - _headerHeight);
3049
+ UIScrollView *scrollView = _sharedHeaderScrollView;
3050
+ NSNumber *appliedTop = [_appliedTopInsets objectForKey:scrollView];
3051
+ CGFloat refreshInset = scrollView.refreshControl != nil && appliedTop != nil
3052
+ ? MAX(0, scrollView.contentInset.top - appliedTop.doubleValue) : 0;
3053
+ CGFloat pinnedTranslation = MAX(0, _currentLogicalOffset - _headerHeight - refreshInset);
3004
3054
  CGAffineTransform stickyTransform = CGAffineTransformMakeTranslation(0, pinnedTranslation);
3005
3055
  _stickyHeaderView.transform = stickyTransform;
3006
3056
  _nativeTabBarView.transform = stickyTransform;