@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.
@@ -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 footerOffset = useSharedValue(0)
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
- snapBackFooterShared.value = effectiveSnapBackFooter ?? effectiveSnapBack
45
- }, [effectiveSnapBack, effectiveSnapBackFooter, effectiveSnapBackHeader, snapBackFooterShared, snapBackHeaderShared])
46
- const setHeaderHeight = useCallback(
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
54
- setHeaderHeightState(h)
55
- headerHeightShared.value = h ?? 0
56
- },
57
- [headerHeight, headerHeightShared]
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
- }, [listGeneration])
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 — scroll content stays hidden (opacity: 0) until one does. Render a <ScrollViewHeader> (it can be empty / zero height) inside this provider.')
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 — onLayout is reliable there, and
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 — only an explicit null (genuinely no footer
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 && footerHeight) return
159
+ if (h === 0 && footerHeightRef.current) return
160
+ footerHeightRef.current = h
101
161
  setFooterHeightState(h)
102
162
  footerHeightShared.value = h ?? 0
103
163
  },
104
- [footerHeight, footerHeightShared]
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
- 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])
110
- return <ScrollViewContext.Provider value={value}>{children}</ScrollViewContext.Provider>
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
  }
@@ -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) — the runOnUI call below
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 — with passive
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 — see ScrollView.tsx's own doc comment on this same
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 — otherwise the reset would clear correct positions.
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 — omitting
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 — this chip's own text always comes from `label` (or the
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 — omitting it here means
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") — a chip whose own background
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 { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
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
- const snapFooter = snapBackFooterShared.value && !footerFixed
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(footerHeightShared.value, footerOffset.value + delta))
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 — the header/footer animations just pick up the change on
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 { footerHeightShared, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared } = useContext(ScrollViewContext)
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 — Fabric can skip
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 — content stays
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
- const snapFooter = snapBackFooterShared.value && !footerFixed
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(footerHeightShared.value, footerOffset.value + delta))
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, footerFixed, footerHeightShared, footerOffset, headerFixed, headerHeightShared, headerOffset, isHorizontal, jsListGeneration, pullSearchHeightShared, scrollPosition, scrollTo, snapBackFooterShared, snapBackHeaderShared]
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
- const footerFixed = isHorizontal ? true : (footerFixedProp ?? contextFooterFixed)
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 — ScrollView/FlatList/CustomList never remount
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 — ScrollViewFooter
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 footerReserve = footerFixed ? footerHeight || insets.bottom : insets.bottom
64
- const bottom = (!keyboardAware || (footerFixed && footerAboveKeyboard) ? footerReserve : Math.max(footerReserve, keyboardHeight)) + tabBarHeight
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 — scroll-to-top targets, minHeight — stays
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 — no scrollTo needed.
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 — see ScrollViewHeader's
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 — a crash on react-native-worklets 0.10.1, not just a perf concern. See
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)