@rific/scroll-view 0.8.3 → 0.9.1
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/LICENSE +21 -0
- package/README.md +199 -25
- package/dist/index.d.mts +21 -3
- package/dist/index.d.ts +21 -3
- package/dist/index.js +335 -156
- package/dist/index.mjs +311 -136
- package/package.json +4 -4
- package/src/CustomList.tsx +3 -3
- package/src/FlatList.tsx +4 -4
- package/src/PullSearch.tsx +1 -1
- package/src/ScrollView.tsx +4 -4
- package/src/ScrollViewChromeContext.ts +37 -0
- package/src/ScrollViewChromeProvider.tsx +65 -0
- package/src/ScrollViewContext.ts +17 -1
- package/src/ScrollViewFooter.tsx +56 -20
- package/src/ScrollViewHeader.tsx +31 -10
- package/src/ScrollViewProvider.tsx +100 -27
- package/src/SectionList.tsx +4 -4
- package/src/index.ts +3 -0
- package/src/internal/ChromeRegistrar.tsx +37 -0
- package/src/internal/HorizontalDots.tsx +1 -1
- package/src/internal/ScrollViewChip.tsx +3 -3
- package/src/internal/chrome.ts +19 -0
- package/src/internal/useScrollHandler.ts +26 -5
- package/src/internal/useScrollHandlerJS.ts +34 -10
- package/src/internal/useScrollList.ts +29 -12
- package/src/internal/useStickyHeaders.ts +3 -3
- package/src/redux/scrollViewSlice.ts +1 -1
- package/src/useKeyboardInset.ts +1 -1
- package/src/useScrollInit.ts +12 -12
- package/src/useScrollViewChrome.ts +41 -0
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { useBlur } from '@rific/auto-paper'
|
|
2
|
-
import { type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { type ReactNode, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
3
3
|
import { Dimensions, Platform } from 'react-native'
|
|
4
4
|
import { useSharedValue } from 'react-native-reanimated'
|
|
5
5
|
|
|
6
|
+
import { CHROME_SLACK } from './internal/chrome'
|
|
7
|
+
import { ChromeRegistrar } from './internal/ChromeRegistrar'
|
|
8
|
+
import { ScrollViewChromeHostContext } from './ScrollViewChromeContext'
|
|
6
9
|
import { ScrollViewContext } from './ScrollViewContext'
|
|
7
10
|
import { ScrollViewSettingsContext } from './ScrollViewSettingsContext'
|
|
8
11
|
|
|
9
12
|
export type ScrollViewProviderProps = {
|
|
10
13
|
blur?: boolean
|
|
14
|
+
// Opt out of an enclosing ScrollViewChromeProvider. A screen inside a hosted navigator joins the
|
|
15
|
+
// host by default; a nested provider that is NOT a screen (a picker or form modal - Paper's Portal
|
|
16
|
+
// can re-parent those above the clearing this provider does for its own children) has to say so.
|
|
17
|
+
chrome?: boolean
|
|
11
18
|
children: ReactNode
|
|
12
19
|
fixed?: boolean
|
|
13
20
|
footerAboveKeyboard?: boolean
|
|
@@ -19,8 +26,14 @@ export type ScrollViewProviderProps = {
|
|
|
19
26
|
tabBarHeight?: number
|
|
20
27
|
}
|
|
21
28
|
|
|
22
|
-
export const ScrollViewProvider = ({ blur, children, fixed = false, footerAboveKeyboard = false, footerFixed, headerFixed, snapBack, snapBackFooter, snapBackHeader, tabBarHeight = 0 }: ScrollViewProviderProps) => {
|
|
29
|
+
export const ScrollViewProvider = ({ blur, chrome, children, fixed = false, footerAboveKeyboard = false, footerFixed, headerFixed, snapBack, snapBackFooter, snapBackHeader, tabBarHeight = 0 }: ScrollViewProviderProps) => {
|
|
23
30
|
const { settings } = useContext(ScrollViewSettingsContext)
|
|
31
|
+
const enclosingHost = useContext(ScrollViewChromeHostContext)
|
|
32
|
+
const chromeHost = chrome === false ? null : enclosingHost
|
|
33
|
+
const chromeHosted = chromeHost !== null
|
|
34
|
+
const chromeFootprint = chromeHost?.footprint ?? 0
|
|
35
|
+
const chromeOverhang = chromeHost?.overhang ?? 0
|
|
36
|
+
const chromeSnapBack = chromeHost?.snapBack ?? false
|
|
24
37
|
const effectiveBlur = useBlur(blur)
|
|
25
38
|
const effectiveSnapBack = snapBack ?? settings.snapBack
|
|
26
39
|
const effectiveSnapBackHeader = snapBackHeader ?? settings.snapBackHeader
|
|
@@ -35,30 +48,76 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerAboveK
|
|
|
35
48
|
const headerHeightShared = useSharedValue(0)
|
|
36
49
|
const footerHeightShared = useSharedValue(0)
|
|
37
50
|
const headerOffset = useSharedValue(0)
|
|
38
|
-
const
|
|
51
|
+
const ownFooterOffset = useSharedValue(0)
|
|
52
|
+
const ownStackHeight = useSharedValue(0)
|
|
53
|
+
// A hosted provider writes the host's ONE offset instead of its own, so the persistent bar and this
|
|
54
|
+
// screen's footer read literally the same SharedValue. Starts unwritable when hosted: the
|
|
55
|
+
// registrar flips it once this screen actually owns the host, so a mounted-but-unfocused screen
|
|
56
|
+
// (Tabs and Stacks keep visited screens mounted) can never fight the focused one for it.
|
|
57
|
+
const footerOffset = chromeHost ? chromeHost.offset : ownFooterOffset
|
|
58
|
+
const chromeWritable = useSharedValue(!chromeHosted)
|
|
59
|
+
// Only the registrar ever writes this while hosted. If a provider stops being hosted while it stays
|
|
60
|
+
// mounted (chrome flipping to false, the host unmounting), the registrar goes away with it and
|
|
61
|
+
// would leave the last value behind - possibly false, which would silently switch off this
|
|
62
|
+
// provider's own snap-back footer for good.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!chromeHosted) chromeWritable.value = true
|
|
65
|
+
}, [chromeHosted, chromeWritable])
|
|
66
|
+
const revealChrome = chromeHost?.reveal
|
|
67
|
+
// Unhosted, the hide distance is simply the footer's own height - the SAME SharedValue, not a
|
|
68
|
+
// copy, so nothing about existing snap-back behavior changes. Hosted, the whole stack (screen
|
|
69
|
+
// footer + bar + whatever pokes above the bar) has to clear the screen, and the footer's own
|
|
70
|
+
// measured height already includes the overhang as its bottom padding.
|
|
71
|
+
const stackHeightShared = chromeHosted ? ownStackHeight : footerHeightShared
|
|
39
72
|
const pullSearchHeightShared = useSharedValue(0)
|
|
40
73
|
const snapBackHeaderShared = useSharedValue(false)
|
|
41
74
|
const snapBackFooterShared = useSharedValue(false)
|
|
42
75
|
useEffect(() => {
|
|
43
76
|
snapBackHeaderShared.value = effectiveSnapBackHeader ?? effectiveSnapBack
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
// Forced on for a hosted footer only: a bar that hides on scroll must be revealable from
|
|
78
|
+
// anywhere in a list, which the non-snap footer (visible only near the top) is not. The header's
|
|
79
|
+
// own snap-back is deliberately left to its own setting.
|
|
80
|
+
snapBackFooterShared.value = chromeHosted ? chromeSnapBack : (effectiveSnapBackFooter ?? effectiveSnapBack)
|
|
81
|
+
}, [chromeHosted, chromeSnapBack, effectiveSnapBack, effectiveSnapBackFooter, effectiveSnapBackHeader, snapBackFooterShared, snapBackHeaderShared])
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!chromeHosted) return
|
|
84
|
+
ownStackHeight.value = chromeFootprint + Math.max(footerHeight ?? 0, chromeOverhang) + CHROME_SLACK
|
|
85
|
+
}, [chromeFootprint, chromeHosted, chromeOverhang, footerHeight, ownStackHeight])
|
|
86
|
+
// The last height each setter below accepted, written synchronously on every call. The 0-guards
|
|
87
|
+
// read these rather than the rendered headerHeight/footerHeight, because a caller can hold a
|
|
88
|
+
// setter from before the previous call committed: on web the header's first onLayout and the
|
|
89
|
+
// fallback below routinely land ~2ms apart in the same frame, and the fallback's closure still
|
|
90
|
+
// saw headerHeight === null, so its 0 passed the guard and overwrote the real height. The header's
|
|
91
|
+
// size never changed after that, so onLayout never fired again and the list kept 0 top padding
|
|
92
|
+
// under the header for good.
|
|
93
|
+
const headerHeightRef = useRef<number | null>(null)
|
|
94
|
+
const footerHeightRef = useRef<number | null>(null)
|
|
95
|
+
const setHeaderHeight = useCallback((h: number | null) => {
|
|
96
|
+
// react-native-screens' web shim hides an inactive screen via display:none on an
|
|
97
|
+
// ancestor instead of unmounting it, so the header's ResizeObserver-backed onLayout
|
|
98
|
+
// keeps firing while hidden and reports a spurious 0 (no layout box), then again once
|
|
99
|
+
// revealed. Never let a spurious 0 clobber a real height - only a true remount
|
|
100
|
+
// (headerHeight reset to null) should ever legitimately shrink it back to 0.
|
|
101
|
+
if (h === 0 && headerHeightRef.current) return
|
|
102
|
+
headerHeightRef.current = h
|
|
103
|
+
setHeaderHeightState(h)
|
|
104
|
+
}, [])
|
|
105
|
+
// Mirrored into the SharedValue in the same commit as the state, never ahead of it. Writing it
|
|
106
|
+
// straight from onLayout let the header's translate worklet see the new height while the list
|
|
107
|
+
// still had no top padding and scrollPosition hadn't been moved to its resting -headerHeight
|
|
108
|
+
// (useScrollInit does that in its own layout effect on this commit) - so for the whole re-render
|
|
109
|
+
// (~50ms on web) the header computed translateY = -headerHeight and slid off-screen, a visible
|
|
110
|
+
// flash of a header-less screen with the list's first rows at the very top.
|
|
111
|
+
useLayoutEffect(() => {
|
|
112
|
+
headerHeightShared.value = headerHeight ?? 0
|
|
113
|
+
}, [headerHeight, headerHeightShared])
|
|
59
114
|
const onListUnmount = useCallback(() => {
|
|
60
115
|
listGeneration.value += 1
|
|
61
|
-
|
|
116
|
+
// The owning screen's list going away is the last scroll source for the shared offset: a screen
|
|
117
|
+
// that swaps its list for non-scrolling content (a spinner, an empty state) while the stack is
|
|
118
|
+
// hidden would otherwise keep the app's primary navigation hidden with nothing left to bring it back.
|
|
119
|
+
if (revealChrome && chromeWritable.value) revealChrome()
|
|
120
|
+
}, [chromeWritable, listGeneration, revealChrome])
|
|
62
121
|
const onJsListUnmount = useCallback(() => {
|
|
63
122
|
jsListGeneration.current += 1
|
|
64
123
|
}, [])
|
|
@@ -66,14 +125,14 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerAboveK
|
|
|
66
125
|
if (!__DEV__ || headerHeight !== null) return
|
|
67
126
|
const timeout = setTimeout(() => {
|
|
68
127
|
// eslint-disable-next-line no-console
|
|
69
|
-
console.warn('[scroll-view] No ScrollViewHeader ever measured inside this ScrollViewProvider
|
|
128
|
+
console.warn('[scroll-view] No ScrollViewHeader ever measured inside this ScrollViewProvider: scroll content stays hidden (opacity: 0) until one does. Render a <ScrollViewHeader> (it can be empty / zero height) inside this provider.')
|
|
70
129
|
}, 3000)
|
|
71
130
|
return () => clearTimeout(timeout)
|
|
72
131
|
}, [headerHeight])
|
|
73
132
|
// Web-only recovery for a real race: a header's ResizeObserver-backed onLayout can simply never
|
|
74
|
-
// fire on the very first mount through Expo Router (observed in practice, not just theorized
|
|
133
|
+
// fire on the very first mount through Expo Router (observed in practice, not just theorized -
|
|
75
134
|
// content stuck at opacity: 0 indefinitely, with no error, only escaping via an unrelated resize
|
|
76
|
-
// forcing a fresh layout pass). Native RN doesn't have this race
|
|
135
|
+
// forcing a fresh layout pass). Native RN doesn't have this race - onLayout is reliable there, and
|
|
77
136
|
// a persistently-null headerHeight really does mean "no header was ever rendered," which the
|
|
78
137
|
// warning above should keep flagging. On web, give a real first layout two frames to arrive before
|
|
79
138
|
// assuming there isn't one; setHeaderHeight(0) is exactly what a genuinely header-less screen would
|
|
@@ -95,17 +154,31 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerAboveK
|
|
|
95
154
|
// shim (and a footer's own unmount cleanup below, which now passes null instead of 0 for
|
|
96
155
|
// exactly this reason) can report/force a spurious 0 while a real footer is effectively
|
|
97
156
|
// still on screen (e.g. a wizard cycling through several <ScrollViewFooter> instances).
|
|
98
|
-
// Never let that clobber a known height
|
|
157
|
+
// Never let that clobber a known height - only an explicit null (genuinely no footer
|
|
99
158
|
// mounted) may reset it.
|
|
100
|
-
if (h === 0 &&
|
|
159
|
+
if (h === 0 && footerHeightRef.current) return
|
|
160
|
+
footerHeightRef.current = h
|
|
101
161
|
setFooterHeightState(h)
|
|
102
162
|
footerHeightShared.value = h ?? 0
|
|
103
163
|
},
|
|
104
|
-
[
|
|
164
|
+
[footerHeightShared]
|
|
105
165
|
)
|
|
106
166
|
const scrollHeight = useMemo(() => Dimensions.get('window').height - (headerHeight ?? 0) - (footerHeight ?? 0), [headerHeight, footerHeight])
|
|
107
167
|
const effectiveHeaderFixed = fixed || (headerFixed ?? settings.headerFixed)
|
|
108
168
|
const effectiveFooterFixed = fixed || (footerFixed ?? settings.footerFixed)
|
|
109
|
-
|
|
110
|
-
|
|
169
|
+
// Hosted, the persistent bar's footprint replaces the consumer's static tabBarHeight guess: it is
|
|
170
|
+
// the same "reserve this much at the bottom" number, just now known exactly.
|
|
171
|
+
const effectiveTabBarHeight = chromeHosted ? chromeFootprint : tabBarHeight
|
|
172
|
+
const value = useMemo(
|
|
173
|
+
() => ({ blur: effectiveBlur, chromeHosted, chromeOverhang, chromeWritable, 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, stackHeightShared, tabBarHeight: effectiveTabBarHeight }),
|
|
174
|
+
[effectiveBlur, chromeHosted, chromeOverhang, chromeWritable, footerAboveKeyboard, effectiveFooterFixed, effectiveHeaderFixed, footerHeight, footerHeightShared, footerOffset, headerHeight, headerHeightShared, headerOffset, jsListGeneration, listGeneration, onJsListUnmount, onListUnmount, progress, pullSearchHeightShared, progressing, scrollHeight, scrollPosition, setFooterHeight, setHeaderHeight, snapBackFooterShared, snapBackHeaderShared, stackHeightShared, effectiveTabBarHeight]
|
|
175
|
+
)
|
|
176
|
+
return (
|
|
177
|
+
<ScrollViewContext.Provider value={value}>
|
|
178
|
+
<ScrollViewChromeHostContext.Provider value={null}>
|
|
179
|
+
{children}
|
|
180
|
+
{chromeHost ? <ChromeRegistrar footerFixed={effectiveFooterFixed} host={chromeHost} writable={chromeWritable} /> : null}
|
|
181
|
+
</ScrollViewChromeHostContext.Provider>
|
|
182
|
+
</ScrollViewContext.Provider>
|
|
183
|
+
)
|
|
111
184
|
}
|
package/src/SectionList.tsx
CHANGED
|
@@ -36,13 +36,13 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
36
36
|
|
|
37
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
|
-
// -1 never matches a real generation (starts at 0, only increments)
|
|
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.
|
|
41
41
|
const capturedGeneration = useSharedValue(-1)
|
|
42
42
|
// Layout effect (not passive effect): React guarantees all layout-effect cleanups for fibers
|
|
43
43
|
// removed in a commit run before any layout-effect setups for fibers added in that same commit,
|
|
44
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
|
|
45
|
+
// instance's unmount and the incoming instance's mount land in the same commit - with passive
|
|
46
46
|
// effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
|
|
47
47
|
// capturing a stale generation and silently disabling this instance's onScroll handling forever.
|
|
48
48
|
// Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
|
|
@@ -52,7 +52,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
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
54
|
//
|
|
55
|
-
// Web has no such thread split, though
|
|
55
|
+
// Web has no such thread split, though - see ScrollView.tsx's own doc comment on this same
|
|
56
56
|
// pattern for why runOnUI's deferred-to-next-frame behavior there leaves a real gap, and why a
|
|
57
57
|
// synchronous assignment is the correct fix rather than a workaround.
|
|
58
58
|
useLayoutEffect(() => {
|
|
@@ -71,7 +71,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
71
71
|
const { activeIndex, clipStyle, measureHeader, overlayStyle, resetPositions } = useStickyHeaders(headerFixed, usesCustomSticky)
|
|
72
72
|
|
|
73
73
|
// useLayoutEffect (not useEffect) so the reset runs before native layout fires onLayout
|
|
74
|
-
// for the newly mounted section headers
|
|
74
|
+
// for the newly mounted section headers - otherwise the reset would clear correct positions.
|
|
75
75
|
useLayoutEffect(() => {
|
|
76
76
|
if (usesCustomSticky) resetPositions(sections.length)
|
|
77
77
|
}, [sections, usesCustomSticky, resetPositions, headerHeight])
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export { PullSearch } from './PullSearch'
|
|
|
8
8
|
export { scrollViewActions, scrollViewReducer } from './redux/scrollViewSlice'
|
|
9
9
|
export type { ScrollViewProps } from './ScrollView'
|
|
10
10
|
export { ScrollView } from './ScrollView'
|
|
11
|
+
export type { ScrollViewChromeProviderProps } from './ScrollViewChromeProvider'
|
|
12
|
+
export { ScrollViewChromeProvider } from './ScrollViewChromeProvider'
|
|
11
13
|
export type { ScrollViewContextType } from './ScrollViewContext'
|
|
12
14
|
export { ScrollViewContext } from './ScrollViewContext'
|
|
13
15
|
export type { ScrollViewFooterProps } from './ScrollViewFooter'
|
|
@@ -24,4 +26,5 @@ export type { SectionListProps } from './SectionList'
|
|
|
24
26
|
export { SectionList } from './SectionList'
|
|
25
27
|
export { useKeyboardInset } from './useKeyboardInset'
|
|
26
28
|
export { useScrollView } from './useScrollView'
|
|
29
|
+
export { useScrollViewChromePin, useScrollViewChromeReveal, useScrollViewChromeStyle } from './useScrollViewChrome'
|
|
27
30
|
export { useScrollViewSettings } from './useScrollViewSettings'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useEffect, useId } from 'react'
|
|
2
|
+
import type { SharedValue } from 'react-native-reanimated'
|
|
3
|
+
|
|
4
|
+
import type { ScrollViewChromeHost } from '../ScrollViewChromeContext'
|
|
5
|
+
|
|
6
|
+
type ChromeRegistrarProps = {
|
|
7
|
+
footerFixed: boolean
|
|
8
|
+
host: ScrollViewChromeHost
|
|
9
|
+
writable: SharedValue<boolean>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Rendered by a hosted ScrollViewProvider as a sibling of its children. It is a component rather
|
|
13
|
+
// than a hook call inside the provider because host.useIsActive is itself a hook, and a provider
|
|
14
|
+
// only sometimes has a host - mounting this only when hosted keeps the hook order stable.
|
|
15
|
+
//
|
|
16
|
+
// While its screen is active it claims the host; the provider's scroll handlers only write the
|
|
17
|
+
// shared offset (`writable`) while this provider is the claim's owner and nothing has pinned the
|
|
18
|
+
// bar. Footer Lock turning on while the stack is hidden reveals it, since the handlers stop writing
|
|
19
|
+
// the moment it does and nothing else would ever bring it back.
|
|
20
|
+
export const ChromeRegistrar = ({ footerFixed, host, writable }: ChromeRegistrarProps) => {
|
|
21
|
+
const id = useId()
|
|
22
|
+
const active = host.useIsActive()
|
|
23
|
+
const { activeId, claim, pinned, release, reveal } = host
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!active) return undefined
|
|
26
|
+
claim(id)
|
|
27
|
+
return () => release(id)
|
|
28
|
+
}, [active, claim, id, release])
|
|
29
|
+
const owner = activeId === id
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
writable.value = owner && !pinned
|
|
32
|
+
}, [owner, pinned, writable])
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (owner && footerFixed) reveal()
|
|
35
|
+
}, [footerFixed, owner, reveal])
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
@@ -31,7 +31,7 @@ type DotProps = {
|
|
|
31
31
|
const Dot = ({ activeColor, index, inactiveColor, pageWidth, scrollPosition }: DotProps) => {
|
|
32
32
|
// Explicit array required on web even though nothing here looks platform-specific: without
|
|
33
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
|
|
34
|
+
// auto-extract dependencies and throws outright rather than silently under-tracking - omitting
|
|
35
35
|
// this array isn't a lesser version of the header/footer bug elsewhere in this package, it's a
|
|
36
36
|
// hard crash (confirmed live on the horizontal-pager demo).
|
|
37
37
|
const circleStyle = useAnimatedStyle(() => {
|
|
@@ -4,11 +4,11 @@ import { StyleSheet, type ViewStyle } from 'react-native'
|
|
|
4
4
|
import { Icon, useTheme } from 'react-native-paper'
|
|
5
5
|
import Animated, { type AnimatedStyle } from 'react-native-reanimated'
|
|
6
6
|
|
|
7
|
-
// 'children' omitted alongside the rest
|
|
7
|
+
// 'children' omitted alongside the rest - this chip's own text always comes from `label` (or the
|
|
8
8
|
// isHorizontal-driven 'Start'/'Top' default below), passed as the real JSX children the underlying
|
|
9
9
|
// Chip renders with, which is what actually wins: React always takes the children an element is
|
|
10
10
|
// given between its tags over one merely passed as a "children" prop via spread. A `children` in
|
|
11
|
-
// chipProps would therefore type-check but silently do nothing at runtime
|
|
11
|
+
// chipProps would therefore type-check but silently do nothing at runtime - omitting it here means
|
|
12
12
|
// TypeScript catches that mismatch instead of a caller finding out the hard way.
|
|
13
13
|
export type ChipProps = Omit<AutoPaperChipProps, 'compact' | 'icon' | 'onPress' | 'children'> & {
|
|
14
14
|
label?: ReactNode
|
|
@@ -34,7 +34,7 @@ export const ScrollViewChip = ({ chipProps, isHorizontal, onChipPress, onPress,
|
|
|
34
34
|
// Rendered via `avatar`, not Chip's own `icon` prop: react-native-paper's MD3 Chip hardcodes
|
|
35
35
|
// `icon`'s color to theme.colors.primary and ignores `selectedColor` entirely (its own doc
|
|
36
36
|
// comment: "With theme version 3 selectedColor doesn't apply to the icon. If you want [a]
|
|
37
|
-
// custom color for the icon, render your own Icon component")
|
|
37
|
+
// custom color for the icon, render your own Icon component") - a chip whose own background
|
|
38
38
|
// IS theme.colors.primary (this library's callers all set that; see Hangman's own vivid-chip
|
|
39
39
|
// convention) renders that icon invisible against itself. `avatar` has no such override; it
|
|
40
40
|
// clones whatever element/color it's given. Falls back to the same default Paper itself would
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { usesContentInset } from './insetMode'
|
|
2
|
+
|
|
3
|
+
// Extra distance past the geometrically-required hide distance, so a soft shadow (a FAB's) that
|
|
4
|
+
// bleeds past the bar's box doesn't leave a sliver at the screen edge once fully hidden.
|
|
5
|
+
export const CHROME_SLACK = 2
|
|
6
|
+
|
|
7
|
+
// How far a list can actually be scrolled from its resting position. In inset mode the resting
|
|
8
|
+
// position sits at -headerHeight and raw contentOffset spans [-headerHeight, maxScroll]; in padding
|
|
9
|
+
// mode the header is part of the content and raw spans [0, maxScroll].
|
|
10
|
+
export const chromeTravel = (maxScroll: number, headerHeight: number) => {
|
|
11
|
+
'worklet'
|
|
12
|
+
return usesContentInset ? maxScroll + headerHeight : maxScroll
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Where a half-hidden stack should come to rest once scrolling stops: whichever end is nearer.
|
|
16
|
+
export const chromeSettleTarget = (offset: number, stackHeight: number) => {
|
|
17
|
+
'worklet'
|
|
18
|
+
return offset > stackHeight / 2 ? stackHeight : 0
|
|
19
|
+
}
|
|
@@ -4,6 +4,7 @@ import { runOnJS, useAnimatedScrollHandler, useSharedValue, withTiming } from 'r
|
|
|
4
4
|
|
|
5
5
|
import { ScrollViewContext } from '../ScrollViewContext'
|
|
6
6
|
import { REMOUNT_SYNC_TOLERANCE } from '../useScrollInit'
|
|
7
|
+
import { chromeSettleTarget, chromeTravel } from './chrome'
|
|
7
8
|
import { usesContentInset } from './insetMode'
|
|
8
9
|
|
|
9
10
|
type UseScrollHandlerOptions = {
|
|
@@ -20,7 +21,7 @@ type UseScrollHandlerOptions = {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget }: UseScrollHandlerOptions) {
|
|
23
|
-
const {
|
|
24
|
+
const { chromeHosted, chromeWritable, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared, stackHeightShared } = useContext(ScrollViewContext)
|
|
24
25
|
const snapUpAccum = useSharedValue(0)
|
|
25
26
|
const fromBottomBounce = useSharedValue(false)
|
|
26
27
|
|
|
@@ -58,7 +59,12 @@ export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold
|
|
|
58
59
|
fromBottomBounce.value = false
|
|
59
60
|
}
|
|
60
61
|
const snapHeader = snapBackHeaderShared.value && !headerFixed
|
|
61
|
-
|
|
62
|
+
// A hosted footer is one half of a stack that has to slide its full hide distance to clear
|
|
63
|
+
// the screen; a list that can't scroll that far (short content) would strand the stack half
|
|
64
|
+
// hidden with no way to finish the job, so it never starts hiding in the first place.
|
|
65
|
+
const hideable = !chromeHosted || chromeTravel(maxScroll, headerHeightShared.value) >= stackHeightShared.value
|
|
66
|
+
if (chromeHosted && !hideable && chromeWritable.value && footerOffset.value !== 0) footerOffset.value = 0
|
|
67
|
+
const snapFooter = snapBackFooterShared.value && !footerFixed && chromeWritable.value && hideable
|
|
62
68
|
if (snapHeader || snapFooter) {
|
|
63
69
|
if (yn <= -headerHeightShared.value) {
|
|
64
70
|
snapUpAccum.value = 0
|
|
@@ -68,7 +74,7 @@ export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold
|
|
|
68
74
|
snapUpAccum.value = 0
|
|
69
75
|
if (yn >= -headerHeightShared.value + pullSearchHeightShared.value) {
|
|
70
76
|
if (snapHeader) headerOffset.value = Math.max(-headerHeightShared.value, Math.min(0, headerOffset.value - delta))
|
|
71
|
-
if (snapFooter) footerOffset.value = Math.max(0, Math.min(
|
|
77
|
+
if (snapFooter) footerOffset.value = Math.max(0, Math.min(stackHeightShared.value, footerOffset.value + delta))
|
|
72
78
|
}
|
|
73
79
|
} else if (delta < 0 && !fromBottomBounce.value) {
|
|
74
80
|
if (yn >= -headerHeightShared.value + pullSearchHeightShared.value) snapUpAccum.value -= delta
|
|
@@ -85,20 +91,35 @@ export function useScrollHandler({ capturedGeneration, chipHidden, chipThreshold
|
|
|
85
91
|
fromBottomBounce.value = false
|
|
86
92
|
snapUpAccum.value = 0
|
|
87
93
|
},
|
|
88
|
-
onEndDrag: ({ contentOffset: { y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight } }) => {
|
|
94
|
+
onEndDrag: ({ contentOffset: { y }, contentSize: { height: contentHeight }, layoutMeasurement: { height: layoutHeight }, velocity }) => {
|
|
89
95
|
'worklet'
|
|
90
96
|
if (isHorizontal) return
|
|
97
|
+
// The 10pt scroll-up latch (snapUpAccum >= 10) has already started animating the footer offset
|
|
98
|
+
// back to 0; read it before anything below resets it, because settling on the in-flight value
|
|
99
|
+
// instead could pick "hidden" and reverse a deliberate reveal.
|
|
100
|
+
const revealing = snapUpAccum.value >= 10
|
|
91
101
|
if (y >= contentHeight - layoutHeight - 10) {
|
|
92
102
|
fromBottomBounce.value = true
|
|
93
103
|
snapUpAccum.value = 0
|
|
94
104
|
}
|
|
105
|
+
// A drag that released with momentum still to come keeps writing the offset through it, and
|
|
106
|
+
// settles when that ends; only a release that will genuinely stop here settles now.
|
|
107
|
+
if (chromeHosted && !footerFixed && chromeWritable.value && (velocity === undefined || Math.abs(velocity.y) < 0.1)) {
|
|
108
|
+
footerOffset.value = withTiming(revealing ? 0 : chromeSettleTarget(footerOffset.value, stackHeightShared.value), { duration: 200 })
|
|
109
|
+
}
|
|
95
110
|
},
|
|
96
111
|
onMomentumEnd: () => {
|
|
97
112
|
'worklet'
|
|
113
|
+
const revealing = snapUpAccum.value >= 10
|
|
98
114
|
fromBottomBounce.value = false
|
|
99
115
|
snapUpAccum.value = 0
|
|
116
|
+
// Hosted: scroll tracks 1:1, so it can stop with the stack half hidden. The bar is the app's
|
|
117
|
+
// primary navigation, so it comes to rest fully shown or fully hidden, never cut off.
|
|
118
|
+
if (chromeHosted && !isHorizontal && !footerFixed && chromeWritable.value) {
|
|
119
|
+
footerOffset.value = withTiming(revealing ? 0 : chromeSettleTarget(footerOffset.value, stackHeightShared.value), { duration: 200 })
|
|
120
|
+
}
|
|
100
121
|
}
|
|
101
122
|
},
|
|
102
|
-
[capturedGeneration, headerFixed, footerFixed, isHorizontal, chipThreshold, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget]
|
|
123
|
+
[capturedGeneration, chromeHosted, headerFixed, footerFixed, isHorizontal, chipThreshold, listGeneration, onRemountSyncRetry, onRemountSynced, remountSyncTarget]
|
|
103
124
|
)
|
|
104
125
|
}
|
|
@@ -4,6 +4,7 @@ import type { SharedValue } from 'react-native-reanimated'
|
|
|
4
4
|
import { withTiming } from 'react-native-reanimated'
|
|
5
5
|
|
|
6
6
|
import { ScrollViewContext } from '../ScrollViewContext'
|
|
7
|
+
import { chromeSettleTarget, chromeTravel } from './chrome'
|
|
7
8
|
import { usesContentInset } from './insetMode'
|
|
8
9
|
|
|
9
10
|
const REMOUNT_RETRY_TOLERANCE = 1
|
|
@@ -32,17 +33,17 @@ export type ScrollHandlerJS = {
|
|
|
32
33
|
// Plain JS-thread scroll handler for a non-Animated scrollable. Reanimated shared values are
|
|
33
34
|
// readable/writable from any thread, so driving them via a regular onScroll callback (rather than
|
|
34
35
|
// a useAnimatedScrollHandler worklet, which requires an Animated-wrapped component to attach to at
|
|
35
|
-
// the native level) works fine here
|
|
36
|
+
// the native level) works fine here - the header/footer animations just pick up the change on
|
|
36
37
|
// their next UI-thread frame. This trades a small amount of latency for a plain scrollable that
|
|
37
38
|
// reliably honors the initial `contentOffset` prop on (re)mount, which Animated.FlatList does not.
|
|
38
39
|
export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, jsListGeneration, remountTarget = null, scrollTo }: UseScrollHandlerJSOptions): ScrollHandlerJS {
|
|
39
|
-
const {
|
|
40
|
+
const { chromeHosted, chromeWritable, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared, stackHeightShared } = useContext(ScrollViewContext)
|
|
40
41
|
const snapUpAccum = useRef(0)
|
|
41
42
|
const fromBottomBounce = useRef(false)
|
|
42
|
-
// On a remount, the very first native position after mount isn't reliable
|
|
43
|
+
// On a remount, the very first native position after mount isn't reliable - Fabric can skip
|
|
43
44
|
// reapplying `contentOffset` when the target is numerically unchanged from before, and native
|
|
44
45
|
// scroll settling can overshoot or undershoot it. Rather than trust either the declarative prop
|
|
45
|
-
// or a single imperative scrollTo call, keep correcting (without hiding anything
|
|
46
|
+
// or a single imperative scrollTo call, keep correcting (without hiding anything - content stays
|
|
46
47
|
// visible throughout) until an onScroll event actually confirms the intended resting position.
|
|
47
48
|
const pendingRemountTarget = useRef(remountTarget)
|
|
48
49
|
const remountAttempts = useRef(0)
|
|
@@ -88,7 +89,11 @@ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThresho
|
|
|
88
89
|
fromBottomBounce.current = false
|
|
89
90
|
}
|
|
90
91
|
const snapHeader = snapBackHeaderShared.value && !headerFixed
|
|
91
|
-
|
|
92
|
+
// See useScrollHandler: a hosted stack that this list can't scroll far enough to fully hide
|
|
93
|
+
// never starts hiding.
|
|
94
|
+
const hideable = !chromeHosted || chromeTravel(maxScroll, headerHeightShared.value) >= stackHeightShared.value
|
|
95
|
+
if (chromeHosted && !hideable && chromeWritable.value && footerOffset.value !== 0) footerOffset.value = 0
|
|
96
|
+
const snapFooter = snapBackFooterShared.value && !footerFixed && chromeWritable.value && hideable
|
|
92
97
|
if (snapHeader || snapFooter) {
|
|
93
98
|
if (yn <= -headerHeightShared.value) {
|
|
94
99
|
snapUpAccum.current = 0
|
|
@@ -98,7 +103,7 @@ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThresho
|
|
|
98
103
|
snapUpAccum.current = 0
|
|
99
104
|
if (yn >= -headerHeightShared.value + pullSearchHeightShared.value) {
|
|
100
105
|
if (snapHeader) headerOffset.value = Math.max(-headerHeightShared.value, Math.min(0, headerOffset.value - delta))
|
|
101
|
-
if (snapFooter) footerOffset.value = Math.max(0, Math.min(
|
|
106
|
+
if (snapFooter) footerOffset.value = Math.max(0, Math.min(stackHeightShared.value, footerOffset.value + delta))
|
|
102
107
|
}
|
|
103
108
|
} else if (delta < 0 && !fromBottomBounce.current) {
|
|
104
109
|
if (yn >= -headerHeightShared.value + pullSearchHeightShared.value) snapUpAccum.current -= delta
|
|
@@ -110,7 +115,7 @@ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThresho
|
|
|
110
115
|
}
|
|
111
116
|
}
|
|
112
117
|
},
|
|
113
|
-
[capturedGeneration, chipHidden, chipThreshold,
|
|
118
|
+
[capturedGeneration, chipHidden, chipThreshold, chromeHosted, chromeWritable, footerFixed, footerOffset, headerFixed, headerHeightShared, headerOffset, isHorizontal, jsListGeneration, pullSearchHeightShared, scrollPosition, scrollTo, snapBackFooterShared, snapBackHeaderShared, stackHeightShared]
|
|
114
119
|
)
|
|
115
120
|
|
|
116
121
|
const onScrollBeginDrag = useCallback(() => {
|
|
@@ -120,26 +125,45 @@ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThresho
|
|
|
120
125
|
|
|
121
126
|
const onMomentumScrollBegin = useCallback(() => {}, [])
|
|
122
127
|
|
|
128
|
+
// Hosted: scroll tracks 1:1, so it can stop with the stack half hidden; it settles to fully shown
|
|
129
|
+
// or fully hidden instead, since the bar is the app's primary navigation. `revealing` is whether
|
|
130
|
+
// the 10pt scroll-up latch has already started animating the offset back to 0 - settling on the
|
|
131
|
+
// in-flight value instead could pick "hidden" and reverse a deliberate reveal.
|
|
132
|
+
const settle = useCallback(
|
|
133
|
+
(revealing: boolean) => {
|
|
134
|
+
if (!chromeHosted || footerFixed || !chromeWritable.value) return
|
|
135
|
+
footerOffset.value = withTiming(revealing ? 0 : chromeSettleTarget(footerOffset.value, stackHeightShared.value), { duration: 200 })
|
|
136
|
+
},
|
|
137
|
+
[chromeHosted, chromeWritable, footerFixed, footerOffset, stackHeightShared]
|
|
138
|
+
)
|
|
139
|
+
|
|
123
140
|
const onScrollEndDrag = useCallback(
|
|
124
141
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
125
142
|
if (isHorizontal) return
|
|
126
143
|
const {
|
|
127
144
|
contentOffset: { y },
|
|
128
145
|
contentSize: { height: contentHeight },
|
|
129
|
-
layoutMeasurement: { height: layoutHeight }
|
|
146
|
+
layoutMeasurement: { height: layoutHeight },
|
|
147
|
+
velocity
|
|
130
148
|
} = event.nativeEvent
|
|
149
|
+
const revealing = snapUpAccum.current >= 10
|
|
131
150
|
if (y >= contentHeight - layoutHeight - 10) {
|
|
132
151
|
fromBottomBounce.current = true
|
|
133
152
|
snapUpAccum.current = 0
|
|
134
153
|
}
|
|
154
|
+
// A release with momentum still to come keeps writing the offset through it and settles when
|
|
155
|
+
// that ends; only one that will genuinely stop here settles now.
|
|
156
|
+
if (velocity === undefined || Math.abs(velocity.y) < 0.1) settle(revealing)
|
|
135
157
|
},
|
|
136
|
-
[isHorizontal]
|
|
158
|
+
[isHorizontal, settle]
|
|
137
159
|
)
|
|
138
160
|
|
|
139
161
|
const onMomentumScrollEnd = useCallback(() => {
|
|
162
|
+
const revealing = snapUpAccum.current >= 10
|
|
140
163
|
fromBottomBounce.current = false
|
|
141
164
|
snapUpAccum.current = 0
|
|
142
|
-
|
|
165
|
+
if (!isHorizontal) settle(revealing)
|
|
166
|
+
}, [isHorizontal, settle])
|
|
143
167
|
|
|
144
168
|
return { onMomentumScrollBegin, onMomentumScrollEnd, onScroll, onScrollBeginDrag, onScrollEndDrag }
|
|
145
169
|
}
|
|
@@ -25,21 +25,24 @@ export type UseScrollListOptions = {
|
|
|
25
25
|
export function useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, hideUntilMeasured, isHorizontal, keyboardAware, pullSearchHeight, style }: UseScrollListOptions = {}) {
|
|
26
26
|
const insets = useSafeAreaInsets()
|
|
27
27
|
const keyboardHeight = useKeyboardInset()
|
|
28
|
-
const { footerAboveKeyboard, footerHeight, footerFixed: contextFooterFixed, headerHeight, headerHeightShared, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared, tabBarHeight } = useContext(ScrollViewContext)
|
|
28
|
+
const { chromeHosted, chromeOverhang, footerAboveKeyboard, footerHeight, footerFixed: contextFooterFixed, headerHeight, headerHeightShared, headerFixed: contextHeaderFixed, headerOffset, pullSearchHeightShared, scrollPosition, snapBackHeaderShared, tabBarHeight } = useContext(ScrollViewContext)
|
|
29
29
|
|
|
30
30
|
const headerFixed = isHorizontal ? true : (headerFixedProp ?? contextHeaderFixed)
|
|
31
|
-
|
|
31
|
+
// Hosted, the footer and the bar share ONE offset that the provider's registrar reveals when Footer
|
|
32
|
+
// Lock turns on - and the registrar only sees the provider-level flag. A per-list override that
|
|
33
|
+
// turned the handlers off on its own would leave the stack stuck wherever it was hidden.
|
|
34
|
+
const footerFixed = isHorizontal ? true : chromeHosted ? contextFooterFixed : (footerFixedProp ?? contextFooterFixed)
|
|
32
35
|
|
|
33
36
|
useEffect(() => {
|
|
34
37
|
pullSearchHeightShared.value = usesContentInset ? (pullSearchHeight ?? 0) : 0
|
|
35
38
|
}, [pullSearchHeight, pullSearchHeightShared])
|
|
36
39
|
|
|
37
|
-
// First render (headerHeight===null): flex:1, sitting naturally below the in-flow header
|
|
40
|
+
// First render (headerHeight===null): flex:1, sitting naturally below the in-flow header -
|
|
38
41
|
// same visual position content ends up in after the swap below (absoluteFill + paddingTop
|
|
39
42
|
// matching the now-measured headerHeight), so no hiding is needed for this transition on its
|
|
40
43
|
// own. hideUntilMeasured is only for SectionList: its key={headerHeight>0?1:0} forces a real
|
|
41
44
|
// remount of the underlying native list on this exact transition, and that remount is what
|
|
42
|
-
// needs hiding, not this container swap itself
|
|
45
|
+
// needs hiding, not this container swap itself - ScrollView/FlatList/CustomList never remount
|
|
43
46
|
// here, so unconditionally hiding this container regressed a real "flash of blank" on every
|
|
44
47
|
// fresh mount for them (2026-08-03).
|
|
45
48
|
const containerStyle = useMemo(() => (headerHeight === null ? [{ flex: 1, opacity: hideUntilMeasured ? 0 : 1 }, style] : [StyleSheet.absoluteFill, style]), [headerHeight, hideUntilMeasured, style])
|
|
@@ -50,7 +53,7 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
50
53
|
// ScrollViewFooter's own paddingBottom).
|
|
51
54
|
//
|
|
52
55
|
// When footerAboveKeyboard opts the (fixed) footer into floating above the keyboard instead of
|
|
53
|
-
// sitting behind it, footerHeight ALREADY includes the current keyboardHeight
|
|
56
|
+
// sitting behind it, footerHeight ALREADY includes the current keyboardHeight - ScrollViewFooter
|
|
54
57
|
// grows its own container by keyboardHeight to float (see its containerPaddingBottom), rather
|
|
55
58
|
// than translating a fixed-height bar, so onLayout reports the combined height. Adding
|
|
56
59
|
// keyboardHeight again here would double-count it. Otherwise (not floating) ScrollViewFooter pins
|
|
@@ -59,13 +62,27 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
59
62
|
// footerReserve + keyboardHeight in that case would leave a gap the size of the now-hidden footer
|
|
60
63
|
// between the focused input and the keyboard, so max() reserves only whichever bottom obstruction
|
|
61
64
|
// is actually taller right now.
|
|
65
|
+
//
|
|
66
|
+
// Hosted, tabBarHeight is the bar's footprint and it already contains the safe-area inset, so the
|
|
67
|
+
// base reserve is only what pokes above the bar (chromeOverhang) - not insets.bottom a second
|
|
68
|
+
// time. Hosted also reserves the screen footer's own height whether or not it is fixed: unlike an
|
|
69
|
+
// unhosted footer (whose position is a function of scroll, so scrolling to the end always hides
|
|
70
|
+
// it), a hosted stack settles wherever the last gesture left it, so a revealed footer at the end
|
|
71
|
+
// of a list would otherwise cover the last rows with no way to scroll them clear. This reserve
|
|
72
|
+
// stays constant even while the stack is hidden: contentInset can't animate, so the space stays
|
|
73
|
+
// reserved at the end of the list rather than the list growing as the bar slides away.
|
|
74
|
+
//
|
|
75
|
+
// A hosted, keyboard-floating footer pins itself at max(footprint - keyboardHeight, 0) instead of
|
|
76
|
+
// the full footprint (see ScrollViewFooter), so the bar reserve shrinks the same way.
|
|
62
77
|
const insetGeometry = useMemo(() => {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
78
|
+
const baseReserve = chromeHosted ? chromeOverhang : insets.bottom
|
|
79
|
+
const footerReserve = chromeHosted ? Math.max(footerHeight ?? 0, baseReserve) : footerFixed ? footerHeight || baseReserve : baseReserve
|
|
80
|
+
const barReserve = chromeHosted && footerFixed && footerAboveKeyboard ? Math.max(tabBarHeight - keyboardHeight, 0) : tabBarHeight
|
|
81
|
+
const bottom = (!keyboardAware || (footerFixed && footerAboveKeyboard) ? footerReserve : Math.max(footerReserve, keyboardHeight)) + barReserve
|
|
65
82
|
return { bottom, top: headerHeight ?? 0 }
|
|
66
|
-
}, [footerAboveKeyboard, footerFixed, footerHeight, insets.bottom, tabBarHeight, headerHeight, keyboardAware, keyboardHeight])
|
|
83
|
+
}, [chromeHosted, chromeOverhang, footerAboveKeyboard, footerFixed, footerHeight, insets.bottom, tabBarHeight, headerHeight, keyboardAware, keyboardHeight])
|
|
67
84
|
// Outside inset mode the same geometry is applied as content padding instead; contentInset is
|
|
68
|
-
// zeroed (not omitted) so consumers' offset math
|
|
85
|
+
// zeroed (not omitted) so consumers' offset math - scroll-to-top targets, minHeight - stays
|
|
69
86
|
// correct in the raw 0-based coordinate space those platforms actually scroll in.
|
|
70
87
|
const contentInset = usesContentInset ? insetGeometry : ZERO_INSET
|
|
71
88
|
const contentPadding = useMemo(() => (usesContentInset || isHorizontal ? null : { paddingBottom: insetGeometry.bottom, paddingTop: insetGeometry.top }), [insetGeometry, isHorizontal])
|
|
@@ -73,19 +90,19 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
73
90
|
// Use hideY offset only when this component mounted with headerHeight already known (mode switch).
|
|
74
91
|
// On initial load, headerHeight is null at mount so we start at showY and let useScrollInit
|
|
75
92
|
// call scrollTo after the phase transition. On mode switch, headerHeight is already set so we
|
|
76
|
-
// can position the FlatList at hideY atomically via contentOffset
|
|
93
|
+
// can position the FlatList at hideY atomically via contentOffset - no scrollTo needed.
|
|
77
94
|
const [startedWithHeader] = useState(headerHeight !== null)
|
|
78
95
|
const insetContentOffset = useMemo(() => ({ x: 0, y: headerHeight !== null ? -headerHeight + (startedWithHeader && !isHorizontal && pullSearchHeight ? pullSearchHeight : 0) : 0 }), [headerHeight, isHorizontal, pullSearchHeight, startedWithHeader])
|
|
79
96
|
const contentOffset = usesContentInset ? insetContentOffset : ZERO_OFFSET
|
|
80
97
|
|
|
81
98
|
const chipHidden = useSharedValue(1)
|
|
82
99
|
// chipHidden/snapBackHeaderShared/headerOffset/scrollPosition/pullSearchHeightShared/
|
|
83
|
-
// headerHeightShared all need to be listed here too, not just read
|
|
100
|
+
// headerHeightShared all need to be listed here too, not just read - see ScrollViewHeader's
|
|
84
101
|
// translateStyle for why (web-only reanimated reactivity gap: a SharedValue's mutations don't
|
|
85
102
|
// retrigger useAnimatedStyle unless the value itself is in this array). headerHeightShared is
|
|
86
103
|
// used here rather than the plain `headerHeight` context value specifically because the latter
|
|
87
104
|
// changes identity (null -> a real number) shortly after mount, which forces Reanimated to
|
|
88
|
-
// recreate this worklet
|
|
105
|
+
// recreate this worklet - a crash on react-native-worklets 0.10.1, not just a perf concern. See
|
|
89
106
|
// ScrollViewHeader's translateStyle/blurStyle/progressStyle for the full explanation.
|
|
90
107
|
const chipStyle = useAnimatedStyle(() => {
|
|
91
108
|
const pointerEvents = chipHidden.value ? ('none' as const) : ('box-none' as const)
|