@rific/scroll-view 0.4.3 → 0.6.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.
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/CustomList.tsx
2
2
  import { memo, useCallback as useCallback2, useContext as useContext4, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useRef as useRef2 } from "react";
3
- import { Dimensions, View } from "react-native";
3
+ import { Dimensions, Platform as Platform3, View } from "react-native";
4
4
  import { Gesture, GestureDetector } from "react-native-gesture-handler";
5
5
  import { runOnUI, useSharedValue as useSharedValue4 } from "react-native-reanimated";
6
6
 
@@ -35,6 +35,7 @@ import { runOnJS, useAnimatedScrollHandler, useSharedValue as useSharedValue2, w
35
35
  import { createContext } from "react";
36
36
  var ScrollViewContext = createContext({
37
37
  blur: true,
38
+ footerAboveKeyboard: false,
38
39
  footerHeight: 0,
39
40
  footerHeightShared: null,
40
41
  footerFixed: false,
@@ -64,7 +65,7 @@ var ScrollViewContext = createContext({
64
65
  },
65
66
  snapBackFooterShared: null,
66
67
  snapBackHeaderShared: null,
67
- tabBarHeight: 60
68
+ tabBarHeight: 0
68
69
  });
69
70
 
70
71
  // src/useScrollInit.ts
@@ -372,17 +373,21 @@ var useKeyboardInset = Platform2.OS === "web" ? useKeyboardInsetWeb : useKeyboar
372
373
  var ZERO_INSET = { bottom: 0, top: 0 };
373
374
  var ZERO_OFFSET = { x: 0, y: 0 };
374
375
  var CHIP_SLIDE = 48;
375
- function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style } = {}) {
376
+ function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, hideUntilMeasured, isHorizontal, keyboardAware, pullSearchHeight, style } = {}) {
376
377
  const insets = useSafeAreaInsets();
377
378
  const keyboardHeight = useKeyboardInset();
378
- const { footerHeight, footerFixed: contextFooterFixed, headerHeight, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared } = useContext3(ScrollViewContext);
379
+ const { footerAboveKeyboard, footerHeight, footerFixed: contextFooterFixed, headerHeight, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared, tabBarHeight } = useContext3(ScrollViewContext);
379
380
  const headerFixed = isHorizontal ? true : headerFixedProp ?? contextHeaderFixed;
380
381
  const footerFixed = isHorizontal ? true : footerFixedProp ?? contextFooterFixed;
381
382
  useEffect2(() => {
382
383
  pullSearchHeightShared.value = usesContentInset ? pullSearchHeight ?? 0 : 0;
383
384
  }, [pullSearchHeight, pullSearchHeightShared]);
384
- const containerStyle = useMemo(() => headerHeight === null ? [{ flex: 1, opacity: 0 }, style] : [StyleSheet2.absoluteFill, style], [headerHeight, style]);
385
- const insetGeometry = useMemo(() => ({ bottom: (footerFixed ? footerHeight || insets.bottom : insets.bottom) + (keyboardAware ? keyboardHeight : 0), top: headerHeight ?? 0 }), [footerFixed, footerHeight, insets.bottom, headerHeight, keyboardAware, keyboardHeight]);
385
+ const containerStyle = useMemo(() => headerHeight === null ? [{ flex: 1, opacity: hideUntilMeasured ? 0 : 1 }, style] : [StyleSheet2.absoluteFill, style], [headerHeight, hideUntilMeasured, style]);
386
+ const insetGeometry = useMemo(() => {
387
+ const footerReserve = footerFixed ? footerHeight || insets.bottom : insets.bottom;
388
+ const bottom = (!keyboardAware || footerFixed && footerAboveKeyboard ? footerReserve : Math.max(footerReserve, keyboardHeight)) + tabBarHeight;
389
+ return { bottom, top: headerHeight ?? 0 };
390
+ }, [footerAboveKeyboard, footerFixed, footerHeight, insets.bottom, tabBarHeight, headerHeight, keyboardAware, keyboardHeight]);
386
391
  const contentInset = usesContentInset ? insetGeometry : ZERO_INSET;
387
392
  const contentPadding = useMemo(() => usesContentInset || isHorizontal ? null : { paddingBottom: insetGeometry.bottom, paddingTop: insetGeometry.top }, [insetGeometry, isHorizontal]);
388
393
  const [startedWithHeader] = useState3(headerHeight !== null);
@@ -408,7 +413,7 @@ function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedP
408
413
  top,
409
414
  transform: [{ translateY: chipHidden.value ? withTiming2(-CHIP_SLIDE) : withTiming2(0) }]
410
415
  };
411
- }, [isHorizontal, headerFixed, headerHeight, insets.top]);
416
+ }, [isHorizontal, headerFixed, headerHeight, insets.top, chipHidden, snapBackHeaderShared, headerOffset, scrollPosition, pullSearchHeightShared]);
412
417
  return { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight, insets, isHorizontal: isHorizontal ?? false };
413
418
  }
414
419
 
@@ -421,6 +426,10 @@ var CustomListInner = ({ chipProps, chipThreshold, component: List, renderFilter
421
426
  const capturedGeneration = useSharedValue4(-1);
422
427
  useLayoutEffect2(() => {
423
428
  const generation = listGeneration.value;
429
+ if (Platform3.OS === "web") {
430
+ capturedGeneration.value = generation;
431
+ return;
432
+ }
424
433
  runOnUI((gen) => {
425
434
  "worklet";
426
435
  capturedGeneration.value = gen;
@@ -518,7 +527,7 @@ var Dot = ({ activeColor, index, inactiveColor, pageWidth, scrollPosition }) =>
518
527
  const size = DOT_FULL + (DOT_SMALL - DOT_FULL) * t;
519
528
  const backgroundColor = interpolateColor(t, [0, 1], [activeColor, inactiveColor]);
520
529
  return { backgroundColor, borderRadius: size / 2, height: size, width: size };
521
- });
530
+ }, [scrollPosition, pageWidth, index, activeColor, inactiveColor]);
522
531
  return /* @__PURE__ */ jsx4(View2, { style: styles3.dotContainer, children: /* @__PURE__ */ jsx4(Animated2.View, { style: circleStyle }) });
523
532
  };
524
533
  var HorizontalDots = ({ total }) => {
@@ -529,15 +538,18 @@ var HorizontalDots = ({ total }) => {
529
538
  const insetsBottom = insets.bottom;
530
539
  const activeColor = theme.colors.primary;
531
540
  const inactiveColor = theme.colors.outlineVariant;
532
- const pillContainerStyle = useAnimatedStyle2(() => ({
533
- bottom: Math.max(footerHeightShared.value, insetsBottom) + 8
534
- }));
541
+ const pillContainerStyle = useAnimatedStyle2(
542
+ () => ({
543
+ bottom: Math.max(footerHeightShared.value, insetsBottom) + 8
544
+ }),
545
+ [footerHeightShared, insetsBottom]
546
+ );
535
547
  const rowStyle = useAnimatedStyle2(() => {
536
548
  const page = scrollPosition.value / width;
537
549
  const maxOffset = Math.max(0, (total - CLIP_COUNT) * DOT_STEP);
538
550
  const offset = Math.max(0, Math.min((page - (CLIP_COUNT - 1) / 2) * DOT_STEP, maxOffset));
539
551
  return { transform: [{ translateX: -offset }] };
540
- });
552
+ }, [scrollPosition, width, total]);
541
553
  return /* @__PURE__ */ jsx4(Animated2.View, { pointerEvents: "none", style: [styles3.pillContainer, pillContainerStyle], children: /* @__PURE__ */ jsxs2(View2, { style: styles3.pill, children: [
542
554
  /* @__PURE__ */ jsx4(BlurView, { blur, style: StyleSheet3.absoluteFill }),
543
555
  /* @__PURE__ */ jsx4(View2, { style: styles3.dotsClip, children: /* @__PURE__ */ jsx4(Animated2.View, { style: [styles3.dotsRow, rowStyle], children: Array.from({ length: total }).map((_, i) => /* @__PURE__ */ jsx4(Dot, { activeColor, index: i, inactiveColor, pageWidth: width, scrollPosition }, i)) }) })
@@ -844,7 +856,10 @@ var PullSearch = forwardRef(function PullSearch2({ debounce, onChangeText, onHei
844
856
  (isHidden, wasHidden) => {
845
857
  if (isHidden && !wasHidden) runOnJS3(blurInput)();
846
858
  },
847
- [barHeight, headerHeight, blurInput]
859
+ // scrollPosition has to be listed too, not just read — see useStickyHeaders' useAnimatedReaction
860
+ // for why: on web, without reanimated's Babel plugin active, an unlisted SharedValue mutation
861
+ // never re-triggers this reaction (confirmed empirically elsewhere in this package).
862
+ [barHeight, headerHeight, blurInput, scrollPosition]
848
863
  );
849
864
  const handleClear = useCallback5(() => {
850
865
  setValue("");
@@ -875,7 +890,7 @@ var PullSearch = forwardRef(function PullSearch2({ debounce, onChangeText, onHei
875
890
  const showY = -h;
876
891
  const hideY = -h + barHeight;
877
892
  return { opacity: interpolate(scrollPosition.value, [showY, hideY], [1, 0], Extrapolation.CLAMP) };
878
- }, [barHeight, headerHeight]);
893
+ }, [barHeight, headerHeight, scrollPosition]);
879
894
  return /* @__PURE__ */ jsx6(Animated3.View, { onLayout: handleLayout, style: [styles5.container, animatedStyle], children: /* @__PURE__ */ jsx6(Searchbar, { autoCorrect: false, onChangeText: (text) => setValue((text || "").trimStart()), onClearIconPress: handleClear, placeholder, placeholderTextColor: theme.colors.onSurfaceVariant, ref: inputRef, spellCheck: false, style: styles5.input, value }) });
880
895
  });
881
896
  var styles5 = StyleSheet4.create({
@@ -913,7 +928,7 @@ var scrollViewReducer = (state = defaultScrollViewSettings, action) => {
913
928
 
914
929
  // src/ScrollView.tsx
915
930
  import { memo as memo3, useCallback as useCallback6, useContext as useContext9, useLayoutEffect as useLayoutEffect4, useMemo as useMemo4, useRef as useRef6 } from "react";
916
- import { View as View4 } from "react-native";
931
+ import { Platform as Platform4, View as View4 } from "react-native";
917
932
  import { Gesture as Gesture3, GestureDetector as GestureDetector3 } from "react-native-gesture-handler";
918
933
  import Animated4, { runOnUI as runOnUI2, useSharedValue as useSharedValue5 } from "react-native-reanimated";
919
934
  import { jsx as jsx7, jsxs as jsxs4 } from "react/jsx-runtime";
@@ -925,6 +940,10 @@ var ScrollViewInner = ({ chipProps, chipThreshold, children, contentContainerSty
925
940
  const capturedGeneration = useSharedValue5(-1);
926
941
  useLayoutEffect4(() => {
927
942
  const generation = listGeneration.value;
943
+ if (Platform4.OS === "web") {
944
+ capturedGeneration.value = generation;
945
+ return;
946
+ }
928
947
  runOnUI2((gen) => {
929
948
  "worklet";
930
949
  capturedGeneration.value = gen;
@@ -989,8 +1008,9 @@ import Animated5, { useAnimatedStyle as useAnimatedStyle4 } from "react-native-r
989
1008
  import { useSafeAreaInsets as useSafeAreaInsets3 } from "react-native-safe-area-context";
990
1009
  import { jsx as jsx8, jsxs as jsxs5 } from "react/jsx-runtime";
991
1010
  var ScrollViewFooter = ({ children, style }) => {
992
- const { blur, footerHeight, footerFixed, footerOffset, headerHeightShared, pullSearchHeightShared, scrollPosition, setFooterHeight, snapBackFooterShared } = useContext10(ScrollViewContext);
1011
+ const { blur, footerAboveKeyboard, footerHeight, footerFixed, footerOffset, headerHeightShared, pullSearchHeightShared, scrollPosition, setFooterHeight, snapBackFooterShared } = useContext10(ScrollViewContext);
993
1012
  const insets = useSafeAreaInsets3();
1013
+ const keyboardHeight = useKeyboardInset();
994
1014
  useEffect4(
995
1015
  () => () => {
996
1016
  setFooterHeight(0);
@@ -1010,10 +1030,12 @@ var ScrollViewFooter = ({ children, style }) => {
1010
1030
  const effective = scrollPosition.value + headerHeightShared.value - pullSearchHeightShared.value;
1011
1031
  if (effective <= 0) return { transform: [{ translateY: 0 }] };
1012
1032
  return { transform: [{ translateY: Math.min(effective, footerHeight) }] };
1013
- }, [footerHeight, footerFixed]);
1014
- return /* @__PURE__ */ jsxs5(Animated5.View, { onLayout: handleLayout, pointerEvents: "box-none", style: [styles6.footer, footerStyle], children: [
1033
+ }, [footerHeight, footerFixed, headerHeightShared, pullSearchHeightShared, scrollPosition, snapBackFooterShared, footerOffset]);
1034
+ const containerPaddingBottom = footerFixed && footerAboveKeyboard ? keyboardHeight : 0;
1035
+ const rowPaddingBottom = footerFixed && footerAboveKeyboard ? Math.max(insets.bottom - keyboardHeight, 0) : insets.bottom;
1036
+ return /* @__PURE__ */ jsxs5(Animated5.View, { onLayout: handleLayout, pointerEvents: "box-none", style: [styles6.footer, { paddingBottom: containerPaddingBottom }, footerStyle], children: [
1015
1037
  footerHeight > 0 && /* @__PURE__ */ jsx8(BlurView2, { blur, style: StyleSheet5.absoluteFill }),
1016
- /* @__PURE__ */ jsx8(View5, { style: [styles6.row, { paddingBottom: insets.bottom }, style], children })
1038
+ /* @__PURE__ */ jsx8(View5, { style: [styles6.row, { paddingBottom: rowPaddingBottom }, style], children })
1017
1039
  ] });
1018
1040
  };
1019
1041
  var styles6 = StyleSheet5.create({
@@ -1030,7 +1052,7 @@ import Animated6, { useAnimatedStyle as useAnimatedStyle5 } from "react-native-r
1030
1052
  import { useSafeAreaInsets as useSafeAreaInsets4 } from "react-native-safe-area-context";
1031
1053
  import { Fragment as Fragment4, jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1032
1054
  var ActionBg = ({ blur, style }) => blur ? /* @__PURE__ */ jsx9(View6, { style: [StyleSheet6.absoluteFill, styles7.actionBg, style], children: /* @__PURE__ */ jsx9(BlurView3, { blur: true, style: StyleSheet6.absoluteFill }) }) : /* @__PURE__ */ jsx9(View6, { style: [StyleSheet6.absoluteFill, styles7.actionBg, style], pointerEvents: "none", children: /* @__PURE__ */ jsx9(Surface, { style: StyleSheet6.absoluteFill, elevation: 1, children: null }) });
1033
- var ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, backActionFixed, caption, centerContent, children, iconSize, style, title, topInset = true, trailingAction, trailingActionFixed = true }) => {
1055
+ var ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, backActionAccessibilityLabel, backActionFixed, caption, centerContent, children, iconSize, style, title, topInset = true, trailingAction, trailingActionFixed = true }) => {
1034
1056
  const { blur, headerHeight, headerFixed, headerOffset, progress, progressing, pullSearchHeightShared, scrollPosition, setHeaderHeight, snapBackHeaderShared } = useContext11(ScrollViewContext);
1035
1057
  const { settings } = useContext11(ScrollViewSettingsContext);
1036
1058
  const effectiveBackActionFixed = backActionFixed ?? settings.backActionFixed;
@@ -1044,35 +1066,36 @@ var ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, backActionFi
1044
1066
  }) => {
1045
1067
  if (headerHeight !== height) setHeaderHeight(height);
1046
1068
  };
1069
+ const actionMargin = 4;
1070
+ const contentMinHeight = actionSize + 2 * actionMargin;
1071
+ const unmeasuredHeight = top + contentMinHeight;
1047
1072
  const translateStyle = useAnimatedStyle5(() => {
1048
1073
  if (headerFixed) return { transform: [{ translateY: 0 }] };
1049
1074
  if (snapBackHeaderShared.value) return { transform: [{ translateY: headerOffset.value }] };
1050
1075
  const effective = scrollPosition.value + (headerHeight ?? 0) - pullSearchHeightShared.value;
1051
1076
  if (effective <= 0) return { transform: [{ translateY: 0 }] };
1052
1077
  return { transform: [{ translateY: -effective }] };
1053
- }, [headerFixed, headerHeight]);
1078
+ }, [headerFixed, headerHeight, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared]);
1054
1079
  const blurStyle = useAnimatedStyle5(() => {
1080
+ if (headerHeight === null) return { height: unmeasuredHeight };
1055
1081
  if (!headerHeight) return { height: 0 };
1056
1082
  if (headerFixed) return { height: headerHeight };
1057
- const h = headerHeight ?? 0;
1058
- const translateY = snapBackHeaderShared.value ? headerOffset.value : -Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value);
1059
- return { height: Math.max(h + translateY, top) };
1060
- }, [headerFixed, headerHeight, top]);
1083
+ const translateY = snapBackHeaderShared.value ? headerOffset.value : -Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value);
1084
+ return { height: Math.max(headerHeight + translateY, top) };
1085
+ }, [headerFixed, headerHeight, top, unmeasuredHeight, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared]);
1061
1086
  const progressStyle = useAnimatedStyle5(() => {
1062
1087
  const h = headerHeight ?? 0;
1063
1088
  if (headerFixed) return { top: h };
1064
1089
  const slide = snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value);
1065
1090
  return { top: Math.max(h - slide, top) };
1066
- }, [headerFixed, headerHeight, top]);
1067
- const actionMargin = 4;
1068
- const contentMinHeight = actionSize + 2 * actionMargin;
1091
+ }, [headerFixed, headerHeight, top, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared]);
1069
1092
  const actionTop = top + actionMargin;
1070
1093
  const buttonStyle = { borderRadius: actionSize, height: actionSize, margin: 0, width: actionSize };
1071
1094
  const actionShadow = { borderRadius: actionSize / 2, elevation: 2, shadowColor: "#000", shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.15, shadowRadius: 2 };
1072
1095
  const leadingStyle = { alignItems: "center", height: actionSize, justifyContent: "center", left: actionMargin, position: "absolute", top: actionTop, width: actionSize, zIndex: 3, ...actionShadow };
1073
1096
  const trailingStyle = { alignItems: "center", height: actionSize, justifyContent: "center", position: "absolute", right: actionMargin, top: actionTop, width: actionSize, zIndex: 3, ...actionShadow };
1074
1097
  return /* @__PURE__ */ jsxs6(Fragment4, { children: [
1075
- /* @__PURE__ */ jsx9(Animated6.View, { pointerEvents: "none", style: [styles7.blur, blurStyle], children: headerHeight !== null && headerHeight > 0 && /* @__PURE__ */ jsx9(BlurView3, { blur, style: [styles7.blurInner, { height: headerHeight }] }) }),
1098
+ /* @__PURE__ */ jsx9(Animated6.View, { pointerEvents: "none", style: [styles7.blur, blurStyle], children: headerHeight !== 0 && /* @__PURE__ */ jsx9(BlurView3, { blur, style: [styles7.blurInner, { height: headerHeight ?? unmeasuredHeight }] }) }),
1076
1099
  /* @__PURE__ */ jsxs6(Animated6.View, { onLayout: handleLayout, pointerEvents: "box-none", style: [headerHeight === null ? styles7.headerInit : styles7.header, translateStyle], children: [
1077
1100
  /* @__PURE__ */ jsx9(View6, { style: [{ paddingTop: top }, style], children: children ?? /* @__PURE__ */ jsxs6(View6, { style: [styles7.content, { minHeight: contentMinHeight }], children: [
1078
1101
  /* @__PURE__ */ jsx9(View6, { style: styles7.side }),
@@ -1087,10 +1110,10 @@ var ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, backActionFi
1087
1110
  /* @__PURE__ */ jsx9(Animated6.View, { pointerEvents: "none", style: [styles7.progress, progressStyle], children: /* @__PURE__ */ jsx9(ProgressBar, { indeterminate: progress === null, visible: progressing, progress: progress ?? void 0 }) }),
1088
1111
  backAction && (effectiveBackActionFixed ? /* @__PURE__ */ jsxs6(Animated6.View, { style: leadingStyle, children: [
1089
1112
  /* @__PURE__ */ jsx9(ActionBg, { blur, style: actionStyle }),
1090
- /* @__PURE__ */ jsx9(Appbar.BackAction, { onPress: backAction, size: iconSize ?? actionSize / 2, style: buttonStyle })
1113
+ /* @__PURE__ */ jsx9(Appbar.BackAction, { accessibilityLabel: backActionAccessibilityLabel, onPress: backAction, size: iconSize ?? actionSize / 2, style: buttonStyle })
1091
1114
  ] }) : /* @__PURE__ */ jsxs6(Animated6.View, { style: [leadingStyle, translateStyle], children: [
1092
1115
  /* @__PURE__ */ jsx9(ActionBg, { blur, style: actionStyle }),
1093
- /* @__PURE__ */ jsx9(Appbar.BackAction, { onPress: backAction, size: iconSize ?? actionSize / 2, style: buttonStyle })
1116
+ /* @__PURE__ */ jsx9(Appbar.BackAction, { accessibilityLabel: backActionAccessibilityLabel, onPress: backAction, size: iconSize ?? actionSize / 2, style: buttonStyle })
1094
1117
  ] })),
1095
1118
  trailingAction && (trailingActionFixed ? /* @__PURE__ */ jsxs6(View6, { style: trailingStyle, children: [
1096
1119
  /* @__PURE__ */ jsx9(ActionBg, { blur, style: actionStyle }),
@@ -1123,7 +1146,7 @@ import { useCallback as useCallback7, useContext as useContext12, useEffect as u
1123
1146
  import { Dimensions as Dimensions3 } from "react-native";
1124
1147
  import { useSharedValue as useSharedValue6 } from "react-native-reanimated";
1125
1148
  import { jsx as jsx10 } from "react/jsx-runtime";
1126
- var ScrollViewProvider = ({ blur, children, fixed = false, footerFixed, headerFixed, snapBack, snapBackFooter, snapBackHeader, tabBarHeight = 60 }) => {
1149
+ var ScrollViewProvider = ({ blur, children, fixed = false, footerAboveKeyboard = false, footerFixed, headerFixed, snapBack, snapBackFooter, snapBackHeader, tabBarHeight = 0 }) => {
1127
1150
  const { settings } = useContext12(ScrollViewSettingsContext);
1128
1151
  const effectiveBlur = useBlur(blur);
1129
1152
  const effectiveSnapBack = snapBack ?? settings.snapBack;
@@ -1149,10 +1172,11 @@ var ScrollViewProvider = ({ blur, children, fixed = false, footerFixed, headerFi
1149
1172
  }, [effectiveSnapBack, effectiveSnapBackFooter, effectiveSnapBackHeader, snapBackFooterShared, snapBackHeaderShared]);
1150
1173
  const setHeaderHeight = useCallback7(
1151
1174
  (h) => {
1175
+ if (h === 0 && headerHeight) return;
1152
1176
  setHeaderHeightState(h);
1153
1177
  headerHeightShared.value = h ?? 0;
1154
1178
  },
1155
- [headerHeightShared]
1179
+ [headerHeight, headerHeightShared]
1156
1180
  );
1157
1181
  const onListUnmount = useCallback7(() => {
1158
1182
  listGeneration.value += 1;
@@ -1177,7 +1201,7 @@ var ScrollViewProvider = ({ blur, children, fixed = false, footerFixed, headerFi
1177
1201
  const scrollHeight = useMemo5(() => Dimensions3.get("window").height - (headerHeight ?? 0) - footerHeight, [headerHeight, footerHeight]);
1178
1202
  const effectiveHeaderFixed = fixed || (headerFixed ?? settings.headerFixed);
1179
1203
  const effectiveFooterFixed = fixed || (footerFixed ?? settings.footerFixed);
1180
- const value = useMemo5(() => ({ blur: effectiveBlur, footerHeight, footerHeightShared, footerFixed: effectiveFooterFixed, footerOffset, headerHeight, headerHeightShared, headerFixed: effectiveHeaderFixed, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, setProgress, setProgressing, snapBackFooterShared, snapBackHeaderShared, tabBarHeight }), [effectiveBlur, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, tabBarHeight]);
1204
+ const value = useMemo5(() => ({ blur: effectiveBlur, footerAboveKeyboard, footerHeight, footerHeightShared, footerFixed: effectiveFooterFixed, footerOffset, headerHeight, headerHeightShared, headerFixed: effectiveHeaderFixed, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, setProgress, setProgressing, snapBackFooterShared, snapBackHeaderShared, tabBarHeight }), [effectiveBlur, footerAboveKeyboard, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, tabBarHeight]);
1181
1205
  return /* @__PURE__ */ jsx10(ScrollViewContext.Provider, { value, children });
1182
1206
  };
1183
1207
 
@@ -1205,7 +1229,7 @@ var ScrollViewSettingsProvider = ({ children, onChange, initialValue }) => {
1205
1229
 
1206
1230
  // src/SectionList.tsx
1207
1231
  import { memo as memo4, useCallback as useCallback10, useContext as useContext14, useEffect as useEffect6, useLayoutEffect as useLayoutEffect6, useMemo as useMemo7, useRef as useRef9 } from "react";
1208
- import { Dimensions as Dimensions4, SectionList as RNSectionList, View as View7 } from "react-native";
1232
+ import { Dimensions as Dimensions4, Platform as Platform5, SectionList as RNSectionList, View as View7 } from "react-native";
1209
1233
  import { Gesture as Gesture4, GestureDetector as GestureDetector4 } from "react-native-gesture-handler";
1210
1234
  import Animated7, { runOnUI as runOnUI3, useSharedValue as useSharedValue8 } from "react-native-reanimated";
1211
1235
 
@@ -1252,7 +1276,12 @@ function useStickyHeaders(headerFixed, enabled) {
1252
1276
  (curr, prev) => {
1253
1277
  if (curr !== prev) runOnJS4(setActiveIndex)(curr);
1254
1278
  },
1255
- [enabled, headerFixed, insetsTop]
1279
+ // Same web-only reanimated gap as useAnimatedStyle elsewhere in this file: this "prepare"
1280
+ // function's own SharedValue reads (sectionYPositions, headerHeightShared, scrollPosition,
1281
+ // snapBackHeaderShared, headerOffset, pullSearchHeightShared) need to be listed explicitly too
1282
+ // — confirmed empirically that without them, useAnimatedReaction re-evaluates once on mount and
1283
+ // then never again on web, permanently freezing activeIndex/the active sticky section.
1284
+ [enabled, headerFixed, insetsTop, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared]
1256
1285
  );
1257
1286
  const clipStyle = useAnimatedStyle6(() => {
1258
1287
  const hHeight = headerHeightShared.value;
@@ -1267,7 +1296,7 @@ function useStickyHeaders(headerFixed, enabled) {
1267
1296
  stickyTop = effective <= 0 ? hHeight : Math.max(insetsTop, hHeight - effective);
1268
1297
  }
1269
1298
  return { top: stickyTop };
1270
- }, [enabled, headerFixed, insetsTop]);
1299
+ }, [enabled, headerFixed, insetsTop, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared]);
1271
1300
  const overlayStyle = useAnimatedStyle6(() => {
1272
1301
  const hidden = { opacity: 0, top: 0, transform: [{ translateY: 0 }] };
1273
1302
  if (!enabled) return hidden;
@@ -1295,7 +1324,7 @@ function useStickyHeaders(headerFixed, enabled) {
1295
1324
  if (nextScreenY < pushThreshold) translateY = nextScreenY - pushThreshold;
1296
1325
  }
1297
1326
  return { opacity: 1, top: 0, transform: [{ translateY }] };
1298
- }, [enabled, headerFixed, insetsTop]);
1327
+ }, [enabled, headerFixed, insetsTop, contentIndexShared, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared, sectionHeights]);
1299
1328
  const measureHeader = useCallback9(
1300
1329
  (index, pageY, height) => {
1301
1330
  const contentY = pageY + scrollPosition.value;
@@ -1324,11 +1353,15 @@ var AnimatedSectionList = Animated7.createAnimatedComponent(RNSectionList);
1324
1353
  var SectionListInner = ({ chipProps, chipThreshold, contentContainerStyle: externalContentContainerStyle, initialNumToRender = 20, maxToRenderPerBatch = 50, renderFilters, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onChipPress, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onContentSizeChange, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pullSearchHeight, refreshing, removeClippedSubviews = false, sections, showsVerticalScrollIndicator, style, ref: externalRef, windowSize = 100, ...props }) => {
1325
1354
  const scrollView = useRef9(null);
1326
1355
  const { stickySectionHeadersEnabled = true, renderSectionHeader, ...passThroughProps } = props;
1327
- const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style });
1356
+ const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, hideUntilMeasured: true, keyboardAware, pullSearchHeight, style });
1328
1357
  const { listGeneration, onListUnmount } = useContext14(ScrollViewContext);
1329
1358
  const capturedGeneration = useSharedValue8(-1);
1330
1359
  useLayoutEffect6(() => {
1331
1360
  const generation = listGeneration.value;
1361
+ if (Platform5.OS === "web") {
1362
+ capturedGeneration.value = generation;
1363
+ return;
1364
+ }
1332
1365
  runOnUI3((gen) => {
1333
1366
  "worklet";
1334
1367
  capturedGeneration.value = gen;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/scroll-view",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
4
4
  "description": "Blur styled scroll system for React Native. Floating headers/footers, keyboard awareness, and scroll-away chrome",
5
5
  "keywords": [
6
6
  "react-native",
@@ -27,7 +27,7 @@
27
27
  "type": "commonjs",
28
28
  "exports": {
29
29
  ".": {
30
- "react-native": "./dist/index.js",
30
+ "react-native": "./dist/index.mjs",
31
31
  "types": "./dist/index.d.ts",
32
32
  "import": "./dist/index.mjs",
33
33
  "require": "./dist/index.js"
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "main": "dist/index.js",
37
37
  "module": "dist/index.mjs",
38
- "react-native": "dist/index.js",
38
+ "react-native": "dist/index.mjs",
39
39
  "types": "dist/index.d.ts",
40
40
  "files": [
41
41
  "dist",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@reduxjs/toolkit": "^2.12.0",
63
- "@rific/auto-paper": "^0.6.0",
63
+ "@rific/auto-paper": "^0.8.0",
64
64
  "@testing-library/dom": "^10.4.1",
65
65
  "@testing-library/react": "^16.3.2",
66
66
  "@types/jest": "^30.0.0",
@@ -71,6 +71,7 @@
71
71
  "eslint-config-prettier": "^10.1.8",
72
72
  "eslint-plugin-package-json": "^1.0.0",
73
73
  "eslint-plugin-prettier": "^5.5.5",
74
+ "eslint-plugin-react-hooks": "^7.1.1",
74
75
  "eslint-plugin-react-native": "^5.0.0",
75
76
  "eslint-plugin-simple-import-sort": "^13.0.0",
76
77
  "jest": "^30.4.2",
@@ -91,7 +92,7 @@
91
92
  "typescript-eslint": "^8.59.3"
92
93
  },
93
94
  "peerDependencies": {
94
- "@rific/auto-paper": "^0.6.0",
95
+ "@rific/auto-paper": "^0.8.0",
95
96
  "react": ">=18.0.0",
96
97
  "react-native": ">=0.76.0",
97
98
  "react-native-gesture-handler": ">=2.0.0",
@@ -1,5 +1,5 @@
1
1
  import { type ComponentType, memo, type ReactElement, type ReactNode, type RefObject, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react'
2
- import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, type StyleProp, View, type ViewStyle } from 'react-native'
2
+ import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, Platform, type StyleProp, View, type ViewStyle } from 'react-native'
3
3
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
4
  import { runOnUI, useSharedValue } from 'react-native-reanimated'
5
5
 
@@ -52,8 +52,16 @@ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component
52
52
  // imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
53
53
  // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
54
54
  // only be queued after this component finishes mounting.
55
+ //
56
+ // Web has no such thread split, though — see ScrollView.tsx's own doc comment on this same
57
+ // pattern for why runOnUI's deferred-to-next-frame behavior there leaves a real gap, and why a
58
+ // synchronous assignment is the correct fix rather than a workaround.
55
59
  useLayoutEffect(() => {
56
60
  const generation = listGeneration.value
61
+ if (Platform.OS === 'web') {
62
+ capturedGeneration.value = generation
63
+ return
64
+ }
57
65
  runOnUI((gen: number) => {
58
66
  'worklet'
59
67
  capturedGeneration.value = gen
@@ -47,7 +47,10 @@ export const PullSearch = forwardRef<PullSearchHandle, PullSearchProps>(function
47
47
  (isHidden, wasHidden) => {
48
48
  if (isHidden && !wasHidden) runOnJS(blurInput)()
49
49
  },
50
- [barHeight, headerHeight, blurInput]
50
+ // scrollPosition has to be listed too, not just read — see useStickyHeaders' useAnimatedReaction
51
+ // for why: on web, without reanimated's Babel plugin active, an unlisted SharedValue mutation
52
+ // never re-triggers this reaction (confirmed empirically elsewhere in this package).
53
+ [barHeight, headerHeight, blurInput, scrollPosition]
51
54
  )
52
55
 
53
56
  const handleClear = useCallback(() => {
@@ -85,7 +88,7 @@ export const PullSearch = forwardRef<PullSearchHandle, PullSearchProps>(function
85
88
  const showY = -h
86
89
  const hideY = -h + barHeight
87
90
  return { opacity: interpolate(scrollPosition.value, [showY, hideY], [1, 0], Extrapolation.CLAMP) }
88
- }, [barHeight, headerHeight])
91
+ }, [barHeight, headerHeight, scrollPosition])
89
92
 
90
93
  return (
91
94
  <Animated.View onLayout={handleLayout} style={[styles.container, animatedStyle]}>
@@ -1,5 +1,5 @@
1
1
  import { memo, type RefObject, useCallback, useContext, useLayoutEffect, useMemo, useRef } from 'react'
2
- import { type NativeScrollEvent, type NativeSyntheticEvent, ScrollView as RNScrollView, type ScrollViewProps as RNScrollViewProps, View } from 'react-native'
2
+ import { type NativeScrollEvent, type NativeSyntheticEvent, Platform, ScrollView as RNScrollView, type ScrollViewProps as RNScrollViewProps, View } from 'react-native'
3
3
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
4
  import Animated, { runOnUI, useSharedValue } from 'react-native-reanimated'
5
5
 
@@ -47,8 +47,23 @@ const ScrollViewInner = ({ chipProps, chipThreshold, children, contentContainerS
47
47
  // imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
48
48
  // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
49
49
  // only be queued after this component finishes mounting.
50
+ //
51
+ // Web has no such thread split, though — react-native-worklets' own runOnUI there (see its
52
+ // threads.js) only enqueues the callback for the NEXT requestAnimationFrame, it doesn't run it
53
+ // synchronously. That opens exactly the gap the native path exists to close: a scroll event
54
+ // (including useScrollInit's own mount-time imperative scrollTo) landing before that deferred
55
+ // frame sees capturedGeneration still at its -1 placeholder, fails the generation check below,
56
+ // and — since this effect only ever runs once per mount — never gets a second chance, silently
57
+ // disabling scroll tracking (and therefore header/footer scroll-away) for this instance's entire
58
+ // lifetime. Assigning directly is both simpler and provably correct on web: single-threaded JS
59
+ // makes a synchronous write immediately visible to any read that follows it in program order, so
60
+ // there's no cross-thread visibility gap here to guard against in the first place.
50
61
  useLayoutEffect(() => {
51
62
  const generation = listGeneration.value
63
+ if (Platform.OS === 'web') {
64
+ capturedGeneration.value = generation
65
+ return
66
+ }
52
67
  runOnUI((gen: number) => {
53
68
  'worklet'
54
69
  capturedGeneration.value = gen
@@ -3,6 +3,7 @@ import { SharedValue } from 'react-native-reanimated'
3
3
 
4
4
  export type ScrollViewContextType = {
5
5
  blur: boolean
6
+ footerAboveKeyboard: boolean
6
7
  footerHeight: number
7
8
  footerHeightShared: SharedValue<number>
8
9
  footerFixed: boolean
@@ -36,6 +37,7 @@ export type ScrollViewContextType = {
36
37
 
37
38
  export const ScrollViewContext = createContext<ScrollViewContextType>({
38
39
  blur: true,
40
+ footerAboveKeyboard: false,
39
41
  footerHeight: 0,
40
42
  footerHeightShared: null as unknown as SharedValue<number>,
41
43
  footerFixed: false,
@@ -59,5 +61,5 @@ export const ScrollViewContext = createContext<ScrollViewContextType>({
59
61
  setProgressing: () => {},
60
62
  snapBackFooterShared: null as unknown as SharedValue<boolean>,
61
63
  snapBackHeaderShared: null as unknown as SharedValue<boolean>,
62
- tabBarHeight: 60
64
+ tabBarHeight: 0
63
65
  })
@@ -5,6 +5,7 @@ import Animated, { useAnimatedStyle } from 'react-native-reanimated'
5
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
6
6
 
7
7
  import { ScrollViewContext } from './ScrollViewContext'
8
+ import { useKeyboardInset } from './useKeyboardInset'
8
9
 
9
10
  export type ScrollViewFooterProps = {
10
11
  children?: ReactNode | ReactNode[]
@@ -12,8 +13,9 @@ export type ScrollViewFooterProps = {
12
13
  }
13
14
 
14
15
  export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) => {
15
- const { blur, footerHeight, footerFixed, footerOffset, headerHeightShared, pullSearchHeightShared, scrollPosition, setFooterHeight, snapBackFooterShared } = useContext(ScrollViewContext)
16
+ const { blur, footerAboveKeyboard, footerHeight, footerFixed, footerOffset, headerHeightShared, pullSearchHeightShared, scrollPosition, setFooterHeight, snapBackFooterShared } = useContext(ScrollViewContext)
16
17
  const insets = useSafeAreaInsets()
18
+ const keyboardHeight = useKeyboardInset()
17
19
  useEffect(
18
20
  () => () => {
19
21
  setFooterHeight(0)
@@ -27,17 +29,46 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
27
29
  }: LayoutChangeEvent) => {
28
30
  if (footerHeight !== height) setFooterHeight(height)
29
31
  }
32
+ // See ScrollViewHeader's translateStyle/blurStyle/progressStyle for why every SharedValue read
33
+ // here (scrollPosition, headerHeightShared, pullSearchHeightShared, snapBackFooterShared,
34
+ // footerOffset) also has to be listed explicitly — on web, reanimated doesn't pick up their
35
+ // mutations as reactive triggers unless they're in this array, unlike native.
30
36
  const footerStyle = useAnimatedStyle(() => {
31
37
  if (footerFixed) return { transform: [{ translateY: 0 }] }
32
38
  if (snapBackFooterShared.value) return { transform: [{ translateY: footerOffset.value }] }
33
39
  const effective = scrollPosition.value + headerHeightShared.value - pullSearchHeightShared.value
34
40
  if (effective <= 0) return { transform: [{ translateY: 0 }] }
35
41
  return { transform: [{ translateY: Math.min(effective, footerHeight) }] }
36
- }, [footerHeight, footerFixed])
42
+ }, [footerHeight, footerFixed, headerHeightShared, pullSearchHeightShared, scrollPosition, snapBackFooterShared, footerOffset])
43
+ // Floating above the keyboard is done by GROWING this container (via paddingBottom) rather than
44
+ // translating it. Translating the whole bar left its bottom edge — and the BlurView filling it —
45
+ // floating above the true screen bottom, exposing a gap of whatever sits behind (visible as a
46
+ // strip above the keyboard, and worse at the keyboard's rounded top corners, which dip below its
47
+ // reported height). Growing the container instead keeps its `bottom: 0` anchor untouched: the
48
+ // blur backdrop always reaches all the way to the physical edge, sealing that gap entirely,
49
+ // while the extra height pushes the row (this bar's actual content) up by keyboardHeight, same
50
+ // as the old translateY did for it.
51
+ const containerPaddingBottom = footerFixed && footerAboveKeyboard ? keyboardHeight : 0
52
+ // The safe-area bottom inset exists to clear the home indicator at the physical screen edge.
53
+ // Once footerAboveKeyboard has floated this bar's content up above an open keyboard, it's no
54
+ // longer sitting at that edge — the keyboard itself now occupies that space — so the inset would
55
+ // just be dead space under the content (e.g. under a Save button) for no reason.
56
+ //
57
+ // This has to ramp continuously with keyboardHeight, not flip as a boolean once the keyboard
58
+ // fully closes: containerPaddingBottom above already tracks keyboardHeight every frame, so a
59
+ // step-function padding change here lands in the same frame keyboardHeight hits 0, growing this
60
+ // row by insets.bottom right as the container finishes shrinking back down — the content visually
61
+ // overshoots to the physical bottom edge and then snaps back up. Ramping this padding by the same
62
+ // keyboardHeight keeps the content's on-screen position moving continuously throughout: for
63
+ // keyboardHeight >= insets.bottom the padding is 0 (unchanged from mid-close), and for the last
64
+ // insets.bottom worth of keyboard travel, containerPaddingBottom's continued shrink and this
65
+ // padding's growth cancel out exactly, so the content holds still at the safe-area line instead
66
+ // of dropping past it and correcting back up.
67
+ const rowPaddingBottom = footerFixed && footerAboveKeyboard ? Math.max(insets.bottom - keyboardHeight, 0) : insets.bottom
37
68
  return (
38
- <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[styles.footer, footerStyle]}>
69
+ <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[styles.footer, { paddingBottom: containerPaddingBottom }, footerStyle]}>
39
70
  {footerHeight > 0 && <BlurView blur={blur} style={StyleSheet.absoluteFill} />}
40
- <View style={[styles.row, { paddingBottom: insets.bottom }, style]}>{children}</View>
71
+ <View style={[styles.row, { paddingBottom: rowPaddingBottom }, style]}>{children}</View>
41
72
  </Animated.View>
42
73
  )
43
74
  }