@rific/scroll-view 0.4.3 → 0.5.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.
@@ -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