@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.
@@ -12,6 +12,7 @@ export type ScrollViewHeaderProps = {
12
12
  actionSize?: number
13
13
  actionStyle?: ViewStyle
14
14
  backAction?: () => void
15
+ backActionAccessibilityLabel?: string
15
16
  backActionFixed?: boolean
16
17
  caption?: string
17
18
  centerContent?: ReactNode
@@ -39,7 +40,7 @@ const ActionBg = ({ blur, style }: ActionBgProps) =>
39
40
  </View>
40
41
  )
41
42
 
42
- export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, backActionFixed, caption, centerContent, children, iconSize, style, title, topInset = true, trailingAction, trailingActionFixed = true }: ScrollViewHeaderProps) => {
43
+ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, backActionAccessibilityLabel, backActionFixed, caption, centerContent, children, iconSize, style, title, topInset = true, trailingAction, trailingActionFixed = true }: ScrollViewHeaderProps) => {
43
44
  const { blur, headerHeight, headerFixed, headerOffset, progress, progressing, pullSearchHeightShared, scrollPosition, setHeaderHeight, snapBackHeaderShared } = useContext(ScrollViewContext)
44
45
  const { settings } = useContext(ScrollViewSettingsContext)
45
46
  const effectiveBackActionFixed = backActionFixed ?? settings.backActionFixed
@@ -53,28 +54,40 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
53
54
  }: LayoutChangeEvent) => {
54
55
  if (headerHeight !== height) setHeaderHeight(height)
55
56
  }
57
+ const actionMargin = 4
58
+ const contentMinHeight = actionSize + 2 * actionMargin
59
+ // Same natural height the header renders at in-flow before headerHeight is ever measured (see
60
+ // useScrollList's containerStyle comment for that same "in-flow first, absolute once measured"
61
+ // pattern) — used as the blur backdrop's own height for that same pre-measurement window, so it
62
+ // doesn't render as a flat, unblurred box for the ~60-80ms an onLayout round trip takes on web.
63
+ const unmeasuredHeight = top + contentMinHeight
64
+ // Every one of these worklets reads several SharedValues (scrollPosition, snapBackHeaderShared,
65
+ // headerOffset, pullSearchHeightShared) whose *mutations* — not just headerFixed/headerHeight
66
+ // changing — need to re-trigger the style recompute. Reanimated's automatic worklet dependency
67
+ // capture covers this on native without needing them listed, but not on web, where a SharedValue
68
+ // has to appear in the array itself (object identity, not .value) for its mutations to be
69
+ // treated as reactive — omitting them here silently froze the header/blur/progress-bar's scroll
70
+ // tracking on web at whatever scrollPosition happened to be when headerHeight last changed.
56
71
  const translateStyle = useAnimatedStyle(() => {
57
72
  if (headerFixed) return { transform: [{ translateY: 0 }] }
58
73
  if (snapBackHeaderShared.value) return { transform: [{ translateY: headerOffset.value }] }
59
74
  const effective = scrollPosition.value + (headerHeight ?? 0) - pullSearchHeightShared.value
60
75
  if (effective <= 0) return { transform: [{ translateY: 0 }] }
61
76
  return { transform: [{ translateY: -effective }] }
62
- }, [headerFixed, headerHeight])
77
+ }, [headerFixed, headerHeight, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared])
63
78
  const blurStyle = useAnimatedStyle(() => {
79
+ if (headerHeight === null) return { height: unmeasuredHeight }
64
80
  if (!headerHeight) return { height: 0 }
65
81
  if (headerFixed) return { height: headerHeight }
66
- const h = headerHeight ?? 0
67
- const translateY = snapBackHeaderShared.value ? headerOffset.value : -Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value)
68
- return { height: Math.max(h + translateY, top) }
69
- }, [headerFixed, headerHeight, top])
82
+ const translateY = snapBackHeaderShared.value ? headerOffset.value : -Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value)
83
+ return { height: Math.max(headerHeight + translateY, top) }
84
+ }, [headerFixed, headerHeight, top, unmeasuredHeight, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared])
70
85
  const progressStyle = useAnimatedStyle(() => {
71
86
  const h = headerHeight ?? 0
72
87
  if (headerFixed) return { top: h }
73
88
  const slide = snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value)
74
89
  return { top: Math.max(h - slide, top) }
75
- }, [headerFixed, headerHeight, top])
76
- const actionMargin = 4
77
- const contentMinHeight = actionSize + 2 * actionMargin
90
+ }, [headerFixed, headerHeight, top, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared])
78
91
  const actionTop = top + actionMargin
79
92
  const buttonStyle = { borderRadius: actionSize, height: actionSize, margin: 0, width: actionSize }
80
93
  const actionShadow = { borderRadius: actionSize / 2, elevation: 2, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.15, shadowRadius: 2 }
@@ -83,7 +96,7 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
83
96
  return (
84
97
  <>
85
98
  <Animated.View pointerEvents='none' style={[styles.blur, blurStyle]}>
86
- {headerHeight !== null && headerHeight > 0 && <BlurView blur={blur} style={[styles.blurInner, { height: headerHeight }]} />}
99
+ {headerHeight !== 0 && <BlurView blur={blur} style={[styles.blurInner, { height: headerHeight ?? unmeasuredHeight }]} />}
87
100
  </Animated.View>
88
101
  <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[headerHeight === null ? styles.headerInit : styles.header, translateStyle]}>
89
102
  <View style={[{ paddingTop: top }, style]}>
@@ -121,12 +134,12 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
121
134
  (effectiveBackActionFixed ? (
122
135
  <Animated.View style={leadingStyle}>
123
136
  <ActionBg blur={blur} style={actionStyle} />
124
- <Appbar.BackAction onPress={backAction} size={iconSize ?? actionSize / 2} style={buttonStyle} />
137
+ <Appbar.BackAction accessibilityLabel={backActionAccessibilityLabel} onPress={backAction} size={iconSize ?? actionSize / 2} style={buttonStyle} />
125
138
  </Animated.View>
126
139
  ) : (
127
140
  <Animated.View style={[leadingStyle, translateStyle]}>
128
141
  <ActionBg blur={blur} style={actionStyle} />
129
- <Appbar.BackAction onPress={backAction} size={iconSize ?? actionSize / 2} style={buttonStyle} />
142
+ <Appbar.BackAction accessibilityLabel={backActionAccessibilityLabel} onPress={backAction} size={iconSize ?? actionSize / 2} style={buttonStyle} />
130
143
  </Animated.View>
131
144
  ))}
132
145
  {trailingAction &&
@@ -10,6 +10,7 @@ export type ScrollViewProviderProps = {
10
10
  blur?: boolean
11
11
  children: ReactNode
12
12
  fixed?: boolean
13
+ footerAboveKeyboard?: boolean
13
14
  footerFixed?: boolean
14
15
  headerFixed?: boolean
15
16
  snapBack?: boolean
@@ -18,7 +19,7 @@ export type ScrollViewProviderProps = {
18
19
  tabBarHeight?: number
19
20
  }
20
21
 
21
- export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed, headerFixed, snapBack, snapBackFooter, snapBackHeader, tabBarHeight = 60 }: ScrollViewProviderProps) => {
22
+ export const ScrollViewProvider = ({ blur, children, fixed = false, footerAboveKeyboard = false, footerFixed, headerFixed, snapBack, snapBackFooter, snapBackHeader, tabBarHeight = 0 }: ScrollViewProviderProps) => {
22
23
  const { settings } = useContext(ScrollViewSettingsContext)
23
24
  const effectiveBlur = useBlur(blur)
24
25
  const effectiveSnapBack = snapBack ?? settings.snapBack
@@ -44,10 +45,16 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
44
45
  }, [effectiveSnapBack, effectiveSnapBackFooter, effectiveSnapBackHeader, snapBackFooterShared, snapBackHeaderShared])
45
46
  const setHeaderHeight = useCallback(
46
47
  (h: number | null) => {
48
+ // react-native-screens' web shim hides an inactive screen via display:none on an
49
+ // ancestor instead of unmounting it, so the header's ResizeObserver-backed onLayout
50
+ // keeps firing while hidden and reports a spurious 0 (no layout box), then again once
51
+ // revealed. Never let a spurious 0 clobber a real height — only a true remount
52
+ // (headerHeight reset to null) should ever legitimately shrink it back to 0.
53
+ if (h === 0 && headerHeight) return
47
54
  setHeaderHeightState(h)
48
55
  headerHeightShared.value = h ?? 0
49
56
  },
50
- [headerHeightShared]
57
+ [headerHeight, headerHeightShared]
51
58
  )
52
59
  const onListUnmount = useCallback(() => {
53
60
  listGeneration.value += 1
@@ -73,6 +80,6 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerFixed,
73
80
  const scrollHeight = useMemo(() => Dimensions.get('window').height - (headerHeight ?? 0) - footerHeight, [headerHeight, footerHeight])
74
81
  const effectiveHeaderFixed = fixed || (headerFixed ?? settings.headerFixed)
75
82
  const effectiveFooterFixed = fixed || (footerFixed ?? settings.footerFixed)
76
- const value = useMemo(() => ({ 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])
83
+ const value = useMemo(() => ({ 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])
77
84
  return <ScrollViewContext.Provider value={value}>{children}</ScrollViewContext.Provider>
78
85
  }
@@ -1,5 +1,5 @@
1
1
  import { memo, type ReactNode, type RefObject, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
2
- import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, SectionList as RNSectionList, type SectionListProps as RNSectionListProps, View } from 'react-native'
2
+ import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, Platform, SectionList as RNSectionList, type SectionListProps as RNSectionListProps, 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
 
@@ -34,7 +34,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
34
34
 
35
35
  const { stickySectionHeadersEnabled = true, renderSectionHeader, ...passThroughProps } = props as RNSectionListProps<ItemT, SectionT>
36
36
 
37
- const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
37
+ const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, hideUntilMeasured: true, keyboardAware, pullSearchHeight, style })
38
38
  const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
39
39
  // -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
40
40
  // corrects it before any scroll event could observe the placeholder.
@@ -51,8 +51,16 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
51
51
  // imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
52
52
  // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
53
53
  // only be queued after this component finishes mounting.
54
+ //
55
+ // Web has no such thread split, though — see ScrollView.tsx's own doc comment on this same
56
+ // pattern for why runOnUI's deferred-to-next-frame behavior there leaves a real gap, and why a
57
+ // synchronous assignment is the correct fix rather than a workaround.
54
58
  useLayoutEffect(() => {
55
59
  const generation = listGeneration.value
60
+ if (Platform.OS === 'web') {
61
+ capturedGeneration.value = generation
62
+ return
63
+ }
56
64
  runOnUI((gen: number) => {
57
65
  'worklet'
58
66
  capturedGeneration.value = gen
@@ -29,6 +29,11 @@ type DotProps = {
29
29
  }
30
30
 
31
31
  const Dot = ({ activeColor, index, inactiveColor, pageWidth, scrollPosition }: DotProps) => {
32
+ // Explicit array required on web even though nothing here looks platform-specific: without
33
+ // reanimated's Babel plugin active for the web bundle, useAnimatedStyle has no way to
34
+ // auto-extract dependencies and throws outright rather than silently under-tracking — omitting
35
+ // this array isn't a lesser version of the header/footer bug elsewhere in this package, it's a
36
+ // hard crash (confirmed live on the horizontal-pager demo).
32
37
  const circleStyle = useAnimatedStyle(() => {
33
38
  const page = scrollPosition.value / pageWidth
34
39
  const distance = Math.abs(index - page)
@@ -36,7 +41,7 @@ const Dot = ({ activeColor, index, inactiveColor, pageWidth, scrollPosition }: D
36
41
  const size = DOT_FULL + (DOT_SMALL - DOT_FULL) * t
37
42
  const backgroundColor = interpolateColor(t, [0, 1], [activeColor, inactiveColor])
38
43
  return { backgroundColor, borderRadius: size / 2, height: size, width: size }
39
- })
44
+ }, [scrollPosition, pageWidth, index, activeColor, inactiveColor])
40
45
  return (
41
46
  <View style={styles.dotContainer}>
42
47
  <Animated.View style={circleStyle} />
@@ -56,16 +61,19 @@ export const HorizontalDots = ({ total }: Props) => {
56
61
  const activeColor = theme.colors.primary
57
62
  const inactiveColor = theme.colors.outlineVariant
58
63
 
59
- const pillContainerStyle = useAnimatedStyle(() => ({
60
- bottom: Math.max(footerHeightShared.value, insetsBottom) + 8
61
- }))
64
+ const pillContainerStyle = useAnimatedStyle(
65
+ () => ({
66
+ bottom: Math.max(footerHeightShared.value, insetsBottom) + 8
67
+ }),
68
+ [footerHeightShared, insetsBottom]
69
+ )
62
70
 
63
71
  const rowStyle = useAnimatedStyle(() => {
64
72
  const page = scrollPosition.value / width
65
73
  const maxOffset = Math.max(0, (total - CLIP_COUNT) * DOT_STEP)
66
74
  const offset = Math.max(0, Math.min((page - (CLIP_COUNT - 1) / 2) * DOT_STEP, maxOffset))
67
75
  return { transform: [{ translateX: -offset }] }
68
- })
76
+ }, [scrollPosition, width, total])
69
77
 
70
78
  return (
71
79
  <Animated.View pointerEvents='none' style={[styles.pillContainer, pillContainerStyle]}>
@@ -15,16 +15,17 @@ const CHIP_SLIDE = 48
15
15
  export type UseScrollListOptions = {
16
16
  footerFixed?: boolean
17
17
  headerFixed?: boolean
18
+ hideUntilMeasured?: boolean
18
19
  isHorizontal?: boolean
19
20
  keyboardAware?: boolean
20
21
  pullSearchHeight?: number
21
22
  style?: StyleProp<ViewStyle>
22
23
  }
23
24
 
24
- export function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style }: UseScrollListOptions = {}) {
25
+ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, hideUntilMeasured, isHorizontal, keyboardAware, pullSearchHeight, style }: UseScrollListOptions = {}) {
25
26
  const insets = useSafeAreaInsets()
26
27
  const keyboardHeight = useKeyboardInset()
27
- const { footerHeight, footerFixed: contextFooterFixed, headerHeight, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared } = useContext(ScrollViewContext)
28
+ const { footerAboveKeyboard, footerHeight, footerFixed: contextFooterFixed, headerHeight, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared, tabBarHeight } = useContext(ScrollViewContext)
28
29
 
29
30
  const headerFixed = isHorizontal ? true : (headerFixedProp ?? contextHeaderFixed)
30
31
  const footerFixed = isHorizontal ? true : (footerFixedProp ?? contextFooterFixed)
@@ -33,12 +34,36 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
33
34
  pullSearchHeightShared.value = usesContentInset ? (pullSearchHeight ?? 0) : 0
34
35
  }, [pullSearchHeight, pullSearchHeightShared])
35
36
 
36
- // First render (headerHeight===0): flex:1, hidden until header measures so the key=0→1
37
- // transition (and contentOffset change) never appears as a visible jump.
38
- // After header measures: absoluteFill with correct contentOffset — then reveal.
39
- const containerStyle = useMemo(() => (headerHeight === null ? [{ flex: 1, opacity: 0 }, style] : [StyleSheet.absoluteFill, style]), [headerHeight, style])
37
+ // First render (headerHeight===null): flex:1, sitting naturally below the in-flow header —
38
+ // same visual position content ends up in after the swap below (absoluteFill + paddingTop
39
+ // matching the now-measured headerHeight), so no hiding is needed for this transition on its
40
+ // own. hideUntilMeasured is only for SectionList: its key={headerHeight>0?1:0} forces a real
41
+ // remount of the underlying native list on this exact transition, and that remount is what
42
+ // needs hiding, not this container swap itself — ScrollView/FlatList/CustomList never remount
43
+ // here, so unconditionally hiding this container regressed a real "flash of blank" on every
44
+ // fresh mount for them (2026-08-03).
45
+ const containerStyle = useMemo(() => (headerHeight === null ? [{ flex: 1, opacity: hideUntilMeasured ? 0 : 1 }, style] : [StyleSheet.absoluteFill, style]), [headerHeight, hideUntilMeasured, style])
40
46
 
41
- const insetGeometry = useMemo(() => ({ bottom: (footerFixed ? footerHeight || insets.bottom : insets.bottom) + (keyboardAware ? keyboardHeight : 0), top: headerHeight ?? 0 }), [footerFixed, footerHeight, insets.bottom, headerHeight, keyboardAware, keyboardHeight])
47
+ // tabBarHeight reserves space for a consuming app's own persistent tab bar - a fixture outside
48
+ // this package's header/footer concepts entirely, so it's added on top of whichever base the
49
+ // footerFixed branch below resolves to (footerHeight already bakes in insets.bottom itself; see
50
+ // ScrollViewFooter's own paddingBottom).
51
+ //
52
+ // When footerAboveKeyboard opts the (fixed) footer into floating above the keyboard instead of
53
+ // sitting behind it, footerHeight ALREADY includes the current keyboardHeight — ScrollViewFooter
54
+ // grows its own container by keyboardHeight to float (see its containerPaddingBottom), rather
55
+ // than translating a fixed-height bar, so onLayout reports the combined height. Adding
56
+ // keyboardHeight again here would double-count it. Otherwise (not floating) ScrollViewFooter pins
57
+ // itself with plain `position: 'absolute', bottom: 0` with no keyboard-awareness of its own, so
58
+ // an open keyboard simply covers it rather than stacking above it - reserving
59
+ // footerReserve + keyboardHeight in that case would leave a gap the size of the now-hidden footer
60
+ // between the focused input and the keyboard, so max() reserves only whichever bottom obstruction
61
+ // is actually taller right now.
62
+ const insetGeometry = useMemo(() => {
63
+ const footerReserve = footerFixed ? footerHeight || insets.bottom : insets.bottom
64
+ const bottom = (!keyboardAware || (footerFixed && footerAboveKeyboard) ? footerReserve : Math.max(footerReserve, keyboardHeight)) + tabBarHeight
65
+ return { bottom, top: headerHeight ?? 0 }
66
+ }, [footerAboveKeyboard, footerFixed, footerHeight, insets.bottom, tabBarHeight, headerHeight, keyboardAware, keyboardHeight])
42
67
  // Outside inset mode the same geometry is applied as content padding instead; contentInset is
43
68
  // zeroed (not omitted) so consumers' offset math — scroll-to-top targets, minHeight — stays
44
69
  // correct in the raw 0-based coordinate space those platforms actually scroll in.
@@ -54,6 +79,10 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
54
79
  const contentOffset = usesContentInset ? insetContentOffset : ZERO_OFFSET
55
80
 
56
81
  const chipHidden = useSharedValue(1)
82
+ // chipHidden/snapBackHeaderShared/headerOffset/scrollPosition/pullSearchHeightShared all need to
83
+ // be listed here too, not just read — see ScrollViewHeader's translateStyle for why (web-only
84
+ // reanimated reactivity gap: a SharedValue's mutations don't retrigger useAnimatedStyle unless
85
+ // the value itself is in this array).
57
86
  const chipStyle = useAnimatedStyle(() => {
58
87
  const pointerEvents = chipHidden.value ? ('none' as const) : ('box-none' as const)
59
88
  if (isHorizontal) {
@@ -73,7 +102,7 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
73
102
  top,
74
103
  transform: [{ translateY: chipHidden.value ? withTiming(-CHIP_SLIDE) : withTiming(0) }]
75
104
  }
76
- }, [isHorizontal, headerFixed, headerHeight, insets.top])
105
+ }, [isHorizontal, headerFixed, headerHeight, insets.top, chipHidden, snapBackHeaderShared, headerOffset, scrollPosition, pullSearchHeightShared])
77
106
 
78
107
  return { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight, insets, isHorizontal: isHorizontal ?? false }
79
108
  }
@@ -62,11 +62,21 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
62
62
  (curr, prev) => {
63
63
  if (curr !== prev) runOnJS(setActiveIndex)(curr)
64
64
  },
65
- [enabled, headerFixed, insetsTop]
65
+ // Same web-only reanimated gap as useAnimatedStyle elsewhere in this file: this "prepare"
66
+ // function's own SharedValue reads (sectionYPositions, headerHeightShared, scrollPosition,
67
+ // snapBackHeaderShared, headerOffset, pullSearchHeightShared) need to be listed explicitly too
68
+ // — confirmed empirically that without them, useAnimatedReaction re-evaluates once on mount and
69
+ // then never again on web, permanently freezing activeIndex/the active sticky section.
70
+ [enabled, headerFixed, insetsTop, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared]
66
71
  )
67
72
 
68
73
  // Positions the clip container at stickyTop so overflow:hidden hides the outgoing
69
74
  // header as it slides above the sticky line during a push transition.
75
+ // Every SharedValue read below (headerHeightShared, scrollPosition, snapBackHeaderShared,
76
+ // headerOffset, pullSearchHeightShared, and for overlayStyle also contentIndexShared/
77
+ // sectionYPositions/sectionHeights) has to be listed in the array too — see
78
+ // ScrollViewHeader's translateStyle for why: on web, reanimated doesn't treat mutations to a
79
+ // SharedValue as a reactive trigger for useAnimatedStyle unless it's in this array.
70
80
  const clipStyle = useAnimatedStyle(() => {
71
81
  const hHeight = headerHeightShared.value
72
82
  const scrollPos = scrollPosition.value
@@ -80,7 +90,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
80
90
  stickyTop = effective <= 0 ? hHeight : Math.max(insetsTop, hHeight - effective)
81
91
  }
82
92
  return { top: stickyTop }
83
- }, [enabled, headerFixed, insetsTop])
93
+ }, [enabled, headerFixed, insetsTop, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared])
84
94
 
85
95
  // overlayStyle positions the header at top:0 relative to the clip container.
86
96
  // The push animation uses 1:1 speed with pushThreshold = stickyTop + currentHeight
@@ -118,7 +128,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
118
128
  }
119
129
 
120
130
  return { opacity: 1, top: 0, transform: [{ translateY }] }
121
- }, [enabled, headerFixed, insetsTop])
131
+ }, [enabled, headerFixed, insetsTop, contentIndexShared, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared, sectionHeights])
122
132
 
123
133
  // pageY is the view's window-coordinate Y from view.measure().
124
134
  // contentY = pageY + contentOffset.y, since screenY = contentY - contentOffset.y.
@@ -160,6 +160,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
160
160
  return () => clearTimeout(fallback)
161
161
  // Intentionally runs once per mount only, using whatever remountTarget was captured at
162
162
  // construction — not a dep that should retrigger this effect.
163
+ // eslint-disable-next-line react-hooks/exhaustive-deps
163
164
  }, [])
164
165
 
165
166
  // Transition to 'ready' once both measurements are available