@onekeyfe/react-native-pager-view 3.0.157-alpha.254 → 3.0.157-alpha.256

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
@@ -75,3 +75,5 @@ including unmounted pages that would show blank.
75
75
 
76
76
  Thank you again to Callstack and everyone who contributes to
77
77
  `react-native-pager-view` 💙
78
+
79
+ The native ancestor-scroll contribution protocol is specified in [docs/SPEC.md](docs/SPEC.md).
@@ -20,6 +20,7 @@ import androidx.recyclerview.widget.RecyclerView
20
20
  import androidx.viewpager2.widget.ViewPager2
21
21
  import com.facebook.react.R as ReactR
22
22
  import com.facebook.react.uimanager.events.NativeGestureUtil
23
+ import com.facebook.react.views.scroll.ReactScrollView
23
24
  import com.margelo.nitro.nativelogger.OneKeyLog
24
25
  import java.util.WeakHashMap
25
26
  import kotlin.math.max
@@ -508,6 +509,19 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
508
509
  applyHeaderOffset()
509
510
  }
510
511
 
512
+ private fun publishConsumedOffset(recycler: RecyclerView, remove: Boolean = false) {
513
+ val key = R.id.onekey_native_scroll_coordinator_consumed_offsets
514
+ val existing = recycler.getTag(key) as? NativeScrollCoordinatorContributions
515
+ val contributions = existing ?: if (remove) return else NativeScrollCoordinatorContributions().also {
516
+ recycler.setTag(key, it)
517
+ }
518
+ val changed = if (remove) contributions.clearOwner(this) else contributions.update(this, headerOffsetPx)
519
+ if (changed) {
520
+ (recycler.getTag(R.id.onekey_native_scroll_coordinator_offset_listener) as? Runnable)?.run()
521
+ }
522
+ if (contributions.isEmpty()) recycler.setTag(key, null)
523
+ }
524
+
511
525
  private fun applyHeaderOffset() {
512
526
  val offset = headerOffsetPx.toFloat()
513
527
  pager.translationY = -offset
@@ -515,6 +529,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
515
529
  stickyHeaderView?.translationY = -offset
516
530
  nativeTabBarView.translationY = -offset
517
531
  nativeSubHeaderView.translationY = -offset
532
+ observedRecyclerView?.let { publishConsumedOffset(it) }
518
533
  }
519
534
 
520
535
  private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index"
@@ -616,12 +631,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
616
631
  private fun attachRecyclerObserver(recycler: RecyclerView) {
617
632
  if (observedRecyclerView === recycler) {
618
633
  applyRecyclerInsets(recycler, selectedPage)
634
+ publishConsumedOffset(recycler)
619
635
  return
620
636
  }
621
637
 
622
638
  detachPrimaryScrollObserver()
623
639
  observedRecyclerView = recycler
624
640
  applyRecyclerInsets(recycler, selectedPage)
641
+ publishConsumedOffset(recycler)
625
642
  val listener = object : RecyclerView.OnScrollListener() {
626
643
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
627
644
  val contentOffset = recyclerView.computeVerticalScrollOffset()
@@ -646,6 +663,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
646
663
  val listener = observedScrollListener
647
664
  if (recycler != null) {
648
665
  pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
666
+ publishConsumedOffset(recycler, remove = true)
649
667
  }
650
668
  if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
651
669
  if (recycler != null) {
@@ -775,11 +793,29 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
775
793
  }
776
794
  updateScrollerPaddingOwnership(scroller, original)
777
795
  val topInset = headerHeightPx + stickyHeaderHeightPx
778
- val top = original.top + topInset
779
796
  scroller.clipToPadding = false
780
- if (scroller.paddingLeft != original.left || scroller.paddingTop != top ||
781
- scroller.paddingRight != original.right || scroller.paddingBottom != original.bottom) {
782
- scroller.setPadding(original.left, top, original.right, original.bottom)
797
+ if (scroller is ReactScrollView) {
798
+ // Fabric owns the content child's layout, so ScrollView padding alone
799
+ // cannot move it. RN's native ScrollAway API also updates Fabric culling.
800
+ val state = scroller.reactScrollViewScrollState
801
+ val scrollAwayTop = nativeScrollerScrollAwayTop(
802
+ state.scrollAwayPaddingTop, original.appliedTopInset, topInset,
803
+ )
804
+ if (state.scrollAwayPaddingTop != scrollAwayTop ||
805
+ scroller.getChildAt(0)?.translationY != scrollAwayTop.toFloat()) {
806
+ scroller.setScrollAwayPaddingEnabledUnstable(scrollAwayTop, state.scrollAwayPaddingBottom)
807
+ }
808
+ val bottom = original.bottom + topInset
809
+ if (scroller.paddingLeft != original.left || scroller.paddingTop != original.top ||
810
+ scroller.paddingRight != original.right || scroller.paddingBottom != bottom) {
811
+ scroller.setPadding(original.left, original.top, original.right, bottom)
812
+ }
813
+ } else {
814
+ val top = original.top + topInset
815
+ if (scroller.paddingLeft != original.left || scroller.paddingTop != top ||
816
+ scroller.paddingRight != original.right || scroller.paddingBottom != original.bottom) {
817
+ scroller.setPadding(original.left, top, original.right, original.bottom)
818
+ }
783
819
  }
784
820
  original.appliedTopInset = topInset
785
821
 
@@ -804,13 +840,29 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
804
840
  scroller: ScrollView,
805
841
  original: ScrollerPadding,
806
842
  ) {
807
- val expectedTop = original.top + original.appliedTopInset
843
+ val isReactScroller = scroller is ReactScrollView
844
+ if (scroller is ReactScrollView) {
845
+ val state = scroller.reactScrollViewScrollState
846
+ if (nativeScrollerPaddingWasResetByScrollAway(
847
+ original.appliedTopInset,
848
+ scroller.paddingLeft, scroller.paddingTop, scroller.paddingRight, scroller.paddingBottom,
849
+ state.scrollAwayPaddingTop, state.scrollAwayPaddingBottom,
850
+ )) {
851
+ // RN may replay ScrollAway state before this pass. Its setter clears
852
+ // caller padding; that native reset must not become our new baseline.
853
+ return
854
+ }
855
+ }
856
+ val ownedTop = if (isReactScroller) 0 else original.appliedTopInset
857
+ val ownedBottom = if (isReactScroller) original.appliedTopInset else 0
808
858
  if (scroller.paddingLeft != original.left) original.left = scroller.paddingLeft
809
- if (scroller.paddingTop != expectedTop) {
810
- original.top = (scroller.paddingTop - original.appliedTopInset).coerceAtLeast(0)
859
+ if (scroller.paddingTop != original.top + ownedTop) {
860
+ original.top = (scroller.paddingTop - ownedTop).coerceAtLeast(0)
811
861
  }
812
862
  if (scroller.paddingRight != original.right) original.right = scroller.paddingRight
813
- if (scroller.paddingBottom != original.bottom) original.bottom = scroller.paddingBottom
863
+ if (scroller.paddingBottom != original.bottom + ownedBottom) {
864
+ original.bottom = (scroller.paddingBottom - ownedBottom).coerceAtLeast(0)
865
+ }
814
866
  if (scroller.clipToPadding) original.clipToPadding = true
815
867
  }
816
868
 
@@ -835,6 +887,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
835
887
  }
836
888
 
837
889
  private fun restoreRecyclerInsets(recycler: RecyclerView) {
890
+ publishConsumedOffset(recycler, remove = true)
838
891
  val original = originalRecyclerPadding.remove(recycler) ?: return
839
892
  updateRecyclerPaddingOwnership(recycler, original)
840
893
  restoredRecyclerKeys.remove(recycler)
@@ -846,6 +899,13 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
846
899
  val original = originalScrollerPadding.remove(scroller) ?: return
847
900
  updateScrollerPaddingOwnership(scroller, original)
848
901
  restoredScrollerKeys.remove(scroller)
902
+ if (scroller is ReactScrollView) {
903
+ val state = scroller.reactScrollViewScrollState
904
+ scroller.setScrollAwayPaddingEnabledUnstable(
905
+ nativeScrollerScrollAwayTop(state.scrollAwayPaddingTop, original.appliedTopInset, 0),
906
+ state.scrollAwayPaddingBottom,
907
+ )
908
+ }
849
909
  scroller.clipToPadding = original.clipToPadding
850
910
  scroller.setPadding(original.left, original.top, original.right, original.bottom)
851
911
  }
@@ -0,0 +1,15 @@
1
+ package com.reactnativepagerview
2
+
3
+ import java.util.WeakHashMap
4
+
5
+ /** Native tag payload; leaf observers can read it as Map without a pager dependency. */
6
+ internal class NativeScrollCoordinatorContributions : WeakHashMap<Any, Int>() {
7
+ fun update(owner: Any, pixels: Int): Boolean {
8
+ val value = pixels.coerceAtLeast(0)
9
+ if (get(owner) == value) return false
10
+ put(owner, value)
11
+ return true
12
+ }
13
+
14
+ fun clearOwner(owner: Any): Boolean = remove(owner) != null
15
+ }
@@ -0,0 +1,17 @@
1
+ package com.reactnativepagerview
2
+
3
+ // Replace only this pager's contribution; another native owner may share the scroller.
4
+ internal fun nativeScrollerScrollAwayTop(currentTop: Int, previousInset: Int, nextInset: Int): Int =
5
+ (currentTop - previousInset).coerceAtLeast(0) + nextInset
6
+
7
+ // ReactScrollView's ScrollAway setter always writes this exact padding shape.
8
+ internal fun nativeScrollerPaddingWasResetByScrollAway(
9
+ appliedInset: Int,
10
+ left: Int,
11
+ top: Int,
12
+ right: Int,
13
+ bottom: Int,
14
+ scrollAwayTop: Int,
15
+ scrollAwayBottom: Int,
16
+ ): Boolean = appliedInset > 0 && left == 0 && top == 0 && right == 0 &&
17
+ bottom == scrollAwayTop + scrollAwayBottom
@@ -0,0 +1,4 @@
1
+ <resources>
2
+ <item name="onekey_native_scroll_coordinator_consumed_offsets" type="id" />
3
+ <item name="onekey_native_scroll_coordinator_offset_listener" type="id" />
4
+ </resources>
@@ -0,0 +1,26 @@
1
+ package com.reactnativepagerview
2
+
3
+ /** Standalone JVM regression: compile together with the production helper. */
4
+ fun main() {
5
+ val outer = Any()
6
+ val inner = Any()
7
+ val firstChild = NativeScrollCoordinatorContributions()
8
+ check(firstChild.update(outer, 120))
9
+ check(firstChild.update(inner, 60))
10
+ check(firstChild.values.sum() == 180)
11
+ check(!firstChild.update(outer, 120))
12
+ check(firstChild.update(outer, 30))
13
+ check(firstChild.values.sum() == 90)
14
+ check(firstChild.clearOwner(outer))
15
+ check(firstChild.values.sum() == 60)
16
+ check(!firstChild.clearOwner(outer))
17
+ val secondChild = NativeScrollCoordinatorContributions()
18
+ check(secondChild.update(outer, 30))
19
+ check(secondChild.values.sum() == 30)
20
+ check(firstChild.values.sum() == 60)
21
+ check(firstChild.clearOwner(inner))
22
+ check(firstChild.isEmpty())
23
+ check(secondChild.update(outer, -1))
24
+ check(secondChild.values.sum() == 0)
25
+ println("Native coordinator contributions: nested owners, duplicate updates, cleanup, and replacement passed")
26
+ }
@@ -0,0 +1,37 @@
1
+ package com.reactnativepagerview
2
+
3
+ /** Standalone JVM contract regression for Fabric scroller inset ownership. */
4
+ fun main() {
5
+ val initialTop = 24
6
+ val firstHeader = 182 + 48
7
+ val initialApplied = nativeScrollerScrollAwayTop(initialTop, 0, firstHeader)
8
+ check(initialApplied == 254)
9
+ check(nativeScrollerScrollAwayTop(initialApplied, firstHeader, firstHeader) == initialApplied)
10
+ val measuredHeader = 310 + 48
11
+ val resized = nativeScrollerScrollAwayTop(initialApplied, firstHeader, measuredHeader)
12
+ check(resized == initialTop + measuredHeader)
13
+ // A second pager's contribution survives the first pager's detach.
14
+ val nested = nativeScrollerScrollAwayTop(resized, 0, 100)
15
+ val outerDetached = nativeScrollerScrollAwayTop(nested, measuredHeader, 0)
16
+ check(outerDetached == initialTop + 100)
17
+ check(nativeScrollerScrollAwayTop(outerDetached, 100, 0) == initialTop)
18
+ // Fabric may reset state while replacing its content root; do not apply a negative baseline.
19
+ check(nativeScrollerScrollAwayTop(0, measuredHeader, firstHeader) == firstHeader)
20
+ // Caller L/R/B = 16/16/40 must survive RN's setter replay after our 230px inset.
21
+ val caller = intArrayOf(16, 0, 16, 40)
22
+ val rnReplay = intArrayOf(0, 0, 0, 230)
23
+ val keepCaller = nativeScrollerPaddingWasResetByScrollAway(
24
+ 230, rnReplay[0], rnReplay[1], rnReplay[2], rnReplay[3], 230, 0,
25
+ )
26
+ check(keepCaller)
27
+ val restored = if (keepCaller) caller else rnReplay
28
+ check(restored.contentEquals(intArrayOf(16, 0, 16, 40)))
29
+ // Nonzero native bottom state is part of RN's reset signature too.
30
+ check(nativeScrollerPaddingWasResetByScrollAway(230, 0, 0, 0, 278, 254, 24))
31
+ // Actual caller updates, including clearing all padding, remain observable.
32
+ check(!nativeScrollerPaddingWasResetByScrollAway(230, 24, 0, 24, 290, 230, 0))
33
+ check(!nativeScrollerPaddingWasResetByScrollAway(230, 0, 0, 0, 0, 230, 0))
34
+ // Initial discovery must capture caller values before this pager owns an inset.
35
+ check(!nativeScrollerPaddingWasResetByScrollAway(0, 0, 0, 0, 230, 230, 0))
36
+ println("NativeScroller ScrollAway ownership: initial, repeat, measured resize, nested detach, Fabric reset, RN padding replay, and caller updates passed")
37
+ }
package/docs/SPEC.md ADDED
@@ -0,0 +1,133 @@
1
+ # PagerView native scrolling contract
2
+
3
+ Existing PagerView and CollapsiblePagerView APIs are documented in README.md.
4
+ The sections below define native coordination boundaries without new React props.
5
+
6
+ ## iOS refresh indicator placement
7
+
8
+ Status: source implementation; real refresh-cycle acceptance pending.
9
+ In smooth-header mode, the pager shifts the refresh control's sublayers upward
10
+ by its header-plus-sticky height. It does not change the refresh control frame,
11
+ bounds, scroll offset, refresh state or JavaScript callback. RN's caller-owned
12
+ `progressViewOffset` remains untouched, including dynamic updates whose bounds
13
+ origin could otherwise collide with a coordinator-applied value.
14
+
15
+ One internal state per UIRefreshControl combines weak pager-owner contributions.
16
+ It preserves the original sublayer transform and clipping setting. Repeated
17
+ attachment and header resizing replace an owner's contribution. Layer clipping
18
+ is disabled while coordinating so it cannot hide the translated indicator.
19
+ A control replacement, scroll-view release, pager recycling or destruction
20
+ removes only that owner's contribution and restores the baseline when none remain.
21
+ Controls installed after the scroller is attached are also coordinated. Native
22
+ UICollectionView controls and RN ScrollView controls use this same UIKit boundary.
23
+
24
+ Native layer observations reapply the contribution after an external sublayer
25
+ transform change, without observing or interpreting UIKit/RN bounds writes.
26
+ There is no per-frame JavaScript work. Acceptance must cover real UIKit pulling
27
+ and refresh completion, caller offset changes, late installation/replacement,
28
+ retained-page switching, clipping and nested-owner cleanup. Programmatic UIKit
29
+ unit tests do not substitute for real pull-gesture visibility acceptance.
30
+
31
+ ## iOS shared-header hit testing
32
+
33
+ Smooth-header hit forwarding is restricted to the pager's own interactive
34
+ viewport. A collapsed header clipped above that viewport MUST NOT intercept
35
+ touches intended for a sibling toolbar. Hidden, disabled and effectively
36
+ transparent pagers do not forward touches. Fabric `pointerEvents="none"` and
37
+ `"box-only"` retain their superclass behavior; `"auto"` and `"box-none"` may
38
+ forward to visible shared-header children inside the pager.
39
+
40
+ The shared header's internal hit testing still supports sticky children that
41
+ have moved outside the header host's original bounds while remaining inside the
42
+ pager. Focused UIKit tests cover sibling-toolbar hit delivery, pinned-child
43
+ delivery, pointer-event modes and interaction flags. Device acceptance includes
44
+ toolbar taps with expanded and collapsed headers on retained pages.
45
+
46
+ ## iOS cached-offset restoration after content shrink
47
+
48
+ When restoring a retained page's cached vertical offset, the pager limits only
49
+ the upper bound to the current content height, viewport and applied insets. A
50
+ cache captured before rows were removed MUST NOT restore an offset below the new
51
+ content bottom, including when removal occurs during a page transition. Valid
52
+ deep offsets and the existing lower-bound/header alignment rules are unchanged.
53
+ This check runs only on explicit restoration, not on scroll frames; UIKit pull
54
+ overscroll and refresh settlement retain their existing ownership. An unmeasured
55
+ zero-height viewport defers this upper-bound limit to a later restoration.
56
+
57
+ The focused policy test covers shrink after the list has already corrected its
58
+ offset, valid deep offsets, negative pull offsets and an unmeasured viewport.
59
+ Runtime acceptance requires clearing a retained page during a page transition,
60
+ then returning without an extra drag to expose its header/empty/footer slots.
61
+
62
+
63
+ ## Ancestor consumed-scroll contributions
64
+
65
+ Status: source implementation; real nested-pager runtime verification pending.
66
+ Android collapsible pagers consume header travel before the primary RecyclerView
67
+ receives content-scroll deltas. A leaf threshold observer needs both distances.
68
+ The coordinator publishes a non-negative physical-pixel contribution under its
69
+ own weak owner identity on the active primary RecyclerView. Nested coordinators
70
+ contribute independently; summing their values gives ancestor-consumed travel.
71
+
72
+ The native View tag named `onekey_native_scroll_coordinator_consumed_offsets`
73
+ stores a WeakHashMap with owner-view keys and integer pixel values. The optional
74
+ `onekey_native_scroll_coordinator_offset_listener` tag holds a Runnable owned by
75
+ the leaf. The publisher updates only its own entry and invokes the listener only
76
+ when that entry changes. An absent map/listener means zero/no observer.
77
+ This protocol depends only on Android View/resources and Java collections; the
78
+ pager MUST NOT depend on NativeList or infer Home/business semantics.
79
+
80
+ On switching the primary child, restoring insets, or detaching, the coordinator
81
+ removes only its own contribution. It MUST NOT retain an old RecyclerView or
82
+ another coordinator. Zero contributions may remain while the owner is attached;
83
+ weak keys prevent abandoned owners from becoming resource roots. The leaf owns
84
+ listener registration and cleanup. All operations occur on the UI thread; there
85
+ are no JavaScript scroll-frame callbacks in this protocol.
86
+
87
+ A new child receives current header travel before threshold evaluation. Native
88
+ list offsets remain local to the leaf, while consumers of this protocol add all
89
+ ancestor contributions exactly once. Programmatic scrolling/header restoration
90
+ must publish the resulting header offset through the same path.
91
+
92
+ ## Acceptance
93
+
94
+ The focused JVM helper test covers independent ancestor contributions and removal.
95
+ Required Android runtime cases are one/nested collapsible pagers, header-only
96
+ scrolling, reversal to expanded top, switching pages, native child replacement,
97
+ and detach/remount without stale offsets. iOS uses its existing normalized
98
+ UIScrollView offset and requires no equivalent tag protocol. Source/helper
99
+ checks do not establish the Android runtime acceptance above.
100
+
101
+
102
+ ## Android Fabric NativeScroller content insets
103
+
104
+ A ReactScrollView's content root is laid out by Fabric. Native `setPadding(top)`
105
+ alone MUST NOT be used to reserve a collapsible header: ReactScrollView does not
106
+ perform the platform ScrollView child layout, so that leaves rows under the
107
+ header. For the supported RN 0.86 runtime, the coordinator uses
108
+ `setScrollAwayPaddingEnabledUnstable` to offset the content root and synchronize
109
+ Fabric content-origin/culling state. The equivalent inset is added to the native
110
+ scroll range at the bottom; it is not added a second time at the top. Ordinary
111
+ Android ScrollView and RecyclerView retain their existing inset paths.
112
+
113
+ Each coordinator replaces only its previous header-plus-sticky contribution.
114
+ Measured-height changes do not accumulate insets; detach restores the remaining
115
+ ScrollAway state and caller padding, including another native owner's inset.
116
+ Content-root replacement reapplies the native offset. No Home layout values,
117
+ new React props, or per-frame JS updates are introduced; iOS/Web are unchanged.
118
+
119
+ The focused JVM policy regression covers an existing inset, repeated application,
120
+ measured-header resize, nested-owner detach, and Fabric state reset. The API call
121
+ is compile-checked against the installed React Android 0.86.2 artifact. Runtime
122
+ acceptance requires first content below the expanded sticky tabs, collapse and
123
+ expand without overlap, retained-page switching, header height changes, and
124
+ reaching the actual final content without an extra header-sized blank footer.
125
+
126
+ RN's ScrollAway setter (including native state replay when Fabric culling flags
127
+ are enabled) resets physical padding to `(0, 0, 0, scrollAwayTop + scrollAwayBottom)`.
128
+ While owning an inset, the coordinator recognizes that exact native reset and
129
+ retains saved caller padding; other physical-padding updates remain observable.
130
+ The focused policy regression includes nonzero caller left/right/bottom padding,
131
+ nonzero prior native insets, explicit caller padding changes/clear, and initial
132
+ discovery before ownership. Current Home zero-padding behavior is unchanged by
133
+ this ownership guard.
@@ -9,4 +9,12 @@ FOUNDATION_EXPORT void RNCPruneReleasedPageStates(
9
9
  NSArray<NSString *> *validPageKeys
10
10
  );
11
11
 
12
+ FOUNDATION_EXPORT CGFloat RNCLimitRestoredScrollOffset(
13
+ CGFloat offset,
14
+ CGFloat contentHeight,
15
+ CGFloat viewportHeight,
16
+ CGFloat topInset,
17
+ CGFloat bottomInset
18
+ );
19
+
12
20
  NS_ASSUME_NONNULL_END
@@ -1,5 +1,18 @@
1
1
  #import "RNCCollapsiblePagerReleasedStatePolicy.h"
2
2
 
3
+ CGFloat RNCLimitRestoredScrollOffset(
4
+ CGFloat offset,
5
+ CGFloat contentHeight,
6
+ CGFloat viewportHeight,
7
+ CGFloat topInset,
8
+ CGFloat bottomInset
9
+ )
10
+ {
11
+ if (viewportHeight <= 0) return offset;
12
+ CGFloat maximum = MAX(-topInset, contentHeight - viewportHeight + bottomInset);
13
+ return MIN(offset, maximum);
14
+ }
15
+
3
16
  void RNCPruneReleasedPageStates(
4
17
  NSMutableDictionary *releasedStates,
5
18
  NSArray<NSString *> *validPageKeys
@@ -11,6 +11,7 @@
11
11
  #import "React/RCTScrollViewComponentView.h"
12
12
  #import "React/RCTSurfaceTouchHandler.h"
13
13
  #import "React/RCTTouchHandler.h"
14
+ #import <objc/runtime.h>
14
15
 
15
16
  // OneKey patch: deduplicate structural mutation parents before scanning the page.
16
17
  #include <unordered_set>
@@ -18,6 +19,116 @@
18
19
  using namespace facebook::react;
19
20
 
20
21
  static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerContentOffsetContext;
22
+ static void *RNCRefreshControlLayerContext = &RNCRefreshControlLayerContext;
23
+ static void *RNCScrollRefreshControlContext = &RNCScrollRefreshControlContext;
24
+ static char RNCRefreshControlOffsetKey;
25
+
26
+ @interface RNCRefreshControlOffsetState : NSObject
27
+ + (void)setOffset:(CGFloat)offset forControl:(UIRefreshControl *)control owner:(id)owner;
28
+ + (void)removeControl:(UIRefreshControl *)control owner:(id)owner;
29
+ @end
30
+
31
+ @implementation RNCRefreshControlOffsetState {
32
+ __weak UIRefreshControl *_control;
33
+ NSMapTable<id, NSNumber *> *_offsets;
34
+ CATransform3D _baselineTransform;
35
+ CATransform3D _appliedTransform;
36
+ BOOL _originalMasksToBounds;
37
+ BOOL _applying;
38
+ BOOL _observing;
39
+ }
40
+
41
+ - (instancetype)initWithControl:(UIRefreshControl *)control
42
+ {
43
+ if ((self = [super init])) {
44
+ _control = control;
45
+ _baselineTransform = control.layer.sublayerTransform;
46
+ _originalMasksToBounds = control.layer.masksToBounds;
47
+ _offsets = [NSMapTable weakToStrongObjectsMapTable];
48
+ [control.layer addObserver:self forKeyPath:@"sublayerTransform"
49
+ options:NSKeyValueObservingOptionNew
50
+ context:RNCRefreshControlLayerContext];
51
+ [control.layer addObserver:self forKeyPath:@"masksToBounds"
52
+ options:NSKeyValueObservingOptionNew
53
+ context:RNCRefreshControlLayerContext];
54
+ _observing = YES;
55
+ }
56
+ return self;
57
+ }
58
+
59
+ - (void)apply
60
+ {
61
+ UIRefreshControl *control = _control;
62
+ if (control == nil) return;
63
+ CGFloat offset = 0;
64
+ for (NSNumber *value in _offsets.objectEnumerator) offset += value.doubleValue;
65
+ CATransform3D transform = CATransform3DConcat(
66
+ _baselineTransform, CATransform3DMakeTranslation(0, -offset, 0));
67
+ _appliedTransform = transform;
68
+ _applying = YES;
69
+ // Visual placement must not change UIKit's refresh geometry or RN's bounds
70
+ // origin (progressViewOffset). Allow the shifted indicator outside its host.
71
+ [CATransaction begin];
72
+ [CATransaction setDisableActions:YES];
73
+ control.layer.masksToBounds = _offsets.count == 0 ? _originalMasksToBounds : NO;
74
+ if (!CATransform3DEqualToTransform(control.layer.sublayerTransform, transform)) {
75
+ control.layer.sublayerTransform = transform;
76
+ }
77
+ [CATransaction commit];
78
+ _applying = NO;
79
+ }
80
+
81
+ + (void)setOffset:(CGFloat)offset forControl:(UIRefreshControl *)control owner:(id)owner
82
+ {
83
+ if (control == nil) return;
84
+ RNCRefreshControlOffsetState *state = objc_getAssociatedObject(control, &RNCRefreshControlOffsetKey);
85
+ if (state == nil) {
86
+ state = [[self alloc] initWithControl:control];
87
+ objc_setAssociatedObject(control, &RNCRefreshControlOffsetKey, state, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
88
+ }
89
+ [state->_offsets setObject:@(offset) forKey:owner];
90
+ [state apply];
91
+ }
92
+
93
+ + (void)removeControl:(UIRefreshControl *)control owner:(id)owner
94
+ {
95
+ if (control == nil) return;
96
+ RNCRefreshControlOffsetState *state = objc_getAssociatedObject(control, &RNCRefreshControlOffsetKey);
97
+ if (state == nil) return;
98
+ [state->_offsets removeObjectForKey:owner];
99
+ [state apply];
100
+ if (state->_offsets.count == 0) {
101
+ [control.layer removeObserver:state forKeyPath:@"sublayerTransform" context:RNCRefreshControlLayerContext];
102
+ [control.layer removeObserver:state forKeyPath:@"masksToBounds" context:RNCRefreshControlLayerContext];
103
+ state->_observing = NO;
104
+ objc_setAssociatedObject(control, &RNCRefreshControlOffsetKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
105
+ }
106
+ }
107
+
108
+ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
109
+ change:(NSDictionary<NSKeyValueChangeKey, id> *)change context:(void *)context
110
+ {
111
+ if (context == RNCRefreshControlLayerContext) {
112
+ if (_applying) return;
113
+ if ([keyPath isEqualToString:@"sublayerTransform"]) {
114
+ CATransform3D next = [change[NSKeyValueChangeNewKey] CATransform3DValue];
115
+ if (CATransform3DEqualToTransform(next, _appliedTransform)) return;
116
+ _baselineTransform = next;
117
+ }
118
+ [self apply];
119
+ return;
120
+ }
121
+ [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
122
+ }
123
+
124
+ - (void)dealloc
125
+ {
126
+ if (_observing && _control != nil) {
127
+ [_control.layer removeObserver:self forKeyPath:@"sublayerTransform" context:RNCRefreshControlLayerContext];
128
+ [_control.layer removeObserver:self forKeyPath:@"masksToBounds" context:RNCRefreshControlLayerContext];
129
+ }
130
+ }
131
+ @end
21
132
  static NSString *const RNCCollapsiblePagerNativeScrollerIDPrefix =
22
133
  @"rnc-collapsible-pager-native-scroller:";
23
134
 
@@ -973,6 +1084,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
973
1084
  - (void)preparePageForHorizontalTransitionAtIndex:(NSInteger)pageIndex;
974
1085
  - (void)updateSharedHeaderPressCancellationGesture;
975
1086
  - (void)detachSharedHeaderPressCancellationGesture;
1087
+ - (void)updateRefreshControlForScrollView:(UIScrollView *)scrollView;
1088
+ - (void)releaseRefreshControlForScrollView:(UIScrollView *)scrollView;
976
1089
  - (void)sharedHeaderPressCancellationGestureChanged:(UIPanGestureRecognizer *)recognizer;
977
1090
  - (void)outerPagerGestureChanged:(UIPanGestureRecognizer *)recognizer;
978
1091
  - (void)verticalPagerGuardGestureChanged:(UIPanGestureRecognizer *)recognizer;
@@ -1023,6 +1136,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1023
1136
  NSString *_retainedPages;
1024
1137
  NSMutableDictionary<NSString *, NSNumber *> *_pageOffsets;
1025
1138
  NSMapTable<UIScrollView *, NSValue *> *_originalInsets;
1139
+ NSMapTable<UIScrollView *, UIRefreshControl *> *_managedRefreshControls;
1140
+ NSHashTable<UIScrollView *> *_refreshControlObservedScrollViews;
1026
1141
  NSMapTable<UIScrollView *, NSNumber *> *_appliedTopInsets;
1027
1142
  NSMapTable<UIScrollView *, NSValue *> *_originalIndicatorInsets;
1028
1143
  NSMapTable<UIScrollView *, NSNumber *> *_originalAlwaysBounceVertical;
@@ -1066,6 +1181,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1066
1181
  _pageOffsets = [NSMutableDictionary new];
1067
1182
  _releasedPageScrollStates = [NSMutableDictionary new];
1068
1183
  _originalInsets = [NSMapTable weakToStrongObjectsMapTable];
1184
+ _managedRefreshControls = [NSMapTable weakToWeakObjectsMapTable];
1185
+ _refreshControlObservedScrollViews = [NSHashTable weakObjectsHashTable];
1069
1186
  _appliedTopInsets = [NSMapTable weakToStrongObjectsMapTable];
1070
1187
  _originalIndicatorInsets = [NSMapTable weakToStrongObjectsMapTable];
1071
1188
  _originalAlwaysBounceVertical = [NSMapTable weakToStrongObjectsMapTable];
@@ -1145,6 +1262,16 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1145
1262
 
1146
1263
  - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
1147
1264
  {
1265
+ // Shared headers can sit above their clipped viewport after collapsing.
1266
+ // Forward only viewport touches, so invisible headers cannot cover siblings.
1267
+ if (!self.userInteractionEnabled || self.hidden || self.alpha < 0.01 ||
1268
+ ![self pointInside:point withEvent:event]) return nil;
1269
+ const auto &viewProps =
1270
+ *std::static_pointer_cast<const RNCCollapsiblePagerViewProps>(_props);
1271
+ if (viewProps.pointerEvents == PointerEventsMode::None ||
1272
+ viewProps.pointerEvents == PointerEventsMode::BoxOnly) {
1273
+ return [super hitTest:point withEvent:event];
1274
+ }
1148
1275
  if (_nativeSmoothHeaderScrollEnabled && !_isBeingRecycled &&
1149
1276
  _sharedHeaderHostView.superview != _containerView) {
1150
1277
  CGPoint headerPoint = [_sharedHeaderHostView convertPoint:point fromView:self];
@@ -1191,6 +1318,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1191
1318
 
1192
1319
  - (void)dealloc
1193
1320
  {
1321
+ for (UIScrollView *scrollView in _refreshControlObservedScrollViews.allObjects) {
1322
+ [self releaseRefreshControlForScrollView:scrollView];
1323
+ }
1194
1324
  [self detachSharedHeaderPressCancellationGesture];
1195
1325
  }
1196
1326
 
@@ -1729,6 +1859,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1729
1859
  [self detachSharedHeaderPressCancellationGesture];
1730
1860
  [self detachScrollObserver];
1731
1861
  [self restoreSharedHeadersToContainer];
1862
+ for (UIScrollView *scrollView in _refreshControlObservedScrollViews.allObjects) {
1863
+ [self releaseRefreshControlForScrollView:scrollView];
1864
+ }
1732
1865
  for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
1733
1866
  NSValue *value = [_originalInsets objectForKey:scrollView];
1734
1867
  if (value != nil) {
@@ -2418,10 +2551,44 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2418
2551
  }
2419
2552
  }
2420
2553
 
2554
+ - (void)updateRefreshControlForScrollView:(UIScrollView *)scrollView
2555
+ {
2556
+ if (!_nativeSmoothHeaderScrollEnabled || _isBeingRecycled) {
2557
+ [self releaseRefreshControlForScrollView:scrollView];
2558
+ return;
2559
+ }
2560
+ if (![_refreshControlObservedScrollViews containsObject:scrollView]) {
2561
+ [_refreshControlObservedScrollViews addObject:scrollView];
2562
+ [scrollView addObserver:self forKeyPath:@"refreshControl"
2563
+ options:NSKeyValueObservingOptionNew
2564
+ context:RNCScrollRefreshControlContext];
2565
+ }
2566
+ UIRefreshControl *previous = [_managedRefreshControls objectForKey:scrollView];
2567
+ UIRefreshControl *current = scrollView.refreshControl;
2568
+ if (previous != current) {
2569
+ [RNCRefreshControlOffsetState removeControl:previous owner:self];
2570
+ [_managedRefreshControls removeObjectForKey:scrollView];
2571
+ if (current != nil) [_managedRefreshControls setObject:current forKey:scrollView];
2572
+ }
2573
+ [RNCRefreshControlOffsetState setOffset:_headerHeight + _stickyHeaderHeight
2574
+ forControl:current owner:self];
2575
+ }
2576
+
2577
+ - (void)releaseRefreshControlForScrollView:(UIScrollView *)scrollView
2578
+ {
2579
+ if ([_refreshControlObservedScrollViews containsObject:scrollView]) {
2580
+ [scrollView removeObserver:self forKeyPath:@"refreshControl" context:RNCScrollRefreshControlContext];
2581
+ [_refreshControlObservedScrollViews removeObject:scrollView];
2582
+ }
2583
+ [RNCRefreshControlOffsetState removeControl:[_managedRefreshControls objectForKey:scrollView] owner:self];
2584
+ [_managedRefreshControls removeObjectForKey:scrollView];
2585
+ }
2586
+
2421
2587
  // OneKey patch: release observers, headers and saved state while the scroll view
2422
2588
  // still represents the old list, before Fabric applies its next owner's props.
2423
2589
  - (void)restoreScrollViewState:(UIScrollView *)scrollView resetSavedOffset:(BOOL)resetSavedOffset
2424
2590
  {
2591
+ [self releaseRefreshControlForScrollView:scrollView];
2425
2592
  if (scrollView == _observedScrollView) {
2426
2593
  [self detachScrollObserver];
2427
2594
  if (resetSavedOffset) _pageOffsets[self.currentPageKey] = @(_headerOffset);
@@ -2538,6 +2705,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2538
2705
  // This host already includes the fixed header and safe area in its frame.
2539
2706
  // UIKit automatic adjustment would add them a second time to short pages.
2540
2707
  scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
2708
+ [self updateRefreshControlForScrollView:scrollView];
2541
2709
  CGFloat previousTopInset = scrollView.contentInset.top;
2542
2710
  CGFloat previousLogicalOffset = scrollView.contentOffset.y + previousTopInset;
2543
2711
  NSNumber *appliedTop = [_appliedTopInsets objectForKey:scrollView];
@@ -2580,6 +2748,15 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2580
2748
  }
2581
2749
  targetOffset = restoredLogicalOffset - next.top;
2582
2750
  targetOffset = MAX(targetOffset, -next.top + _headerOffset);
2751
+ // An inactive list may shrink after its offset observer was detached.
2752
+ // Do not overwrite its corrected offset with a now-unreachable cached one.
2753
+ targetOffset = RNCLimitRestoredScrollOffset(
2754
+ targetOffset,
2755
+ scrollView.contentSize.height,
2756
+ scrollView.bounds.size.height,
2757
+ next.top,
2758
+ next.bottom
2759
+ );
2583
2760
  }
2584
2761
  // A bottom-inset change must not cancel UIKit's refresh or bounce settlement.
2585
2762
  if (restore || previousTopInset != next.top) {
@@ -2617,6 +2794,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2617
2794
  change:(NSDictionary<NSKeyValueChangeKey,id> *)change
2618
2795
  context:(void *)context
2619
2796
  {
2797
+ if (context == RNCScrollRefreshControlContext) {
2798
+ [self updateRefreshControlForScrollView:(UIScrollView *)object];
2799
+ return;
2800
+ }
2620
2801
  if (context == RNCCollapsiblePagerContentOffsetContext && object == _observedScrollView) {
2621
2802
  if ([keyPath isEqualToString:@"contentSize"]) {
2622
2803
  NSValue *oldSize = change[NSKeyValueChangeOldKey];
@@ -0,0 +1,80 @@
1
+ #import <XCTest/XCTest.h>
2
+
3
+ #import "RNCCollapsiblePagerViewComponentView.h"
4
+ #import <react/renderer/components/pagerview/Props.h>
5
+
6
+ using namespace facebook::react;
7
+
8
+ @interface RNCCollapsiblePagerHitTestTests : XCTestCase
9
+ @property (nonatomic, strong) UIView *root;
10
+ @property (nonatomic, strong) UIButton *toolbar;
11
+ @property (nonatomic, strong) RNCCollapsiblePagerViewComponentView *pager;
12
+ @property (nonatomic, strong) UIButton *sticky;
13
+ @end
14
+
15
+ @implementation RNCCollapsiblePagerHitTestTests
16
+
17
+ - (void)setPointerEvents:(PointerEventsMode)mode
18
+ {
19
+ auto props = std::make_shared<RNCCollapsiblePagerViewProps>();
20
+ props->nativeSmoothHeaderScrollEnabled = true;
21
+ props->pointerEvents = mode;
22
+ [self.pager updateProps:props oldProps:nullptr];
23
+ }
24
+
25
+ - (void)setUp
26
+ {
27
+ [super setUp];
28
+ self.root = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)];
29
+ self.toolbar = [[UIButton alloc] initWithFrame:CGRectMake(0, 36, 320, 64)];
30
+ [self.root addSubview:self.toolbar];
31
+ self.pager = [[RNCCollapsiblePagerViewComponentView alloc] init];
32
+ [self setPointerEvents:PointerEventsMode::Auto];
33
+ self.pager.frame = CGRectMake(0, 100, 320, 500);
34
+ [self.root addSubview:self.pager];
35
+
36
+ // Model smooth mode's header reparenting and its collapsed, clipped content.
37
+ UIView *scroll = [[UIView alloc] initWithFrame:self.pager.bounds];
38
+ [self.pager addSubview:scroll];
39
+ UIView *header = [self.pager valueForKey:@"sharedHeaderHostView"];
40
+ for (UIView *child in header.subviews.copy) [child removeFromSuperview];
41
+ [scroll addSubview:header];
42
+ header.frame = CGRectMake(0, -64, 320, 108);
43
+ [header addSubview:[[UIButton alloc] initWithFrame:CGRectMake(0, 0, 320, 64)]];
44
+ self.sticky = [[UIButton alloc] initWithFrame:CGRectMake(0, 140, 320, 44)];
45
+ [header addSubview:self.sticky];
46
+ }
47
+
48
+ - (void)testCollapsedHeaderDoesNotInterceptSiblingToolbar
49
+ {
50
+ XCTAssertEqual([self.root hitTest:CGPointMake(20, 80) withEvent:nil], self.toolbar);
51
+ }
52
+
53
+ - (void)testPinnedChildOutsideHeaderHostStillReceivesViewportTouches
54
+ {
55
+ XCTAssertEqual([self.pager hitTest:CGPointMake(20, 90) withEvent:nil], self.sticky);
56
+ [self setPointerEvents:PointerEventsMode::BoxNone];
57
+ XCTAssertEqual([self.pager hitTest:CGPointMake(20, 90) withEvent:nil], self.sticky);
58
+ }
59
+
60
+ - (void)testFabricPointerEventsAreNotBypassed
61
+ {
62
+ [self setPointerEvents:PointerEventsMode::None];
63
+ XCTAssertNil([self.pager hitTest:CGPointMake(20, 90) withEvent:nil]);
64
+ [self setPointerEvents:PointerEventsMode::BoxOnly];
65
+ XCTAssertEqual([self.pager hitTest:CGPointMake(20, 90) withEvent:nil], self.pager);
66
+ }
67
+
68
+ - (void)testNoninteractivePagerDoesNotForwardHeaderTouches
69
+ {
70
+ self.pager.hidden = YES;
71
+ XCTAssertNil([self.pager hitTest:CGPointMake(20, 90) withEvent:nil]);
72
+ self.pager.hidden = NO;
73
+ self.pager.userInteractionEnabled = NO;
74
+ XCTAssertNil([self.pager hitTest:CGPointMake(20, 90) withEvent:nil]);
75
+ self.pager.userInteractionEnabled = YES;
76
+ self.pager.alpha = 0.005;
77
+ XCTAssertNil([self.pager hitTest:CGPointMake(20, 90) withEvent:nil]);
78
+ }
79
+
80
+ @end
@@ -0,0 +1,180 @@
1
+ #import <XCTest/XCTest.h>
2
+ #import "RNCCollapsiblePagerViewComponentView.h"
3
+
4
+ @interface RNCCollapsiblePagerViewComponentView (RefreshControlTesting)
5
+ - (void)updateRefreshControlForScrollView:(UIScrollView *)scrollView;
6
+ - (void)releaseRefreshControlForScrollView:(UIScrollView *)scrollView;
7
+ @end
8
+
9
+ @interface RNCCollapsiblePagerRefreshControlTests : XCTestCase
10
+ @end
11
+
12
+ @implementation RNCCollapsiblePagerRefreshControlTests
13
+
14
+ - (RNCCollapsiblePagerViewComponentView *)pagerWithHeader:(CGFloat)height
15
+ {
16
+ RNCCollapsiblePagerViewComponentView *pager = [RNCCollapsiblePagerViewComponentView new];
17
+ [pager setValue:@YES forKey:@"nativeSmoothHeaderScrollEnabled"];
18
+ [pager setValue:@(height) forKey:@"headerHeight"];
19
+ [pager setValue:@44 forKey:@"stickyHeaderHeight"];
20
+ return pager;
21
+ }
22
+
23
+ - (void)testLateControlAndCallerOffsetUpdatesPreserveBaseline
24
+ {
25
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:64];
26
+ UIScrollView *scroll = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)];
27
+ [pager updateRefreshControlForScrollView:scroll];
28
+ UIRefreshControl *control = [UIRefreshControl new];
29
+ control.bounds = CGRectMake(3, -12, 320, 60);
30
+ scroll.refreshControl = control;
31
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001);
32
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, -12, 0.001);
33
+ [pager updateRefreshControlForScrollView:scroll];
34
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001);
35
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, -12, 0.001);
36
+ control.layer.sublayerTransform = control.layer.sublayerTransform;
37
+ // Also cover an explicit no-op KVO notification, even if CALayer's setter
38
+ // suppresses notifications for equal transforms on the current OS.
39
+ [control.layer willChangeValueForKey:@"sublayerTransform"];
40
+ [control.layer didChangeValueForKey:@"sublayerTransform"];
41
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001);
42
+
43
+ // Fabric's progressViewOffset setter replaces bounds.origin.y directly.
44
+ control.bounds = CGRectMake(3, -20, 320, 60);
45
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001);
46
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, -20, 0.001);
47
+ [pager setValue:@100 forKey:@"headerHeight"];
48
+ [pager updateRefreshControlForScrollView:scroll];
49
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -144, 0.001);
50
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, -20, 0.001);
51
+ [pager releaseRefreshControlForScrollView:scroll];
52
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, -20, 0.001);
53
+ XCTAssertEqualWithAccuracy(control.bounds.origin.x, 3, 0.001);
54
+ }
55
+
56
+ - (void)testUIKitSizeChangesAndRefreshCycleDoNotAccumulateOffset
57
+ {
58
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:64];
59
+ UIScrollView *scroll = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)];
60
+ // UIKit ignores offscreen beginRefreshing calls. Exercise the actual refresh
61
+ // lifecycle in a visible window rather than weakening the state assertion.
62
+ UIWindow *window = [[UIWindow alloc] initWithFrame:scroll.frame];
63
+ UIViewController *controller = [UIViewController new];
64
+ window.rootViewController = controller;
65
+ scroll.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
66
+ [controller.view addSubview:scroll];
67
+ window.hidden = NO;
68
+ [self addTeardownBlock:^{ window.hidden = YES; }];
69
+ UIRefreshControl *control = [UIRefreshControl new];
70
+ scroll.refreshControl = control;
71
+ [window layoutIfNeeded];
72
+ [scroll layoutIfNeeded];
73
+ XCTAssertEqual(control.window, window);
74
+ [pager updateRefreshControlForScrollView:scroll];
75
+ CGRect bounds = control.bounds;
76
+ bounds.size = CGSizeMake(320, 60);
77
+ control.bounds = bounds;
78
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001);
79
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001);
80
+ [control beginRefreshing];
81
+ [control layoutIfNeeded];
82
+ [pager updateRefreshControlForScrollView:scroll];
83
+ XCTAssertTrue(control.isRefreshing);
84
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001);
85
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001);
86
+ [control endRefreshing];
87
+ [control layoutIfNeeded];
88
+ XCTAssertFalse(control.isRefreshing);
89
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001);
90
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001);
91
+ [pager releaseRefreshControlForScrollView:scroll];
92
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001);
93
+ XCTAssertTrue(CATransform3DIsIdentity(control.layer.sublayerTransform));
94
+ }
95
+
96
+ - (void)testReplacementAndNestedOwnerReleaseRestoreOnlyTheirContributions
97
+ {
98
+ RNCCollapsiblePagerViewComponentView *first = [self pagerWithHeader:64];
99
+ RNCCollapsiblePagerViewComponentView *second = [self pagerWithHeader:20];
100
+ UICollectionView *scroll = [[UICollectionView alloc] initWithFrame:CGRectMake(0, 0, 320, 600)
101
+ collectionViewLayout:[UICollectionViewFlowLayout new]];
102
+ UIRefreshControl *original = [UIRefreshControl new];
103
+ scroll.refreshControl = original;
104
+ [first updateRefreshControlForScrollView:scroll];
105
+ [second updateRefreshControlForScrollView:scroll];
106
+ XCTAssertEqualWithAccuracy(original.layer.sublayerTransform.m42, -172, 0.001);
107
+ [first releaseRefreshControlForScrollView:scroll];
108
+ XCTAssertEqualWithAccuracy(original.layer.sublayerTransform.m42, -64, 0.001);
109
+
110
+ UIRefreshControl *replacement = [UIRefreshControl new];
111
+ replacement.bounds = CGRectMake(0, -8, 320, 60);
112
+ scroll.refreshControl = replacement;
113
+ XCTAssertTrue(CATransform3DIsIdentity(original.layer.sublayerTransform));
114
+ XCTAssertEqualWithAccuracy(replacement.layer.sublayerTransform.m42, -64, 0.001);
115
+ XCTAssertEqualWithAccuracy(replacement.bounds.origin.y, -8, 0.001);
116
+ [second prepareForRecycle];
117
+ XCTAssertEqualWithAccuracy(replacement.bounds.origin.y, -8, 0.001);
118
+ XCTAssertTrue(CATransform3DIsIdentity(replacement.layer.sublayerTransform));
119
+ scroll.refreshControl = nil;
120
+ }
121
+
122
+ - (void)testPagerDestructionRestoresControl
123
+ {
124
+ UIScrollView *scroll = [UIScrollView new];
125
+ UIRefreshControl *control = [UIRefreshControl new];
126
+ scroll.refreshControl = control;
127
+ @autoreleasepool {
128
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:64];
129
+ [pager updateRefreshControlForScrollView:scroll];
130
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -108, 0.001);
131
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001);
132
+ }
133
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, 0, 0.001);
134
+ XCTAssertTrue(CATransform3DIsIdentity(control.layer.sublayerTransform));
135
+ }
136
+
137
+ - (void)testCallerOffsetCollisionDoesNotAffectVisualContribution
138
+ {
139
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:56];
140
+ UIScrollView *scroll = [UIScrollView new];
141
+ UIRefreshControl *control = [UIRefreshControl new];
142
+ scroll.refreshControl = control;
143
+ control.bounds = CGRectMake(0, -200, 320, 60);
144
+ [pager updateRefreshControlForScrollView:scroll];
145
+ control.bounds = CGRectMake(0, -100, 320, 60);
146
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, -100, 0.001);
147
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, -100, 0.001);
148
+ [pager releaseRefreshControlForScrollView:scroll];
149
+ XCTAssertEqualWithAccuracy(control.bounds.origin.y, -100, 0.001);
150
+ XCTAssertTrue(CATransform3DIsIdentity(control.layer.sublayerTransform));
151
+ }
152
+
153
+ - (void)testNestedOwnerDestructionPreservesBaselineAndRestoresClipping
154
+ {
155
+ RNCCollapsiblePagerViewComponentView *second = [self pagerWithHeader:20];
156
+ UIScrollView *scroll = [UIScrollView new];
157
+ UIRefreshControl *control = [UIRefreshControl new];
158
+ scroll.refreshControl = control;
159
+ CATransform3D baseline = CATransform3DMakeTranslation(7, 13, 0);
160
+ control.layer.sublayerTransform = baseline;
161
+ control.layer.masksToBounds = YES;
162
+ @autoreleasepool {
163
+ RNCCollapsiblePagerViewComponentView *first = [self pagerWithHeader:64];
164
+ [first updateRefreshControlForScrollView:scroll];
165
+ [second updateRefreshControlForScrollView:scroll];
166
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, 13 - 172, 0.001);
167
+ XCTAssertFalse(control.layer.masksToBounds);
168
+ }
169
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, 13 - 64, 0.001);
170
+ // A UIKit layer reset must not discard the remaining visual contribution.
171
+ control.layer.sublayerTransform = baseline;
172
+ control.layer.masksToBounds = YES;
173
+ XCTAssertEqualWithAccuracy(control.layer.sublayerTransform.m42, 13 - 64, 0.001);
174
+ XCTAssertFalse(control.layer.masksToBounds);
175
+ [second releaseRefreshControlForScrollView:scroll];
176
+ XCTAssertTrue(CATransform3DEqualToTransform(control.layer.sublayerTransform, baseline));
177
+ XCTAssertTrue(control.layer.masksToBounds);
178
+ }
179
+
180
+ @end
@@ -7,6 +7,29 @@
7
7
 
8
8
  @implementation RNCCollapsiblePagerReleasedStatePolicyTests
9
9
 
10
+ - (void)testRestorationAfterInactiveContentShrinkCannotReapplyStaleCache
11
+ {
12
+ // The list already corrected to -44 while inactive, but the pager still
13
+ // retains the old logical offset captured before rows were removed.
14
+ CGFloat savedLogicalOffset = 513.67;
15
+ CGFloat restored = RNCLimitRestoredScrollOffset(
16
+ savedLogicalOffset - 108, 132, 708, 108, 532);
17
+ XCTAssertEqualWithAccuracy(restored, -44, 0.001);
18
+ XCTAssertEqualWithAccuracy(restored + 108, 64, 0.001);
19
+ XCTAssertEqualWithAccuracy(
20
+ RNCLimitRestoredScrollOffset(restored, 132, 708, 108, 532), restored, 0.001);
21
+ }
22
+
23
+ - (void)testRestorationPreservesValidDeepAndNegativeOffsets
24
+ {
25
+ XCTAssertEqualWithAccuracy(
26
+ RNCLimitRestoredScrollOffset(600, 2400, 708, 108, 0), 600, 0.001);
27
+ XCTAssertEqualWithAccuracy(
28
+ RNCLimitRestoredScrollOffset(-160, 132, 708, 108, 532), -160, 0.001);
29
+ XCTAssertEqualWithAccuracy(
30
+ RNCLimitRestoredScrollOffset(600, 0, 0, 108, 0), 600, 0.001);
31
+ }
32
+
10
33
  - (void)testPrunesRemovedKeysAndKeepsStableKeysAcrossReorders
11
34
  {
12
35
  NSObject *alpha = [NSObject new];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-pager-view",
3
- "version": "3.0.157-alpha.254",
3
+ "version": "3.0.157-alpha.256",
4
4
  "description": "React Native wrapper for Android and iOS ViewPager",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/module/index.js",
@@ -21,6 +21,7 @@
21
21
  "lib",
22
22
  "android",
23
23
  "ios",
24
+ "docs",
24
25
  "react-native-pager-view.podspec",
25
26
  "!ios/build",
26
27
  "!android/build",
@@ -66,7 +67,7 @@
66
67
  "typescript": "^5.9.2"
67
68
  },
68
69
  "peerDependencies": {
69
- "@onekeyfe/react-native-native-logger": "3.0.157-alpha.254",
70
+ "@onekeyfe/react-native-native-logger": "3.0.157-alpha.255",
70
71
  "react": "*",
71
72
  "react-native": "*"
72
73
  },