@onekeyfe/react-native-pager-view 6.7.0-19 → 6.7.0-20

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.
@@ -12,6 +12,8 @@ import android.view.View
12
12
  import android.view.ViewGroup
13
13
  import android.widget.HorizontalScrollView
14
14
  import android.widget.TextView
15
+ import androidx.recyclerview.widget.RecyclerView
16
+ import androidx.viewpager2.widget.ViewPager2
15
17
  import com.facebook.react.uimanager.PointerEvents
16
18
  import com.facebook.react.views.view.ReactViewGroup
17
19
  import java.util.concurrent.atomic.AtomicInteger
@@ -244,4 +246,61 @@ class CollapsiblePagerTabReservationTest : InstrumentationTestCase() {
244
246
  instrumentation.waitForIdleSync()
245
247
  }
246
248
  }
249
+
250
+ fun testUnmountWithTransitioningPageHoldersSkipsEmptyRecyclerLayout() {
251
+ val context = instrumentation.targetContext
252
+ val activity = instrumentation.startActivitySync(
253
+ Intent(context, CollapsiblePagerHitTestActivity::class.java)
254
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
255
+ ) as CollapsiblePagerHitTestActivity
256
+ try {
257
+ instrumentation.runOnMainSync {
258
+ val density = context.resources.displayMetrics.density
259
+ fun dp(value: Int) = (value * density).roundToInt()
260
+ val width = dp(320)
261
+ val height = dp(500)
262
+ val host = CollapsiblePagerHost(context)
263
+ activity.setContentView(host)
264
+ val header = View(context)
265
+ val sticky = View(context)
266
+ val page0 = View(context)
267
+ val page1 = View(context)
268
+ host.addReactChild(header, 0)
269
+ host.addReactChild(sticky, 1)
270
+ host.addReactChild(page0, 2)
271
+ host.addReactChild(page1, 3)
272
+ host.measure(
273
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
274
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
275
+ )
276
+ host.layout(0, 0, width, height)
277
+ val pager = (0 until host.childCount).map(host::getChildAt).filterIsInstance<ViewPager2>().first()
278
+ val recycler = pager.getChildAt(0) as RecyclerView
279
+ assertNull(recycler.itemAnimator)
280
+ assertTrue(host.attachedPageCount() > 0)
281
+
282
+ val transitioningHolders = (0 until recycler.childCount).map(recycler::getChildAt)
283
+ transitioningHolders.forEach { recycler.startViewTransition(it) }
284
+
285
+ host.removeReactChild(page1)
286
+ host.removeReactChild(page0)
287
+ host.removeReactChild(sticky)
288
+ host.removeReactChild(header)
289
+
290
+ host.viewTreeObserver.dispatchOnPreDraw()
291
+ host.measure(
292
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
293
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
294
+ )
295
+ host.layout(0, 0, width, height)
296
+ assertTrue(host.attachedPageCount() > 0)
297
+
298
+ transitioningHolders.forEach { recycler.endViewTransition(it) }
299
+ }
300
+ instrumentation.waitForIdleSync()
301
+ } finally {
302
+ instrumentation.runOnMainSync { activity.finish() }
303
+ instrumentation.waitForIdleSync()
304
+ }
305
+ }
247
306
  }
@@ -327,6 +327,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
327
327
  pager.id = View.generateViewId()
328
328
  pager.adapter = adapter
329
329
  pager.isSaveEnabled = false
330
+ (pager.getChildAt(0) as? RecyclerView)?.itemAnimator = null
330
331
  super.addView(
331
332
  pager,
332
333
  LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
@@ -708,7 +709,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
708
709
 
709
710
  private fun layoutPagerIfRequested() {
710
711
  val recycler = pager.getChildAt(0) as? RecyclerView ?: return
711
- if (!recycler.isLayoutRequested || recycler.isLayoutSuppressed || recycler.isComputingLayout || recycler.width <= 0 || recycler.height <= 0) return
712
+ if (
713
+ !shouldDrainCollapsiblePagerLayout(
714
+ recycler.isLayoutRequested,
715
+ recycler.isLayoutSuppressed,
716
+ recycler.isComputingLayout,
717
+ recycler.width,
718
+ recycler.height,
719
+ adapter.itemCount,
720
+ )
721
+ ) return
712
722
  // OneKey patch: a non-animated page command queues RecyclerView layout, but RN
713
723
  // can stop that request after navigation. Drain it before showing the page.
714
724
  recycler.measure(
@@ -732,7 +742,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
732
742
  }
733
743
 
734
744
  private fun isPagerRecyclerLayoutSuppressed(): Boolean =
735
- (pager.getChildAt(0) as? RecyclerView)?.isLayoutSuppressed == true
745
+ shouldSuppressCollapsiblePagerRecyclerLayout(
746
+ (pager.getChildAt(0) as? RecyclerView)?.isLayoutSuppressed == true,
747
+ adapter.itemCount,
748
+ attachedPageCount(),
749
+ )
736
750
 
737
751
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
738
752
  val width = MeasureSpec.getSize(widthMeasureSpec)
@@ -39,3 +39,22 @@ internal class NativeRefreshForegroundAlpha(initialAlpha: Float) {
39
39
  return lastApplied
40
40
  }
41
41
  }
42
+
43
+ // Skip ViewPager2 RecyclerView layout when explicitly suppressed or when all React page
44
+ // children have been removed on unmount while rendered ViewHolder views remain attached.
45
+ internal fun shouldSuppressCollapsiblePagerRecyclerLayout(
46
+ isLayoutSuppressed: Boolean,
47
+ adapterItemCount: Int,
48
+ attachedPageCount: Int,
49
+ ): Boolean = isLayoutSuppressed || (adapterItemCount <= 0 && attachedPageCount > 0)
50
+
51
+ internal fun shouldDrainCollapsiblePagerLayout(
52
+ isLayoutRequested: Boolean,
53
+ isLayoutSuppressed: Boolean,
54
+ isComputingLayout: Boolean,
55
+ width: Int,
56
+ height: Int,
57
+ adapterItemCount: Int,
58
+ ): Boolean = adapterItemCount > 0 && isLayoutRequested && !isLayoutSuppressed &&
59
+ !isComputingLayout && width > 0 && height > 0
60
+
@@ -72,4 +72,44 @@ fun main() {
72
72
  check(nativeRefreshForegroundTop(37 + 230 + 64, 294f) == 37f)
73
73
  check(nativeRefreshForegroundTop(-40 + 294, 294f) == -40f)
74
74
  println("Refresh foreground policy: repeat, owner transfer/release, external alpha, transparent baseline, caller offset, and combined nested inset passed")
75
+
76
+ // Initial mount before any page ViewHolder is attached still measures ViewPager2.
77
+ check(!shouldSuppressCollapsiblePagerRecyclerLayout(isLayoutSuppressed = false, adapterItemCount = 0, attachedPageCount = 0))
78
+ // Unmount removes all adapter pages while rendered ViewHolder views remain attached.
79
+ check(shouldSuppressCollapsiblePagerRecyclerLayout(isLayoutSuppressed = false, adapterItemCount = 0, attachedPageCount = 1))
80
+ check(shouldSuppressCollapsiblePagerRecyclerLayout(isLayoutSuppressed = true, adapterItemCount = 2, attachedPageCount = 1))
81
+ // Re-adding pages resumes ViewPager2 layout.
82
+ check(!shouldSuppressCollapsiblePagerRecyclerLayout(isLayoutSuppressed = false, adapterItemCount = 1, attachedPageCount = 1))
83
+
84
+ check(
85
+ !shouldDrainCollapsiblePagerLayout(
86
+ isLayoutRequested = true,
87
+ isLayoutSuppressed = false,
88
+ isComputingLayout = false,
89
+ width = 1080,
90
+ height = 2400,
91
+ adapterItemCount = 0,
92
+ ),
93
+ )
94
+ check(
95
+ shouldDrainCollapsiblePagerLayout(
96
+ isLayoutRequested = true,
97
+ isLayoutSuppressed = false,
98
+ isComputingLayout = false,
99
+ width = 1080,
100
+ height = 2400,
101
+ adapterItemCount = 2,
102
+ ),
103
+ )
104
+ check(
105
+ !shouldDrainCollapsiblePagerLayout(
106
+ isLayoutRequested = true,
107
+ isLayoutSuppressed = true,
108
+ isComputingLayout = false,
109
+ width = 1080,
110
+ height = 2400,
111
+ adapterItemCount = 2,
112
+ ),
113
+ )
114
+ println("CollapsiblePager unmount layout guard: initial mount, unmount retention, suppression, and re-add passed")
75
115
  }
package/docs/SPEC.md CHANGED
@@ -357,8 +357,19 @@ Status: implemented locally; unreleased. On the Android UI thread, during a
357
357
  screen removal transition,
358
358
  NativeList and CollapsiblePager layout entry points respect the RecyclerView
359
359
  suppression state established by the screen-transition owner. The owner restores
360
- the original state when its last transition ownership ends. This adds no public
361
- API and does not change Web or iOS behavior.
360
+ the original state when its last transition ownership ends. In addition,
361
+ `CollapsiblePagerHost` disables `DefaultItemAnimator` on its internal
362
+ `ViewPager2` `RecyclerView` (`itemAnimator = null`) and skips pre-draw
363
+ (`layoutPagerIfRequested`) and host-driven `ViewPager2` layout passes when all
364
+ React page children have been removed from the adapter (`adapter.itemCount == 0`)
365
+ while previously rendered page `ViewHolder` views remain attached
366
+ (`attachedPageCount() > 0`), matching `PagerViewViewManagerImpl`'s unmount
367
+ guard. Initial empty measurement before any page is attached
368
+ (`attachedPageCount() == 0`) and subsequent page insertions (`adapter.itemCount > 0`)
369
+ still measure and lay out normally. This prevents unmount-time empty-adapter
370
+ layout passes from clearing visible exit frames or recycling attached
371
+ `ViewHolder` views held in `mDisappearingChildren` by `ViewGroup.startViewTransition`.
372
+ This adds no public API and does not change Web or iOS behavior.
362
373
 
363
374
  If Fabric removes the current NativeScroller React child while its origin-frame
364
375
  transition remains active, Android retains the already displayed native extent
@@ -917,6 +928,19 @@ fallback debounce, including replacement-state restoration. Other collection
917
928
  fallbacks keep that safety delay. Caller insets and retained logical offsets
918
929
  remain governed by the existing restoration policy.
919
930
 
931
+ A verified, mounted NativeList with usable vertical bounds is a scroll-owner
932
+ boundary. Its React container slots may contain independent Fabric scroll views,
933
+ NativeScroller views or nested pagers; those descendants MUST NOT replace that
934
+ NativeList as the enclosing page's owner or receive its shared header. Candidate
935
+ priority between sibling page owners remains unchanged, including an explicit
936
+ NativeScroller sibling. Pages without an enclosing NativeList retain nested-pager
937
+ active-owner delegation. This boundary does not change scroll offsets, insets,
938
+ shared-header collapse or per-page body memory.
939
+
940
+ Status: implemented and verified by production-native UIKit regressions on
941
+ iOS 27.1 for footer insertion, explicit-sibling priority and nested-pager
942
+ delegation. Full App and physical-device visual acceptance are reported separately.
943
+
920
944
  When the active scroll owner is removed before its replacement becomes usable,
921
945
  the pager MUST retain bounded recovery (at most 12 retries per recovery round,
922
946
  spaced 100 ms apart). Clearing the completed mount's reattachment flag MUST NOT
@@ -3691,6 +3691,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3691
3691
  resolution.tier = isMountedNativeList
3692
3692
  ? RNCScrollCandidateTierVerifiedNativeList
3693
3693
  : RNCScrollCandidateTierGenericFallback;
3694
+ // React slots inside a verified list may own independent scroll views.
3695
+ if (isMountedNativeList) return resolution;
3694
3696
  }
3695
3697
  }
3696
3698
  }
@@ -1,6 +1,7 @@
1
1
  #import <XCTest/XCTest.h>
2
2
  #import "RNCCollapsiblePagerViewComponentView.h"
3
3
  #import "RNCNativeScrollerComponentView.h"
4
+ #import <React/RCTScrollViewComponentView.h>
4
5
  #import <react/renderer/components/pagerview/Props.h>
5
6
  #import <react/renderer/mounting/MountingTransaction.h>
6
7
 
@@ -13,6 +14,7 @@ using namespace facebook::react;
13
14
  withSurfaceTelemetry:(const SurfaceTelemetry &)telemetry;
14
15
  - (UIScrollView *)findExplicitNativeScrollerInView:(UIView *)view;
15
16
  - (UIScrollView *)findVerticalScrollViewInView:(UIView *)view;
17
+ - (UIScrollView *)verticalScrollViewForPageAtIndex:(NSInteger)pageIndex;
16
18
  - (void)attachScrollObserverForCurrentPage;
17
19
  - (void)attachScrollObserverForCurrentPageAfterTransition:(BOOL)selectionSettled;
18
20
  - (void)detachScrollObserver;
@@ -789,4 +791,85 @@ using namespace facebook::react;
789
791
  XCTAssertNil([pager valueForKey:@"releasedPageScrollStates"][@"page-0"]);
790
792
  }
791
793
 
794
+ - (RCTViewComponentView *)addNativeListToPager:(RNCCollapsiblePagerViewComponentView *)pager
795
+ {
796
+ Class nativeListClass = NSClassFromString(@"HybridNativeListComponent");
797
+ XCTAssertNotNil(nativeListClass, @"The real NativeList test dependency must be linked");
798
+ RCTViewComponentView *component = [[nativeListClass alloc] init];
799
+ component.tag = 92001;
800
+ component.frame = CGRectMake(0, 0, 320, 600);
801
+ component.contentView.frame = component.bounds;
802
+ UIViewController *page = [pager valueForKey:@"pageControllers"][0];
803
+ [page.view addSubview:component];
804
+ [component.contentView layoutIfNeeded];
805
+ return component;
806
+ }
807
+
808
+ - (void)testNativeListFooterScrollViewCannotReplacePageOwner
809
+ {
810
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
811
+ RCTViewComponentView *component = [self addNativeListToPager:pager];
812
+ UICollectionView *list = nil;
813
+ for (UIView *child in component.contentView.subviews) {
814
+ if ([child isKindOfClass:UICollectionView.class]) {
815
+ list = (UICollectionView *)child;
816
+ break;
817
+ }
818
+ }
819
+ XCTAssertTrue([list isKindOfClass:UICollectionView.class]);
820
+ XCTAssertGreaterThan(CGRectGetWidth(list.bounds), 0);
821
+ XCTAssertGreaterThan(CGRectGetHeight(list.bounds), 0);
822
+ XCTAssertEqual(list.delegate, (id)component.contentView);
823
+ XCTAssertTrue([list isDescendantOfView:component.contentView]);
824
+ [pager attachScrollObserverForCurrentPage];
825
+ UIView *header = [pager valueForKey:@"sharedHeaderHostView"];
826
+ XCTAssertNotNil(header);
827
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], list);
828
+ XCTAssertEqual(header.superview, list);
829
+ CGFloat inset = list.contentInset.top;
830
+ CGFloat offset = list.contentOffset.y;
831
+
832
+ RCTScrollViewComponentView *footerList = [RCTScrollViewComponentView new];
833
+ footerList.tag = 92002;
834
+ footerList.frame = CGRectMake(0, 0, 320, 400);
835
+ footerList.scrollView.frame = footerList.bounds;
836
+ footerList.scrollView.contentSize = CGSizeMake(320, 1000);
837
+ footerList.scrollView.alwaysBounceHorizontal = NO;
838
+ for (NSInteger index = 0; index < 6; index++) {
839
+ UIView *slot = index == 2 ? footerList : [UIView new];
840
+ [component mountChildComponentView:(id)slot index:index];
841
+ }
842
+ XCTAssertTrue([footerList isDescendantOfView:list]);
843
+ XCTAssertEqual([pager verticalScrollViewForPageAtIndex:0], list);
844
+ ShadowView inserted;
845
+ inserted.tag = (Tag)footerList.tag;
846
+ MountingTransaction transaction(1, 2,
847
+ {ShadowViewMutation::InsertMutation((Tag)component.tag, inserted, 2)}, {});
848
+ SurfaceTelemetry telemetry;
849
+ [pager mountingTransactionDidMount:transaction withSurfaceTelemetry:telemetry];
850
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], list);
851
+ XCTAssertEqual(header.superview, list);
852
+ XCTAssertEqualWithAccuracy(list.contentInset.top, inset, 0.01);
853
+ XCTAssertEqualWithAccuracy(list.contentOffset.y, offset, 0.01);
854
+ XCTAssertEqualWithAccuracy(footerList.scrollView.contentInset.top, 0, 0.01);
855
+ }
856
+
857
+ - (void)testExplicitScrollerSiblingStillPrecedesNativeList
858
+ {
859
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
860
+ [self addNativeListToPager:pager];
861
+ RNCNativeScrollerComponentView *scroller = [self addScrollerToPager:pager atIndex:0];
862
+ XCTAssertEqual([pager verticalScrollViewForPageAtIndex:0], scroller.scrollView);
863
+ }
864
+
865
+ - (void)testPageWithoutNativeListStillDelegatesToNestedPager
866
+ {
867
+ RNCCollapsiblePagerViewComponentView *outer = [self pagerWithPageCount:1];
868
+ RNCCollapsiblePagerViewComponentView *inner = [self pagerWithPageCount:1];
869
+ RNCNativeScrollerComponentView *scroller = [self addScrollerToPager:inner atIndex:0];
870
+ UIViewController *page = [outer valueForKey:@"pageControllers"][0];
871
+ [page.view addSubview:inner];
872
+ XCTAssertEqual([outer verticalScrollViewForPageAtIndex:0], scroller.scrollView);
873
+ }
874
+
792
875
  @end
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-pager-view",
3
- "version": "6.7.0-19",
3
+ "version": "6.7.0-20",
4
4
  "description": "React Native wrapper for Android and iOS ViewPager",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/module/index.js",
@@ -73,7 +73,7 @@
73
73
  "typescript": "^5.9.2"
74
74
  },
75
75
  "peerDependencies": {
76
- "@onekeyfe/react-native-native-logger": "6.7.0-19",
76
+ "@onekeyfe/react-native-native-logger": "6.7.0-20",
77
77
  "react": "*",
78
78
  "react-native": "*"
79
79
  },
@@ -19,6 +19,7 @@ Pod::Spec.new do |s|
19
19
 
20
20
  s.test_spec "Tests" do |test_spec|
21
21
  test_spec.source_files = "ios/tests/**/*.{m,mm}"
22
+ test_spec.dependency "react-native-native-list"
22
23
  end
23
24
 
24
25
  install_modules_dependencies(s)