@shopify/flash-list 2.2.2 → 2.3.0

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.
Files changed (98) hide show
  1. package/dist/FlashListProps.d.ts +44 -8
  2. package/dist/FlashListProps.d.ts.map +1 -1
  3. package/dist/native/config/PlatformHelper.android.d.ts +10 -0
  4. package/dist/native/config/PlatformHelper.android.d.ts.map +1 -1
  5. package/dist/native/config/PlatformHelper.android.js +7 -0
  6. package/dist/native/config/PlatformHelper.android.js.map +1 -1
  7. package/dist/native/config/PlatformHelper.d.ts +10 -0
  8. package/dist/native/config/PlatformHelper.d.ts.map +1 -1
  9. package/dist/native/config/PlatformHelper.ios.d.ts +10 -0
  10. package/dist/native/config/PlatformHelper.ios.d.ts.map +1 -1
  11. package/dist/native/config/PlatformHelper.ios.js +2 -0
  12. package/dist/native/config/PlatformHelper.ios.js.map +1 -1
  13. package/dist/native/config/PlatformHelper.js +2 -0
  14. package/dist/native/config/PlatformHelper.js.map +1 -1
  15. package/dist/native/config/PlatformHelper.web.d.ts +10 -0
  16. package/dist/native/config/PlatformHelper.web.d.ts.map +1 -1
  17. package/dist/native/config/PlatformHelper.web.js +2 -0
  18. package/dist/native/config/PlatformHelper.web.js.map +1 -1
  19. package/dist/recyclerview/RecyclerView.d.ts.map +1 -1
  20. package/dist/recyclerview/RecyclerView.js +25 -15
  21. package/dist/recyclerview/RecyclerView.js.map +1 -1
  22. package/dist/recyclerview/RecyclerViewManager.d.ts +2 -0
  23. package/dist/recyclerview/RecyclerViewManager.d.ts.map +1 -1
  24. package/dist/recyclerview/RecyclerViewManager.js +20 -15
  25. package/dist/recyclerview/RecyclerViewManager.js.map +1 -1
  26. package/dist/recyclerview/ViewHolder.d.ts +2 -0
  27. package/dist/recyclerview/ViewHolder.d.ts.map +1 -1
  28. package/dist/recyclerview/ViewHolder.js +8 -2
  29. package/dist/recyclerview/ViewHolder.js.map +1 -1
  30. package/dist/recyclerview/ViewHolderCollection.d.ts +4 -0
  31. package/dist/recyclerview/ViewHolderCollection.d.ts.map +1 -1
  32. package/dist/recyclerview/ViewHolderCollection.js +6 -3
  33. package/dist/recyclerview/ViewHolderCollection.js.map +1 -1
  34. package/dist/recyclerview/components/StickyHeaders.d.ts +3 -1
  35. package/dist/recyclerview/components/StickyHeaders.d.ts.map +1 -1
  36. package/dist/recyclerview/components/StickyHeaders.js +3 -2
  37. package/dist/recyclerview/components/StickyHeaders.js.map +1 -1
  38. package/dist/recyclerview/hooks/useBoundDetection.js +1 -1
  39. package/dist/recyclerview/hooks/useBoundDetection.js.map +1 -1
  40. package/dist/recyclerview/hooks/useRecyclerViewController.d.ts.map +1 -1
  41. package/dist/recyclerview/hooks/useRecyclerViewController.js +2 -3
  42. package/dist/recyclerview/hooks/useRecyclerViewController.js.map +1 -1
  43. package/dist/recyclerview/hooks/useSecondaryProps.d.ts +2 -2
  44. package/dist/recyclerview/hooks/useSecondaryProps.d.ts.map +1 -1
  45. package/dist/recyclerview/hooks/useSecondaryProps.js +36 -16
  46. package/dist/recyclerview/hooks/useSecondaryProps.js.map +1 -1
  47. package/dist/recyclerview/layout-managers/GridLayoutManager.d.ts +5 -0
  48. package/dist/recyclerview/layout-managers/GridLayoutManager.d.ts.map +1 -1
  49. package/dist/recyclerview/layout-managers/GridLayoutManager.js +12 -0
  50. package/dist/recyclerview/layout-managers/GridLayoutManager.js.map +1 -1
  51. package/dist/recyclerview/layout-managers/LayoutManager.d.ts +11 -0
  52. package/dist/recyclerview/layout-managers/LayoutManager.d.ts.map +1 -1
  53. package/dist/recyclerview/layout-managers/LayoutManager.js +13 -0
  54. package/dist/recyclerview/layout-managers/LayoutManager.js.map +1 -1
  55. package/dist/recyclerview/layout-managers/MasonryLayoutManager.d.ts.map +1 -1
  56. package/dist/recyclerview/layout-managers/MasonryLayoutManager.js.map +1 -1
  57. package/dist/recyclerview/utils/componentUtils.d.ts +12 -3
  58. package/dist/recyclerview/utils/componentUtils.d.ts.map +1 -1
  59. package/dist/recyclerview/utils/componentUtils.js +16 -3
  60. package/dist/recyclerview/utils/componentUtils.js.map +1 -1
  61. package/dist/recyclerview/utils/getInvertedTransformStyle.d.ts +10 -0
  62. package/dist/recyclerview/utils/getInvertedTransformStyle.d.ts.map +1 -0
  63. package/dist/recyclerview/utils/getInvertedTransformStyle.js +7 -0
  64. package/dist/recyclerview/utils/getInvertedTransformStyle.js.map +1 -0
  65. package/dist/recyclerview/utils/measureLayout.d.ts +9 -5
  66. package/dist/recyclerview/utils/measureLayout.d.ts.map +1 -1
  67. package/dist/recyclerview/utils/measureLayout.js +6 -5
  68. package/dist/recyclerview/utils/measureLayout.js.map +1 -1
  69. package/dist/recyclerview/utils/measureLayout.web.d.ts +6 -2
  70. package/dist/recyclerview/utils/measureLayout.web.d.ts.map +1 -1
  71. package/dist/recyclerview/utils/measureLayout.web.js +1 -3
  72. package/dist/recyclerview/utils/measureLayout.web.js.map +1 -1
  73. package/dist/tsconfig.tsbuildinfo +1 -1
  74. package/dist/utils/AverageWindow.d.ts.map +1 -1
  75. package/dist/utils/AverageWindow.js +2 -3
  76. package/dist/utils/AverageWindow.js.map +1 -1
  77. package/package.json +1 -1
  78. package/src/FlashListProps.ts +53 -3
  79. package/src/native/config/PlatformHelper.android.ts +7 -0
  80. package/src/native/config/PlatformHelper.ios.ts +2 -0
  81. package/src/native/config/PlatformHelper.ts +2 -0
  82. package/src/native/config/PlatformHelper.web.ts +2 -0
  83. package/src/recyclerview/RecyclerView.tsx +26 -11
  84. package/src/recyclerview/RecyclerViewManager.ts +21 -16
  85. package/src/recyclerview/ViewHolder.tsx +11 -1
  86. package/src/recyclerview/ViewHolderCollection.tsx +14 -3
  87. package/src/recyclerview/components/StickyHeaders.tsx +5 -0
  88. package/src/recyclerview/hooks/useBoundDetection.ts +1 -1
  89. package/src/recyclerview/hooks/useRecyclerViewController.tsx +2 -3
  90. package/src/recyclerview/hooks/useSecondaryProps.tsx +48 -18
  91. package/src/recyclerview/layout-managers/GridLayoutManager.ts +13 -0
  92. package/src/recyclerview/layout-managers/LayoutManager.ts +14 -0
  93. package/src/recyclerview/layout-managers/MasonryLayoutManager.ts +4 -0
  94. package/src/recyclerview/utils/componentUtils.ts +27 -5
  95. package/src/recyclerview/utils/getInvertedTransformStyle.ts +7 -0
  96. package/src/recyclerview/utils/measureLayout.ts +12 -6
  97. package/src/recyclerview/utils/measureLayout.web.ts +7 -4
  98. package/src/utils/AverageWindow.ts +4 -2
@@ -48,6 +48,7 @@ import { CompatScroller } from "./components/CompatScroller";
48
48
  import { useBoundDetection } from "./hooks/useBoundDetection";
49
49
  import { adjustOffsetForRTL } from "./utils/adjustOffsetForRTL";
50
50
  import { useSecondaryProps } from "./hooks/useSecondaryProps";
51
+ import { getInvertedTransformStyle } from "./utils/getInvertedTransformStyle";
51
52
  import { StickyHeaders, StickyHeaderRef } from "./components/StickyHeaders";
52
53
  import { ScrollAnchor, ScrollAnchorRef } from "./components/ScrollAnchor";
53
54
  import { useRecyclerViewController } from "./hooks/useRecyclerViewController";
@@ -86,6 +87,7 @@ const RecyclerViewComponent = <T,>(
86
87
  onCommitLayoutEffect,
87
88
  onChangeStickyIndex,
88
89
  stickyHeaderConfig,
90
+ inverted,
89
91
  ...rest
90
92
  } = props;
91
93
 
@@ -100,6 +102,11 @@ const RecyclerViewComponent = <T,>(
100
102
  const stickyHeaderHideRelatedCell =
101
103
  stickyHeaderConfig?.hideRelatedCell ?? false;
102
104
 
105
+ // Compute the inverted transform style based on platform and orientation
106
+ const invertedTransformStyle = inverted
107
+ ? getInvertedTransformStyle(horizontal)
108
+ : undefined;
109
+
103
110
  // Core refs for managing scroll view, internal view, and child container
104
111
  const scrollViewRef = useRef<CompatScroller>(null);
105
112
  const internalViewRef = useRef<CompatView>(null);
@@ -157,29 +164,28 @@ const RecyclerViewComponent = <T,>(
157
164
  */
158
165
  useLayoutEffect(() => {
159
166
  if (internalViewRef.current && firstChildViewRef.current) {
160
- // Measure the outer and inner container layouts
161
- const outerViewLayout = measureParentSize(internalViewRef.current);
167
+ // Measure the outer container size and inner container layout
168
+ const outerViewSize = measureParentSize(internalViewRef.current);
162
169
  const firstChildViewLayout = measureFirstChildLayout(
163
170
  firstChildViewRef.current,
164
171
  internalViewRef.current
165
172
  );
166
173
 
167
- containerViewSizeRef.current = outerViewLayout;
174
+ containerViewSizeRef.current = outerViewSize;
168
175
 
169
- // Calculate offset of first item
176
+ // firstChildViewLayout is already relative to the outer container,
177
+ // so its x/y directly gives the first item offset.
170
178
  const firstItemOffset = horizontal
171
- ? firstChildViewLayout.x - outerViewLayout.x
172
- : firstChildViewLayout.y - outerViewLayout.y;
179
+ ? firstChildViewLayout.x
180
+ : firstChildViewLayout.y;
173
181
 
174
182
  // Update the RecyclerView manager with window dimensions
175
183
  recyclerViewManager.updateLayoutParams(
176
184
  {
177
- width: horizontal
178
- ? outerViewLayout.width
179
- : firstChildViewLayout.width,
185
+ width: horizontal ? outerViewSize.width : firstChildViewLayout.width,
180
186
  height: horizontal
181
187
  ? firstChildViewLayout.height
182
- : outerViewLayout.height,
188
+ : outerViewSize.height,
183
189
  },
184
190
  isHorizontalRTL && recyclerViewManager.hasLayout()
185
191
  ? firstItemOffset -
@@ -300,7 +306,11 @@ const RecyclerViewComponent = <T,>(
300
306
  checkBounds();
301
307
 
302
308
  // Record interaction and compute item visibility
303
- recyclerViewManager.recordInteraction();
309
+ // Skip recording interaction during programmatic initial scroll
310
+ // to respect waitForInteraction in viewability config
311
+ if (recyclerViewManager.isInitialScrollComplete) {
312
+ recyclerViewManager.recordInteraction();
313
+ }
304
314
  recyclerViewManager.computeItemViewability();
305
315
 
306
316
  // Call user-provided onScroll handler
@@ -430,6 +440,7 @@ const RecyclerViewComponent = <T,>(
430
440
  stickyHeaderRef={stickyHeaderRef}
431
441
  recyclerViewManager={recyclerViewManager}
432
442
  extraData={extraData}
443
+ inverted={inverted}
433
444
  onChangeStickyIndex={(newStickyHeaderIndex) => {
434
445
  if (stickyHeaderHideRelatedCell) {
435
446
  setCurrentStickyIndex(newStickyHeaderIndex);
@@ -452,6 +463,7 @@ const RecyclerViewComponent = <T,>(
452
463
  currentStickyIndex,
453
464
  onChangeStickyIndex,
454
465
  stickyHeaderHideRelatedCell,
466
+ inverted,
455
467
  ]);
456
468
 
457
469
  // Set up scroll event handling with animation support for sticky headers
@@ -523,6 +535,7 @@ const RecyclerViewComponent = <T,>(
523
535
  overflow: "hidden",
524
536
  },
525
537
  style,
538
+ invertedTransformStyle,
526
539
  ]}
527
540
  ref={internalViewRef}
528
541
  collapsable={false}
@@ -612,6 +625,7 @@ const RecyclerViewComponent = <T,>(
612
625
  }}
613
626
  CellRendererComponent={CellRendererComponent}
614
627
  ItemSeparatorComponent={ItemSeparatorComponent}
628
+ isInLastRow={(index) => recyclerViewManager.isInLastRow(index)}
615
629
  getChildContainerLayout={() =>
616
630
  recyclerViewManager.hasLayout()
617
631
  ? recyclerViewManager.getChildContainerDimensions()
@@ -619,6 +633,7 @@ const RecyclerViewComponent = <T,>(
619
633
  }
620
634
  currentStickyIndex={currentStickyIndex}
621
635
  hideStickyHeaderRelatedCell={stickyHeaderHideRelatedCell}
636
+ inverted={inverted}
622
637
  />
623
638
  {renderEmpty}
624
639
  {renderFooter}
@@ -39,6 +39,7 @@ export class RecyclerViewManager<T> {
39
39
  public firstItemOffset = 0;
40
40
  public ignoreScrollEvents = false;
41
41
  public isFirstPaintOnUiComplete = false;
42
+ public isInitialScrollComplete = false;
42
43
 
43
44
  public get animationOptimizationsEnabled() {
44
45
  return this._animationOptimizationsEnabled;
@@ -71,6 +72,7 @@ export class RecyclerViewManager<T> {
71
72
  props.maxItemsInRecyclePool
72
73
  );
73
74
  this.itemViewabilityManager = new ViewabilityManager<T>(this as any);
75
+ this.isInitialScrollComplete = this.getInitialScrollIndex() === undefined;
74
76
  this.checkPropsAndWarn();
75
77
  }
76
78
 
@@ -151,6 +153,10 @@ export class RecyclerViewManager<T> {
151
153
  return undefined;
152
154
  }
153
155
 
156
+ isInLastRow(index: number): boolean {
157
+ return this.layoutManager?.isInLastRow(index) ?? false;
158
+ }
159
+
154
160
  // Doesn't include header / foot etc
155
161
  getChildContainerDimensions() {
156
162
  if (!this.layoutManager) {
@@ -376,27 +382,26 @@ export class RecyclerViewManager<T> {
376
382
  }
377
383
 
378
384
  const initialScrollIndex = this.getInitialScrollIndex();
379
- const initialItemLayout = this.layoutManager?.getLayout(
380
- initialScrollIndex ?? 0
381
- );
382
- const initialItemOffset = this.propsRef.horizontal
383
- ? initialItemLayout?.x
384
- : initialItemLayout?.y;
385
385
 
386
386
  if (initialScrollIndex !== undefined) {
387
- // console.log(
388
- // "initialItemOffset",
389
- // initialScrollIndex,
390
- // initialItemOffset,
391
- // this.firstItemOffset
392
- // );
387
+ // Recompute layouts first, then read the offset. The recompute may
388
+ // re-estimate unmeasured items with an updated average height, changing
389
+ // the target item's position. Reading before recompute would capture a
390
+ // stale offset, causing the wrong items to be rendered.
393
391
  this.layoutManager.recomputeLayouts(0, initialScrollIndex);
394
- this.engagedIndicesTracker.scrollOffset =
395
- initialItemOffset ?? 0 + this.firstItemOffset;
392
+ const initialItemLayout =
393
+ this.layoutManager.getLayout(initialScrollIndex);
394
+ const initialItemOffset = this.propsRef.horizontal
395
+ ? initialItemLayout.x
396
+ : initialItemLayout.y;
397
+ this.engagedIndicesTracker.scrollOffset = initialItemOffset;
396
398
  } else {
397
- // console.log("initialItemOffset", initialItemOffset, this.firstItemOffset);
399
+ const initialItemLayout = this.layoutManager.getLayout(0);
400
+ const initialItemOffset = this.propsRef.horizontal
401
+ ? initialItemLayout.x
402
+ : initialItemLayout.y;
398
403
  this.engagedIndicesTracker.scrollOffset =
399
- (initialItemOffset ?? 0) - this.firstItemOffset;
404
+ initialItemOffset - this.firstItemOffset;
400
405
  }
401
406
  }
402
407
 
@@ -17,6 +17,7 @@ import { FlashListProps, RenderTarget } from "../FlashListProps";
17
17
 
18
18
  import { RVDimension, RVLayout } from "./layout-managers/LayoutManager";
19
19
  import { CompatView } from "./components/CompatView";
20
+ import { getInvertedTransformStyle } from "./utils/getInvertedTransformStyle";
20
21
 
21
22
  /**
22
23
  * Props interface for the ViewHolder component
@@ -49,6 +50,8 @@ export interface ViewHolderProps<TItem> {
49
50
  onSizeChanged?: (index: number, size: RVDimension) => void;
50
51
  /** Whether this item should be hidden (likely because it is associated with the active sticky header) */
51
52
  hidden: boolean;
53
+ /** Whether the list is inverted */
54
+ inverted?: FlashListProps<TItem>["inverted"];
52
55
  }
53
56
 
54
57
  /**
@@ -72,6 +75,7 @@ const ViewHolderInternal = <TItem,>(props: ViewHolderProps<TItem>) => {
72
75
  trailingItem,
73
76
  horizontal,
74
77
  hidden,
78
+ inverted,
75
79
  } = props;
76
80
 
77
81
  useLayoutEffect(() => {
@@ -105,6 +109,10 @@ const ViewHolderInternal = <TItem,>(props: ViewHolderProps<TItem>) => {
105
109
  // eslint-disable-next-line react-hooks/exhaustive-deps
106
110
  }, [item, extraData, target, renderItem]);
107
111
 
112
+ const invertedTransformStyle = inverted
113
+ ? getInvertedTransformStyle(horizontal)
114
+ : undefined;
115
+
108
116
  const style = {
109
117
  flexDirection: horizontal ? "row" : "column",
110
118
  position: target === "StickyHeader" ? "relative" : "absolute",
@@ -117,6 +125,7 @@ const ViewHolderInternal = <TItem,>(props: ViewHolderProps<TItem>) => {
117
125
  left: layout.x,
118
126
  top: layout.y,
119
127
  opacity: hidden ? 0 : 1,
128
+ ...invertedTransformStyle,
120
129
  } as const;
121
130
 
122
131
  // TODO: Fix this type issue
@@ -157,7 +166,8 @@ export const ViewHolder = React.memo(
157
166
  prevProps.ItemSeparatorComponent === nextProps.ItemSeparatorComponent &&
158
167
  prevProps.trailingItem === nextProps.trailingItem &&
159
168
  prevProps.horizontal === nextProps.horizontal &&
160
- prevProps.hidden === nextProps.hidden
169
+ prevProps.hidden === nextProps.hidden &&
170
+ prevProps.inverted === nextProps.inverted
161
171
  );
162
172
  }
163
173
  );
@@ -54,6 +54,10 @@ export interface ViewHolderCollectionProps<TItem> {
54
54
  currentStickyIndex: number;
55
55
  /** Whether the cell associated with an active sticky header is hidden */
56
56
  hideStickyHeaderRelatedCell: boolean;
57
+ /** Returns whether the item at the given index is in the last row of the layout */
58
+ isInLastRow: (index: number) => boolean;
59
+ /** Whether the list is inverted */
60
+ inverted: FlashListProps<TItem>["inverted"];
57
61
  }
58
62
 
59
63
  /**
@@ -90,6 +94,8 @@ export const ViewHolderCollection = <TItem,>(
90
94
  getAdjustmentMargin,
91
95
  currentStickyIndex,
92
96
  hideStickyHeaderRelatedCell,
97
+ isInLastRow,
98
+ inverted,
93
99
  } = props;
94
100
 
95
101
  const [renderId, setRenderId] = React.useState(0);
@@ -171,9 +177,13 @@ export const ViewHolderCollection = <TItem,>(
171
177
  hasData &&
172
178
  Array.from(renderStack.entries(), ([reactKey, { index }]) => {
173
179
  const item = data[index];
174
- const trailingItem = ItemSeparatorComponent
175
- ? data[index + 1]
176
- : undefined;
180
+ // Suppress separators for items in the last row to prevent
181
+ // height mismatch. The last data item has no separator (no
182
+ // trailingItem), so all items sharing its row must match.
183
+ const trailingItem =
184
+ ItemSeparatorComponent && !isInLastRow(index)
185
+ ? data[index + 1]
186
+ : undefined;
177
187
 
178
188
  return (
179
189
  <ViewHolder
@@ -195,6 +205,7 @@ export const ViewHolderCollection = <TItem,>(
195
205
  hidden={
196
206
  hideStickyHeaderRelatedCell && currentStickyIndex === index
197
207
  }
208
+ inverted={inverted}
198
209
  />
199
210
  );
200
211
  })}
@@ -44,6 +44,8 @@ export interface StickyHeaderProps<TItem> {
44
44
  recyclerViewManager: RecyclerViewManager<TItem>;
45
45
  /** Additional data to trigger re-renders */
46
46
  extraData: FlashListProps<TItem>["extraData"];
47
+ /** Whether the list is inverted */
48
+ inverted: FlashListProps<TItem>["inverted"];
47
49
  }
48
50
 
49
51
  /**
@@ -69,6 +71,7 @@ export const StickyHeaders = <TItem,>({
69
71
  data,
70
72
  extraData,
71
73
  onChangeStickyIndex,
74
+ inverted,
72
75
  }: StickyHeaderProps<TItem>) => {
73
76
  const [stickyHeaderState, setStickyHeaderState] = useState<StickyHeaderState>(
74
77
  {
@@ -214,6 +217,7 @@ export const StickyHeaders = <TItem,>({
214
217
  trailingItem={null}
215
218
  target="StickyHeader"
216
219
  hidden={false}
220
+ inverted={inverted}
217
221
  />
218
222
  ) : null}
219
223
  </CompatAnimatedView>
@@ -227,6 +231,7 @@ export const StickyHeaders = <TItem,>({
227
231
  refHolder,
228
232
  extraData,
229
233
  stickyHeaderOffset,
234
+ inverted,
230
235
  ]);
231
236
 
232
237
  if (PlatformConfig.isRN083OrAbove && currentStickyIndex === -1) {
@@ -140,7 +140,7 @@ export function useBoundDetection<T>(
140
140
  recyclerViewManager.props.maintainVisibleContentPosition
141
141
  ?.animateAutoScrollToBottom ?? true;
142
142
  scrollViewRef.current?.scrollToEnd({
143
- animated: shouldAnimate,
143
+ animated: shouldAnimate && !recyclerViewManager.ignoreScrollEvents,
144
144
  });
145
145
  });
146
146
  }
@@ -51,7 +51,6 @@ export function useRecyclerViewController<T>(
51
51
  const isUnmounted = useUnmountFlag();
52
52
  const [_, setRenderId] = useState(0);
53
53
  const pauseOffsetCorrection = useRef(false);
54
- const initialScrollCompletedRef = useRef(false);
55
54
  const lastDataLengthRef = useRef(recyclerViewManager.getDataLength());
56
55
  const { setTimeout } = useUnmountAwareTimeout();
57
56
 
@@ -573,12 +572,12 @@ export function useRecyclerViewController<T>(
573
572
  if (
574
573
  initialScrollIndex >= 0 &&
575
574
  initialScrollIndex < dataLength &&
576
- !initialScrollCompletedRef.current &&
575
+ !recyclerViewManager.isInitialScrollComplete &&
577
576
  recyclerViewManager.getIsFirstLayoutComplete()
578
577
  ) {
579
578
  // Use setTimeout to ensure that we keep trying to scroll on first few renders
580
579
  setTimeout(() => {
581
- initialScrollCompletedRef.current = true;
580
+ recyclerViewManager.isInitialScrollComplete = true;
582
581
  pauseOffsetCorrection.current = false;
583
582
  }, 100);
584
583
 
@@ -2,9 +2,10 @@ import { Animated, RefreshControl } from "react-native";
2
2
  import React, { useMemo } from "react";
3
3
 
4
4
  import { RecyclerViewProps } from "../RecyclerViewProps";
5
- import { getValidComponent } from "../utils/componentUtils";
5
+ import { getValidComponent, isComponentClass } from "../utils/componentUtils";
6
6
  import { CompatView } from "../components/CompatView";
7
7
  import { CompatAnimatedScroller } from "../components/CompatScroller";
8
+ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle";
8
9
 
9
10
  /**
10
11
  * Hook that manages secondary props and components for the RecyclerView.
@@ -30,6 +31,7 @@ export function useSecondaryProps<T>(props: RecyclerViewProps<T>) {
30
31
  ListFooterComponent,
31
32
  ListFooterComponentStyle,
32
33
  ListEmptyComponent,
34
+ ListEmptyComponentStyle,
33
35
  renderScrollComponent,
34
36
  refreshing,
35
37
  progressViewOffset,
@@ -37,8 +39,14 @@ export function useSecondaryProps<T>(props: RecyclerViewProps<T>) {
37
39
  data,
38
40
  refreshControl: customRefreshControl,
39
41
  stickyHeaderConfig,
42
+ inverted,
43
+ horizontal,
40
44
  } = props;
41
45
 
46
+ const invertedTransformStyle = inverted
47
+ ? getInvertedTransformStyle(horizontal)
48
+ : undefined;
49
+
42
50
  /**
43
51
  * Creates the refresh control component if onRefresh is provided.
44
52
  */
@@ -65,11 +73,11 @@ export function useSecondaryProps<T>(props: RecyclerViewProps<T>) {
65
73
  return null;
66
74
  }
67
75
  return (
68
- <CompatView style={ListHeaderComponentStyle}>
76
+ <CompatView style={[ListHeaderComponentStyle, invertedTransformStyle]}>
69
77
  {getValidComponent(ListHeaderComponent)}
70
78
  </CompatView>
71
79
  );
72
- }, [ListHeaderComponent, ListHeaderComponentStyle]);
80
+ }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]);
73
81
 
74
82
  /**
75
83
  * Creates the footer component with optional styling.
@@ -79,11 +87,11 @@ export function useSecondaryProps<T>(props: RecyclerViewProps<T>) {
79
87
  return null;
80
88
  }
81
89
  return (
82
- <CompatView style={ListFooterComponentStyle}>
90
+ <CompatView style={[ListFooterComponentStyle, invertedTransformStyle]}>
83
91
  {getValidComponent(ListFooterComponent)}
84
92
  </CompatView>
85
93
  );
86
- }, [ListFooterComponent, ListFooterComponentStyle]);
94
+ }, [ListFooterComponent, ListFooterComponentStyle, invertedTransformStyle]);
87
95
 
88
96
  /**
89
97
  * Creates the empty state component when there's no data.
@@ -93,8 +101,21 @@ export function useSecondaryProps<T>(props: RecyclerViewProps<T>) {
93
101
  if (!ListEmptyComponent || (data && data.length > 0)) {
94
102
  return null;
95
103
  }
96
- return getValidComponent(ListEmptyComponent);
97
- }, [ListEmptyComponent, data]);
104
+ const emptyContent = getValidComponent(ListEmptyComponent);
105
+ if (!invertedTransformStyle && !ListEmptyComponentStyle) {
106
+ return emptyContent;
107
+ }
108
+ return (
109
+ <CompatView style={[ListEmptyComponentStyle, invertedTransformStyle]}>
110
+ {emptyContent}
111
+ </CompatView>
112
+ );
113
+ }, [
114
+ ListEmptyComponent,
115
+ data,
116
+ invertedTransformStyle,
117
+ ListEmptyComponentStyle,
118
+ ]);
98
119
 
99
120
  /**
100
121
  * Creates the sticky header backdrop component.
@@ -105,32 +126,41 @@ export function useSecondaryProps<T>(props: RecyclerViewProps<T>) {
105
126
  }
106
127
  return (
107
128
  <CompatView
108
- style={{
109
- position: "absolute",
110
- inset: 0,
111
- pointerEvents: "none",
112
- }}
129
+ style={[
130
+ {
131
+ position: "absolute",
132
+ inset: 0,
133
+ pointerEvents: "none",
134
+ },
135
+ invertedTransformStyle,
136
+ ]}
113
137
  >
114
138
  {getValidComponent(stickyHeaderConfig?.backdropComponent)}
115
139
  </CompatView>
116
140
  );
117
- }, [stickyHeaderConfig?.backdropComponent]);
141
+ }, [stickyHeaderConfig?.backdropComponent, invertedTransformStyle]);
118
142
 
119
143
  /**
120
144
  * Creates an animated scroll component based on the provided renderScrollComponent.
121
145
  * If no custom component is provided, uses the default CompatAnimatedScroller.
122
146
  */
123
147
  const CompatScrollView = useMemo(() => {
124
- let scrollComponent = CompatAnimatedScroller;
125
- if (typeof renderScrollComponent === "function") {
148
+ let scrollComponent: React.ComponentType<any> = CompatAnimatedScroller;
149
+ if (
150
+ typeof renderScrollComponent === "function" &&
151
+ !isComponentClass(renderScrollComponent)
152
+ ) {
126
153
  // Create a forwarded ref wrapper for the custom scroll component
127
154
  const ForwardedScrollComponent = React.forwardRef((_props, ref) =>
128
- (renderScrollComponent as any)({ ..._props, ref } as any)
155
+ (renderScrollComponent as (...args: unknown[]) => React.ReactNode)({
156
+ ..._props,
157
+ ref,
158
+ })
129
159
  );
130
160
  ForwardedScrollComponent.displayName = "CustomScrollView";
131
- scrollComponent = ForwardedScrollComponent as any;
161
+ scrollComponent = ForwardedScrollComponent as React.ComponentType<any>;
132
162
  } else if (renderScrollComponent) {
133
- scrollComponent = renderScrollComponent;
163
+ scrollComponent = renderScrollComponent as React.ComponentType<any>;
134
164
  }
135
165
  // Wrap the scroll component with Animated.createAnimatedComponent
136
166
  return Animated.createAnimatedComponent(scrollComponent);
@@ -253,4 +253,17 @@ export class RVGridLayoutManagerImpl extends RVLayoutManager {
253
253
  }
254
254
  return Math.max(i, 0);
255
255
  }
256
+
257
+ /**
258
+ * Returns whether the item is in the same row as the last item.
259
+ * Items in the same row share the same y-coordinate, regardless of column spans.
260
+ */
261
+ isInLastRow(index: number): boolean {
262
+ if (this.layouts.length === 0) return false;
263
+ const lastIndex = this.layouts.length - 1;
264
+ return (
265
+ index === lastIndex ||
266
+ this.layouts[index]?.y === this.layouts[lastIndex]?.y
267
+ );
268
+ }
256
269
  }
@@ -264,6 +264,20 @@ export abstract class RVLayoutManager {
264
264
  return this.layouts.length;
265
265
  }
266
266
 
267
+ /**
268
+ * Returns whether the item at the given index is in the last row of the layout.
269
+ * Used to suppress separators for all items in the last row, preventing
270
+ * height mismatch when the last data item has no separator.
271
+ *
272
+ * Base implementation returns false — only layouts that normalize heights
273
+ * across multiple items (e.g., grid) need to override this.
274
+ * @param index Index of the item
275
+ * @returns True if the item is in the last row
276
+ */
277
+ isInLastRow(index: number): boolean {
278
+ return false;
279
+ }
280
+
267
281
  /**
268
282
  * Abstract method to recompute layouts for items in the given range.
269
283
  * @param startIndex Starting index of items to recompute
@@ -320,4 +320,8 @@ export class RVMasonryLayoutManagerImpl extends RVLayoutManager {
320
320
  }
321
321
  }
322
322
  }
323
+
324
+ // TODO: For masonry, the "last row" is the last item in each column.
325
+ // Override isInLastRow if ItemSeparatorComponent support is needed
326
+ // for masonry layouts.
323
327
  }
@@ -1,11 +1,31 @@
1
1
  import React from "react";
2
2
 
3
+ type RenderableComponent =
4
+ | React.ComponentType
5
+ | React.ExoticComponent
6
+ | React.ReactElement
7
+ | null
8
+ | undefined;
9
+
10
+ /**
11
+ * Returns true if the value is a React class component.
12
+ * Class components set `prototype.isReactComponent` per React convention,
13
+ * which distinguishes them from plain functions and render props.
14
+ */
15
+ export const isComponentClass = (value: unknown): boolean =>
16
+ typeof value === "function" &&
17
+ Boolean(
18
+ (value as { prototype?: { isReactComponent?: unknown } }).prototype
19
+ ?.isReactComponent
20
+ );
21
+
3
22
  /**
4
23
  * Helper function to handle both React components and React elements.
5
24
  * This utility ensures proper rendering of components whether they are passed as
6
- * component types or pre-rendered elements.
25
+ * component types or pre-rendered elements. Supports function components, class
26
+ * components, React.memo, React.forwardRef, and pre-rendered elements.
7
27
  *
8
- * @param component - Can be a React component type, React element, null, or undefined
28
+ * @param component - Can be a React component type, exotic component, React element, null, or undefined
9
29
  * @returns A valid React element if the input is valid, null otherwise
10
30
  *
11
31
  * @example
@@ -17,12 +37,14 @@ import React from "react";
17
37
  * getValidComponent(<MyComponent />)
18
38
  */
19
39
  export const getValidComponent = (
20
- component: React.ComponentType | React.ReactElement | null | undefined
40
+ component: RenderableComponent
21
41
  ): React.ReactElement | null => {
22
42
  if (React.isValidElement(component)) {
23
43
  return component;
24
- } else if (typeof component === "function") {
25
- return React.createElement(component);
44
+ } else if (component != null) {
45
+ // Cast needed: React.createElement's type overloads don't include ExoticComponent,
46
+ // but it handles memo/forwardRef/lazy correctly at runtime.
47
+ return React.createElement(component as React.ComponentType);
26
48
  }
27
49
  return null;
28
50
  };
@@ -0,0 +1,7 @@
1
+ import { PlatformConfig } from "../../native/config/PlatformHelper";
2
+
3
+ export function getInvertedTransformStyle(horizontal?: boolean | null) {
4
+ return horizontal
5
+ ? PlatformConfig.invertedTransformStyleHorizontal
6
+ : PlatformConfig.invertedTransformStyle;
7
+ }
@@ -7,6 +7,11 @@ interface Layout {
7
7
  height: number;
8
8
  }
9
9
 
10
+ interface Size {
11
+ width: number;
12
+ height: number;
13
+ }
14
+
10
15
  /**
11
16
  * Measures the layout of a view relative to itselft.
12
17
  * Using measure wasn't returing accurate values but this workaround does.
@@ -89,14 +94,15 @@ export function roundOffPixel(value: number): number {
89
94
  }
90
95
 
91
96
  /**
92
- * Specific method for easier mocking
93
- * Measures the layout of parent of RecyclerView
94
- * Returns the x, y coordinates and dimensions of the view.
97
+ * Measures the size of the RecyclerView's outer container.
98
+ * Uses a self-relative measureLayout call to get width/height synchronously.
99
+ *
95
100
  * @param view - The React Native View component to measure
96
- * @returns An object containing x, y, width, and height measurements
101
+ * @returns An object containing width and height
97
102
  */
98
- export function measureParentSize(view: View): Layout {
99
- return measureLayout(view, undefined);
103
+ export function measureParentSize(view: View): Size {
104
+ const layout = measureLayout(view, undefined);
105
+ return { width: layout.width, height: layout.height };
100
106
  }
101
107
 
102
108
  /**
@@ -5,6 +5,11 @@ interface Layout {
5
5
  height: number;
6
6
  }
7
7
 
8
+ interface Size {
9
+ width: number;
10
+ height: number;
11
+ }
12
+
8
13
  /**
9
14
  * Gets scroll offsets from up to 3 parent elements
10
15
  */
@@ -43,12 +48,10 @@ export function roundOffPixel(value: number): number {
43
48
  }
44
49
 
45
50
  /**
46
- * Measures the layout of parent of RecyclerView
51
+ * Measures the size of the RecyclerView's outer container.
47
52
  */
48
- export function measureParentSize(view: Element): Layout {
53
+ export function measureParentSize(view: Element): Size {
49
54
  return {
50
- x: 0,
51
- y: 0,
52
55
  width: view.clientWidth,
53
56
  height: view.clientHeight,
54
57
  };