@rific/scroll-view 0.2.0 → 0.4.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 (35) hide show
  1. package/README.md +2 -0
  2. package/dist/index.d.mts +23 -17
  3. package/dist/index.d.ts +23 -17
  4. package/dist/index.js +720 -1960
  5. package/dist/index.mjs +697 -1939
  6. package/package.json +10 -10
  7. package/src/CustomList.tsx +32 -8
  8. package/src/FlatList.tsx +102 -20
  9. package/src/PullSearch.tsx +4 -3
  10. package/src/ScrollView.tsx +28 -8
  11. package/src/ScrollViewContext.ts +17 -4
  12. package/src/ScrollViewHeader.tsx +34 -31
  13. package/src/ScrollViewProvider.tsx +22 -6
  14. package/src/SectionList.tsx +31 -10
  15. package/src/internal/useScrollHandler.ts +19 -21
  16. package/src/internal/useScrollHandlerJS.ts +141 -0
  17. package/src/internal/useScrollList.ts +16 -11
  18. package/src/redux/scrollViewSlice.ts +19 -11
  19. package/src/useScrollInit.ts +200 -19
  20. package/src/__mocks__/auto-paper.ts +0 -9
  21. package/src/__mocks__/flash-list.ts +0 -20
  22. package/src/__mocks__/react-native-gesture-handler.ts +0 -13
  23. package/src/__mocks__/react-native-keyboard-controller.ts +0 -11
  24. package/src/__mocks__/react-native-paper.ts +0 -22
  25. package/src/__mocks__/react-native-reanimated.ts +0 -30
  26. package/src/__mocks__/react-native-safe-area-context.ts +0 -2
  27. package/src/__mocks__/react-native.ts +0 -30
  28. package/src/__tests__/CustomList.test.tsx +0 -61
  29. package/src/__tests__/FlatList.test.tsx +0 -52
  30. package/src/__tests__/PullSearch.test.tsx +0 -38
  31. package/src/__tests__/ScrollView.test.tsx +0 -23
  32. package/src/__tests__/ScrollViewHeader.test.tsx +0 -28
  33. package/src/__tests__/ScrollViewProvider.test.tsx +0 -65
  34. package/src/__tests__/SectionList.test.tsx +0 -88
  35. package/src/out.txt +0 -61
@@ -1,16 +1,17 @@
1
- import { memo, type ReactNode, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
1
+ import { memo, type ReactNode, type RefObject, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
2
2
  import { Dimensions, type NativeScrollEvent, type NativeSyntheticEvent, SectionList as RNSectionList, type SectionListProps as RNSectionListProps, View } from 'react-native'
3
3
  import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
4
- import Animated, { createAnimatedComponent } from 'react-native-reanimated'
4
+ import Animated, { runOnUI, useSharedValue } from 'react-native-reanimated'
5
5
 
6
6
  import { RefreshControl } from './internal/RefreshControl'
7
7
  import { type ChipProps, ScrollViewChip } from './internal/ScrollViewChip'
8
8
  import { useScrollHandler } from './internal/useScrollHandler'
9
9
  import { useScrollList } from './internal/useScrollList'
10
10
  import { useStickyHeaders } from './internal/useStickyHeaders'
11
+ import { ScrollViewContext } from './ScrollViewContext'
11
12
  import { useScrollInit } from './useScrollInit'
12
13
 
13
- const AnimatedSectionList = createAnimatedComponent(RNSectionList) as unknown as typeof RNSectionList
14
+ const AnimatedSectionList = Animated.createAnimatedComponent(RNSectionList) as unknown as typeof RNSectionList
14
15
 
15
16
  export type SectionListProps<ItemT, SectionT extends { data: ReadonlyArray<ItemT> } = { data: ReadonlyArray<ItemT> }> = Omit<RNSectionListProps<ItemT, SectionT>, 'horizontal'> & {
16
17
  chipProps?: ChipProps
@@ -34,6 +35,29 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
34
35
  const { stickySectionHeadersEnabled = true, renderSectionHeader, ...passThroughProps } = props as RNSectionListProps<ItemT, SectionT>
35
36
 
36
37
  const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
38
+ const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
39
+ // -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
40
+ // corrects it before any scroll event could observe the placeholder.
41
+ const capturedGeneration = useSharedValue(-1)
42
+ // Layout effect (not passive effect): React guarantees all layout-effect cleanups for fibers
43
+ // removed in a commit run before any layout-effect setups for fibers added in that same commit,
44
+ // but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
45
+ // instance's unmount and the incoming instance's mount land in the same commit — with passive
46
+ // effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
47
+ // capturing a stale generation and silently disabling this instance's onScroll handling forever.
48
+ // Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
49
+ // on the UI thread, and a plain JS-thread write to a SharedValue is not guaranteed to be visible
50
+ // there before a scroll event the UI thread schedules shortly after (e.g. useScrollInit's mount-time
51
+ // imperative scrollTo calls). Routing the write itself through runOnUI puts it on the UI thread's
52
+ // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
53
+ // only be queued after this component finishes mounting.
54
+ useLayoutEffect(() => {
55
+ const generation = listGeneration.value
56
+ runOnUI((gen: number) => {
57
+ 'worklet'
58
+ capturedGeneration.value = gen
59
+ })(generation)
60
+ }, [capturedGeneration, listGeneration])
37
61
 
38
62
  const usesCustomSticky = stickySectionHeadersEnabled && renderSectionHeader != null
39
63
  const { activeIndex, clipStyle, measureHeader, overlayStyle, resetPositions } = useStickyHeaders(headerFixed, usesCustomSticky)
@@ -70,6 +94,8 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
70
94
  }
71
95
  }, [usesCustomSticky, renderSectionHeader, sections, measureHeader])
72
96
 
97
+ useLayoutEffect(() => () => onListUnmount(), [onListUnmount])
98
+
73
99
  const scrollTo = useCallback((offset: number, animated: boolean) => {
74
100
  scrollView.current?.getScrollResponder()?.scrollTo({ y: offset, animated })
75
101
  }, [])
@@ -120,12 +146,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
120
146
 
121
147
  const contentContainerStyle = useMemo(() => [{ minHeight: Dimensions.get('window').height - contentInset.top - contentInset.bottom + pullSearchMinHeight }, externalContentContainerStyle], [contentInset.bottom, contentInset.top, externalContentContainerStyle, pullSearchMinHeight])
122
148
 
123
- const onPullSearchZoneEnter = useCallback(() => {
124
- if (!pullSearchHeight) return
125
- scrollTo(-contentInset.top + pullSearchHeight, true)
126
- }, [contentInset.top, pullSearchHeight, scrollTo])
127
-
128
- const handleScroll = useScrollHandler({ chipHidden, chipThreshold, footerFixed, headerFixed, onPullSearchZoneEnter: pullSearchHeight ? onPullSearchZoneEnter : undefined })
149
+ const handleScroll = useScrollHandler({ capturedGeneration, chipHidden, chipThreshold, footerFixed, headerFixed, listGeneration })
129
150
 
130
151
  const handleScrollToTop = useCallback(() => {
131
152
  const offset = pullSearchHeight ? -contentInset.top + pullSearchHeight : -contentInset.top
@@ -147,7 +168,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
147
168
  const content = (
148
169
  <View style={containerStyle}>
149
170
  <AnimatedSectionList
150
- key={headerHeight > 0 ? 1 : 0}
171
+ key={headerHeight !== null && headerHeight > 0 ? 1 : 0}
151
172
  {...passThroughProps}
152
173
  contentContainerStyle={contentContainerStyle}
153
174
  contentInset={contentInset}
@@ -3,27 +3,41 @@ import type { SharedValue } from 'react-native-reanimated'
3
3
  import { runOnJS, useAnimatedScrollHandler, useSharedValue, withTiming } from 'react-native-reanimated'
4
4
 
5
5
  import { ScrollViewContext } from '../ScrollViewContext'
6
+ import { REMOUNT_SYNC_TOLERANCE } from '../useScrollInit'
6
7
 
7
8
  type UseScrollHandlerOptions = {
9
+ capturedGeneration: SharedValue<number>
8
10
  chipHidden: SharedValue<number>
9
11
  chipThreshold?: number
10
12
  footerFixed: boolean
11
13
  headerFixed: boolean
12
14
  isHorizontal?: boolean
13
- onPullSearchZoneEnter?: () => void
15
+ listGeneration: SharedValue<number>
16
+ onRemountSyncRetry?: (currentY: number) => void
17
+ onRemountSynced?: (y: number) => void
18
+ remountSyncTarget?: SharedValue<number | null>
14
19
  }
15
20
 
16
- export function useScrollHandler({ chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, onPullSearchZoneEnter }: UseScrollHandlerOptions) {
21
+ export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget }: UseScrollHandlerOptions) {
17
22
  const { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
18
23
  const snapUpAccum = useSharedValue(0)
19
24
  const fromBottomBounce = useSharedValue(false)
20
- const isMomentum = useSharedValue(false)
21
- const momentumPullSearchFired = useSharedValue(false)
22
25
 
23
26
  return useAnimatedScrollHandler(
24
27
  {
25
28
  onScroll: ({ contentOffset: { x, y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
26
29
  'worklet'
30
+ if (remountSyncTarget && remountSyncTarget.value !== null) {
31
+ const target = remountSyncTarget.value
32
+ if (Math.abs(y - target) < REMOUNT_SYNC_TOLERANCE) {
33
+ remountSyncTarget.value = null
34
+ if (onRemountSynced) runOnJS(onRemountSynced)(y)
35
+ } else if (onRemountSyncRetry) {
36
+ runOnJS(onRemountSyncRetry)(y)
37
+ }
38
+ return
39
+ }
40
+ if (listGeneration.value !== capturedGeneration.value) return
27
41
  if (isHorizontal) {
28
42
  scrollPosition.value = x
29
43
  chipHidden.value = x < chipThreshold ? 1 : 0
@@ -39,13 +53,6 @@ export function useScrollHandler({ chipHidden, chipThreshold = 100, footerFixed,
39
53
  } else if (fromBottomBounce.value) {
40
54
  fromBottomBounce.value = false
41
55
  }
42
- if (isMomentum.value && pullSearchHeightShared.value > 0 && !momentumPullSearchFired.value) {
43
- const hideY = -headerHeightShared.value + pullSearchHeightShared.value
44
- if (y < hideY) {
45
- momentumPullSearchFired.value = true
46
- if (onPullSearchZoneEnter) runOnJS(onPullSearchZoneEnter)()
47
- }
48
- }
49
56
  const snapHeader = snapBackHeaderShared.value && !headerFixed
50
57
  const snapFooter = snapBackFooterShared.value && !footerFixed
51
58
  if (snapHeader || snapFooter) {
@@ -71,16 +78,9 @@ export function useScrollHandler({ chipHidden, chipThreshold = 100, footerFixed,
71
78
  },
72
79
  onBeginDrag: () => {
73
80
  'worklet'
74
- isMomentum.value = false
75
- momentumPullSearchFired.value = false
76
81
  fromBottomBounce.value = false
77
82
  snapUpAccum.value = 0
78
83
  },
79
- onMomentumBegin: () => {
80
- 'worklet'
81
- isMomentum.value = true
82
- momentumPullSearchFired.value = false
83
- },
84
84
  onEndDrag: ({ contentOffset: { y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
85
85
  'worklet'
86
86
  if (isHorizontal) return
@@ -91,12 +91,10 @@ export function useScrollHandler({ chipHidden, chipThreshold = 100, footerFixed,
91
91
  },
92
92
  onMomentumEnd: () => {
93
93
  'worklet'
94
- isMomentum.value = false
95
- momentumPullSearchFired.value = false
96
94
  fromBottomBounce.value = false
97
95
  snapUpAccum.value = 0
98
96
  }
99
97
  },
100
- [headerFixed, footerFixed, isHorizontal, chipThreshold, onPullSearchZoneEnter]
98
+ [capturedGeneration, headerFixed, footerFixed, isHorizontal, chipThreshold, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget]
101
99
  )
102
100
  }
@@ -0,0 +1,141 @@
1
+ import { type RefObject, useCallback, useContext, useRef } from 'react'
2
+ import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'
3
+ import type { SharedValue } from 'react-native-reanimated'
4
+ import { withTiming } from 'react-native-reanimated'
5
+
6
+ import { ScrollViewContext } from '../ScrollViewContext'
7
+
8
+ const REMOUNT_RETRY_TOLERANCE = 1
9
+ const REMOUNT_RETRY_MAX_ATTEMPTS = 8
10
+
11
+ type UseScrollHandlerJSOptions = {
12
+ capturedGeneration: RefObject<number>
13
+ chipHidden: SharedValue<number>
14
+ chipThreshold?: number
15
+ footerFixed: boolean
16
+ headerFixed: boolean
17
+ isHorizontal?: boolean
18
+ jsListGeneration: RefObject<number>
19
+ remountTarget?: number | null
20
+ scrollTo?: (offset: number, animated: boolean) => void
21
+ }
22
+
23
+ export type ScrollHandlerJS = {
24
+ onMomentumScrollBegin: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
25
+ onMomentumScrollEnd: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
26
+ onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
27
+ onScrollBeginDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
28
+ onScrollEndDrag: (e: NativeSyntheticEvent<NativeScrollEvent>) => void
29
+ }
30
+
31
+ // Plain JS-thread scroll handler for a non-Animated scrollable. Reanimated shared values are
32
+ // readable/writable from any thread, so driving them via a regular onScroll callback (rather than
33
+ // a useAnimatedScrollHandler worklet, which requires an Animated-wrapped component to attach to at
34
+ // the native level) works fine here — the header/footer animations just pick up the change on
35
+ // their next UI-thread frame. This trades a small amount of latency for a plain scrollable that
36
+ // reliably honors the initial `contentOffset` prop on (re)mount, which Animated.FlatList does not.
37
+ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, jsListGeneration, remountTarget = null, scrollTo }: UseScrollHandlerJSOptions): ScrollHandlerJS {
38
+ const { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
39
+ const snapUpAccum = useRef(0)
40
+ const fromBottomBounce = useRef(false)
41
+ // On a remount, the very first native position after mount isn't reliable — Fabric can skip
42
+ // reapplying `contentOffset` when the target is numerically unchanged from before, and native
43
+ // scroll settling can overshoot or undershoot it. Rather than trust either the declarative prop
44
+ // or a single imperative scrollTo call, keep correcting (without hiding anything — content stays
45
+ // visible throughout) until an onScroll event actually confirms the intended resting position.
46
+ const pendingRemountTarget = useRef(remountTarget)
47
+ const remountAttempts = useRef(0)
48
+
49
+ const onScroll = useCallback(
50
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
51
+ if (jsListGeneration.current !== capturedGeneration.current) return
52
+ const {
53
+ contentOffset: { x, y },
54
+ contentSize: { height: contentHeight },
55
+ layoutMeasurement: { height: layoutHeight }
56
+ } = event.nativeEvent
57
+ if (pendingRemountTarget.current !== null) {
58
+ const target = pendingRemountTarget.current
59
+ if (Math.abs(y - target) < REMOUNT_RETRY_TOLERANCE) {
60
+ pendingRemountTarget.current = null
61
+ } else {
62
+ remountAttempts.current += 1
63
+ if (remountAttempts.current > REMOUNT_RETRY_MAX_ATTEMPTS) {
64
+ pendingRemountTarget.current = null
65
+ } else {
66
+ scrollTo?.(target, false)
67
+ return
68
+ }
69
+ }
70
+ }
71
+ if (isHorizontal) {
72
+ scrollPosition.value = x
73
+ chipHidden.value = x < chipThreshold ? 1 : 0
74
+ return
75
+ }
76
+ const delta = y - scrollPosition.value
77
+ scrollPosition.value = y
78
+ chipHidden.value = y < chipThreshold ? 1 : 0
79
+ const maxScroll = contentHeight - layoutHeight
80
+ if (maxScroll > 0 && y >= maxScroll) {
81
+ fromBottomBounce.current = true
82
+ snapUpAccum.current = 0
83
+ } else if (fromBottomBounce.current) {
84
+ fromBottomBounce.current = false
85
+ }
86
+ const snapHeader = snapBackHeaderShared.value && !headerFixed
87
+ const snapFooter = snapBackFooterShared.value && !footerFixed
88
+ if (snapHeader || snapFooter) {
89
+ if (y <= -headerHeightShared.value) {
90
+ snapUpAccum.current = 0
91
+ if (snapHeader) headerOffset.value = 0
92
+ if (snapFooter) footerOffset.value = 0
93
+ } else if (delta > 0) {
94
+ snapUpAccum.current = 0
95
+ if (y >= -headerHeightShared.value + pullSearchHeightShared.value) {
96
+ if (snapHeader) headerOffset.value = Math.max(-headerHeightShared.value, Math.min(0, headerOffset.value - delta))
97
+ if (snapFooter) footerOffset.value = Math.max(0, Math.min(footerHeightShared.value, footerOffset.value + delta))
98
+ }
99
+ } else if (delta < 0 && !fromBottomBounce.current) {
100
+ if (y >= -headerHeightShared.value + pullSearchHeightShared.value) snapUpAccum.current -= delta
101
+ if (snapUpAccum.current >= 10) {
102
+ snapUpAccum.current = 999
103
+ if (snapHeader) headerOffset.value = withTiming(0, { duration: 200 })
104
+ if (snapFooter) footerOffset.value = withTiming(0, { duration: 200 })
105
+ }
106
+ }
107
+ }
108
+ },
109
+ [capturedGeneration, chipHidden, chipThreshold, footerFixed, footerHeightShared, footerOffset, headerFixed, headerHeightShared, headerOffset, isHorizontal, jsListGeneration, pullSearchHeightShared, scrollPosition, scrollTo, snapBackFooterShared, snapBackHeaderShared]
110
+ )
111
+
112
+ const onScrollBeginDrag = useCallback(() => {
113
+ fromBottomBounce.current = false
114
+ snapUpAccum.current = 0
115
+ }, [])
116
+
117
+ const onMomentumScrollBegin = useCallback(() => {}, [])
118
+
119
+ const onScrollEndDrag = useCallback(
120
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
121
+ if (isHorizontal) return
122
+ const {
123
+ contentOffset: { y },
124
+ contentSize: { height: contentHeight },
125
+ layoutMeasurement: { height: layoutHeight }
126
+ } = event.nativeEvent
127
+ if (y >= contentHeight - layoutHeight - 10) {
128
+ fromBottomBounce.current = true
129
+ snapUpAccum.current = 0
130
+ }
131
+ },
132
+ [isHorizontal]
133
+ )
134
+
135
+ const onMomentumScrollEnd = useCallback(() => {
136
+ fromBottomBounce.current = false
137
+ snapUpAccum.current = 0
138
+ }, [])
139
+
140
+ return { onMomentumScrollBegin, onMomentumScrollEnd, onScroll, onScrollBeginDrag, onScrollEndDrag }
141
+ }
@@ -1,4 +1,4 @@
1
- import { useContext, useEffect, useMemo } from 'react'
1
+ import { useContext, useEffect, useMemo, useState } from 'react'
2
2
  import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native'
3
3
  import { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'
4
4
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -29,15 +29,19 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
29
29
  pullSearchHeightShared.value = pullSearchHeight ?? 0
30
30
  }, [pullSearchHeight, pullSearchHeightShared])
31
31
 
32
- // First render (headerHeight===0): flex:1 so the list sits naturally below the in-flow header.
33
- // After header measures: absoluteFill with contentOffset to match the same visual position.
34
- const containerStyle = useMemo(() => (headerHeight === 0 ? [{ flex: 1 }, style] : [StyleSheet.absoluteFill, style]), [headerHeight, style])
32
+ // First render (headerHeight===0): flex:1, hidden until header measures so the key=0→1
33
+ // transition (and contentOffset change) never appears as a visible jump.
34
+ // After header measures: absoluteFill with correct contentOffset — then reveal.
35
+ const containerStyle = useMemo(() => (headerHeight === null ? [{ flex: 1, opacity: 0 }, style] : [StyleSheet.absoluteFill, style]), [headerHeight, style])
35
36
 
36
- const contentInset = useMemo(() => ({ bottom: (footerFixed ? footerHeight || insets.bottom : insets.bottom) + (keyboardAware ? keyboardHeight : 0), top: headerHeight }), [footerFixed, footerHeight, insets.bottom, headerHeight, keyboardAware, keyboardHeight])
37
+ const contentInset = useMemo(() => ({ bottom: (footerFixed ? footerHeight || insets.bottom : insets.bottom) + (keyboardAware ? keyboardHeight : 0), top: headerHeight ?? 0 }), [footerFixed, footerHeight, insets.bottom, headerHeight, keyboardAware, keyboardHeight])
37
38
 
38
- // Synchronously positions the scroll view to match the in-flow layout from the first render.
39
- // Fabric applies this atomically with the containerStyle switch, so there is no visible jump.
40
- const contentOffset = useMemo(() => ({ x: 0, y: -contentInset.top }), [contentInset.top])
39
+ // Use hideY offset only when this component mounted with headerHeight already known (mode switch).
40
+ // On initial load, headerHeight is null at mount so we start at showY and let useScrollInit
41
+ // call scrollTo after the phase transition. On mode switch, headerHeight is already set so we
42
+ // can position the FlatList at hideY atomically via contentOffset — no scrollTo needed.
43
+ const [startedWithHeader] = useState(headerHeight !== null)
44
+ const contentOffset = useMemo(() => ({ x: 0, y: headerHeight !== null ? -headerHeight + (startedWithHeader && !isHorizontal && pullSearchHeight ? pullSearchHeight : 0) : 0 }), [headerHeight, isHorizontal, pullSearchHeight, startedWithHeader])
41
45
 
42
46
  const chipHidden = useSharedValue(1)
43
47
  const chipStyle = useAnimatedStyle(() => {
@@ -46,12 +50,13 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
46
50
  return {
47
51
  opacity: chipHidden.value ? withTiming(0) : withTiming(1),
48
52
  pointerEvents,
49
- top: Math.max(headerHeight, insets.top) + 4,
53
+ top: Math.max(headerHeight ?? 0, insets.top) + 4,
50
54
  transform: [{ translateX: chipHidden.value ? withTiming(-CHIP_SLIDE) : withTiming(0) }]
51
55
  }
52
56
  }
53
- const slide = headerFixed ? 0 : snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + headerHeight - pullSearchHeightShared.value)
54
- const top = Math.max(headerHeight - slide, insets.top) + 4
57
+ const h = headerHeight ?? 0
58
+ const slide = headerFixed ? 0 : snapBackHeaderShared.value ? -headerOffset.value : Math.max(0, scrollPosition.value + h - pullSearchHeightShared.value)
59
+ const top = Math.max(h - slide, insets.top) + 4
55
60
  return {
56
61
  opacity: chipHidden.value ? withTiming(0) : withTiming(1),
57
62
  pointerEvents,
@@ -1,14 +1,22 @@
1
- import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
2
-
3
1
  import { defaultScrollViewSettings, type ScrollViewSettings } from '../ScrollViewSettingsContext'
4
2
 
5
- const slice = createSlice({
6
- name: 'scrollView',
7
- initialState: defaultScrollViewSettings,
8
- reducers: {
9
- initialize: (_state, action: PayloadAction<ScrollViewSettings>) => action.payload
10
- }
11
- })
3
+ // Hand-rolled slice — no @reduxjs/toolkit dependency. Action types and creator
4
+ // behavior match the previous createSlice implementation exactly, so this works
5
+ // with RTK stores, vanilla Redux, or any reducer-based state container.
6
+ type PayloadAction<P> = { payload: P; type: string }
7
+
8
+ const createAction = <P>(type: string) => {
9
+ const actionCreator = (payload: P): PayloadAction<P> => ({ payload, type })
10
+ actionCreator.type = type
11
+ actionCreator.match = (action: { type: string }): action is PayloadAction<P> => action.type === type
12
+ return actionCreator
13
+ }
14
+
15
+ const initialize = createAction<ScrollViewSettings>('scrollView/initialize')
16
+
17
+ export const scrollViewActions = { initialize }
12
18
 
13
- export const scrollViewActions = slice.actions
14
- export const scrollViewReducer = slice.reducer
19
+ export const scrollViewReducer = (state: ScrollViewSettings = defaultScrollViewSettings, action: { type: string }): ScrollViewSettings => {
20
+ if (initialize.match(action)) return action.payload
21
+ return state
22
+ }