@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 +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 +59 -4
- package/ios/RNCCollapsiblePagerViewComponentView.mm +52 -2
- package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +470 -4
- package/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm +69 -0
- 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
|
|
@@ -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
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|