@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/scroll-view",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "Blur styled scroll system for React Native. Floating headers/footers, keyboard awareness, and scroll-away chrome",
5
5
  "keywords": [
6
6
  "react-native",
@@ -65,8 +65,8 @@
65
65
  "@infinitetoken/jest-config": "^0.4.0",
66
66
  "@infinitetoken/tsconfig": "^0.5.0",
67
67
  "@reduxjs/toolkit": "^2.12.0",
68
- "@rific/auto-paper": "^0.12.0",
69
- "@rific/core": "^0.2.0",
68
+ "@rific/auto-paper": "^0.13.2",
69
+ "@rific/core": "^0.3.0",
70
70
  "@testing-library/dom": "^10.4.1",
71
71
  "@testing-library/react": "^16.3.2",
72
72
  "@types/jest": "^30.0.0",
@@ -80,7 +80,7 @@
80
80
  "prettier": "^3.8.3",
81
81
  "react": "^19.0.0",
82
82
  "react-dom": "^19.0.0",
83
- "react-native": "^0.83.0",
83
+ "react-native": "^0.86.3",
84
84
  "react-native-gesture-handler": "^2.32.0",
85
85
  "react-native-keyboard-controller": "*",
86
86
  "react-native-paper": "^5.12.0",
@@ -38,13 +38,13 @@ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component
38
38
 
39
39
  const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, keyboardAware, pullSearchHeight, style })
40
40
  const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
41
- // -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
41
+ // -1 never matches a real generation (starts at 0, only increments) - the runOnUI call below
42
42
  // corrects it before any scroll event could observe the placeholder.
43
43
  const capturedGeneration = useSharedValue(-1)
44
44
  // Layout effects (not passive effects): React guarantees all layout-effect cleanups for fibers
45
45
  // removed in a commit run before any layout-effect setups for fibers added in that same commit,
46
46
  // but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
47
- // instance's unmount and the incoming instance's mount land in the same commit — with passive
47
+ // instance's unmount and the incoming instance's mount land in the same commit - with passive
48
48
  // effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
49
49
  // capturing a stale generation and silently disabling this instance's onScroll handling forever.
50
50
  // Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
@@ -54,7 +54,7 @@ const CustomListInner = <P extends object>({ chipProps, chipThreshold, component
54
54
  // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
55
55
  // only be queued after this component finishes mounting.
56
56
  //
57
- // Web has no such thread split, though — see ScrollView.tsx's own doc comment on this same
57
+ // Web has no such thread split, though - see ScrollView.tsx's own doc comment on this same
58
58
  // pattern for why runOnUI's deferred-to-next-frame behavior there leaves a real gap, and why a
59
59
  // synchronous assignment is the correct fix rather than a workaround.
60
60
  useLayoutEffect(() => {
package/src/FlatList.tsx CHANGED
@@ -30,9 +30,9 @@ export type FlatListProps<T> = Omit<RNFlatListProps<T>, 'onRefresh' | 'refreshin
30
30
 
31
31
  const FlatListInner = <T,>({ chipProps, chipThreshold, columnWrapperStyle, contentContainerStyle: externalContentContainerStyle, data, initialNumToRender = 20, keyExtractor, maxToRenderPerBatch = 50, numColumns, renderFilters, renderItem, footerFixed: footerFixedProp, gesture, headerFixed: headerFixedProp, horizontal, keyboardAware, ListHeaderComponent: externalListHeaderComponent, onChipPress, onMomentumScrollEnd: externalMomentumScrollEnd, onRefresh, onScrollBeginDrag: externalScrollBeginDrag, onScrollEndDrag: externalScrollEndDrag, pagingEnabled, pullSearchHeight, refreshing, removeClippedSubviews = false, showsHorizontalScrollIndicator, showsVerticalScrollIndicator, style, onContentSizeChange, ref: externalRef, windowSize = 100, ...props }: FlatListProps<T>) => {
32
32
  // Plain (non-Animated) FlatList: react-native-reanimated's Animated.FlatList does not reliably
33
- // honor the initial `contentOffset` prop on a key-forced remount — the header/list can briefly
33
+ // honor the initial `contentOffset` prop on a key-forced remount - the header/list can briefly
34
34
  // render at the wrong position. A plain FlatList honors it correctly, so scroll position is driven
35
- // by a regular JS onScroll callback (useScrollHandlerJS) instead of a Reanimated worklet — which
35
+ // by a regular JS onScroll callback (useScrollHandlerJS) instead of a Reanimated worklet - which
36
36
  // also avoids the "WorkletEventHandler" crash that a worklet handler triggers when attached to a
37
37
  // non-Animated component.
38
38
  const scrollView = useRef<RNFlatList<T>>(null)
@@ -42,10 +42,10 @@ const FlatListInner = <T,>({ chipProps, chipThreshold, columnWrapperStyle, conte
42
42
  // RN's own FlatList implements numColumns by chunking `data` into rows internally, but throws if
43
43
  // numColumns ever changes without the caller changing `key` to force a full remount. On iOS,
44
44
  // Fabric pools/recycles native ScrollView instances by component type across such a remount, and
45
- // the recycled instance's onScroll can silently stop firing for the new React instance entirely —
45
+ // the recycled instance's onScroll can silently stop firing for the new React instance entirely -
46
46
  // a native event-routing issue, not something a JS-side retry loop can detect or correct. Doing
47
47
  // the row-chunking ourselves here and always passing numColumns={1} through to the underlying
48
- // RNFlatList means numColumns never "changes" from RN's perspective — only `data`/`renderItem`
48
+ // RNFlatList means numColumns never "changes" from RN's perspective - only `data`/`renderItem`
49
49
  // change, which RNFlatList treats as an ordinary prop update with no remount at all, so callers
50
50
  // can toggle numColumns freely (e.g. switching a list to a grid) without ever hitting this class
51
51
  // of bug.
@@ -47,7 +47,7 @@ export const PullSearch = forwardRef<PullSearchHandle, PullSearchProps>(function
47
47
  (isHidden, wasHidden) => {
48
48
  if (isHidden && !wasHidden) runOnJS(blurInput)()
49
49
  },
50
- // scrollPosition has to be listed too, not just read — see useStickyHeaders' useAnimatedReaction
50
+ // scrollPosition has to be listed too, not just read - see useStickyHeaders' useAnimatedReaction
51
51
  // for why: on web, without reanimated's Babel plugin active, an unlisted SharedValue mutation
52
52
  // never re-triggers this reaction (confirmed empirically elsewhere in this package).
53
53
  [barHeight, headerHeight, blurInput, scrollPosition]
@@ -32,13 +32,13 @@ const ScrollViewInner = ({ chipProps, chipThreshold, children, contentContainerS
32
32
 
33
33
  const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, isHorizontal, keyboardAware, pullSearchHeight, style })
34
34
  const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
35
- // -1 never matches a real generation (starts at 0, only increments) — the runOnUI call below
35
+ // -1 never matches a real generation (starts at 0, only increments) - the runOnUI call below
36
36
  // corrects it before any scroll event could observe the placeholder.
37
37
  const capturedGeneration = useSharedValue(-1)
38
38
  // Layout effects (not passive effects): React guarantees all layout-effect cleanups for fibers
39
39
  // removed in a commit run before any layout-effect setups for fibers added in that same commit,
40
40
  // but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
41
- // instance's unmount and the incoming instance's mount land in the same commit — with passive
41
+ // instance's unmount and the incoming instance's mount land in the same commit - with passive
42
42
  // effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
43
43
  // capturing a stale generation and silently disabling this instance's onScroll handling forever.
44
44
  // Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
@@ -48,12 +48,12 @@ const ScrollViewInner = ({ chipProps, chipThreshold, children, contentContainerS
48
48
  // own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
49
49
  // only be queued after this component finishes mounting.
50
50
  //
51
- // Web has no such thread split, though — react-native-worklets' own runOnUI there (see its
51
+ // Web has no such thread split, though - react-native-worklets' own runOnUI there (see its
52
52
  // threads.js) only enqueues the callback for the NEXT requestAnimationFrame, it doesn't run it
53
53
  // synchronously. That opens exactly the gap the native path exists to close: a scroll event
54
54
  // (including useScrollInit's own mount-time imperative scrollTo) landing before that deferred
55
55
  // frame sees capturedGeneration still at its -1 placeholder, fails the generation check below,
56
- // and — since this effect only ever runs once per mount — never gets a second chance, silently
56
+ // and - since this effect only ever runs once per mount - never gets a second chance, silently
57
57
  // disabling scroll tracking (and therefore header/footer scroll-away) for this instance's entire
58
58
  // lifetime. Assigning directly is both simpler and provably correct on web: single-threaded JS
59
59
  // makes a synchronous write immediately visible to any read that follows it in program order, so
@@ -0,0 +1,37 @@
1
+ import { createContext } from 'react'
2
+ import type { SharedValue } from 'react-native-reanimated'
3
+
4
+ // Two contexts, deliberately split by who is allowed to see them:
5
+ //
6
+ // ScrollViewChromeHostContext is the claim machinery. ScrollViewProvider reads it to decide whether
7
+ // it is "hosted" and then immediately re-provides `null` around its own children, so a nested
8
+ // ScrollViewProvider (a picker or form modal rendered inside a screen) never sees the host and never
9
+ // competes with its parent screen for it.
10
+ //
11
+ // ScrollViewChromeContext is the public, never-cleared face of the same host: the shared offset a
12
+ // persistent bar animates from, plus pin/reveal controls. It has to survive the clearing above
13
+ // because the components that use it (a search bar, a pager) sit *below* a ScrollViewProvider.
14
+ export type ScrollViewChromeHost = {
15
+ activeId: string | null
16
+ claim: (id: string) => void
17
+ footprint: number
18
+ offset: SharedValue<number>
19
+ overhang: number
20
+ pinned: boolean
21
+ release: (id: string) => void
22
+ reveal: () => void
23
+ snapBack: boolean
24
+ useIsActive: () => boolean
25
+ }
26
+
27
+ export type ScrollViewChrome = {
28
+ addPin: () => void
29
+ footprint: number
30
+ offset: SharedValue<number>
31
+ overhang: number
32
+ removePin: () => void
33
+ reveal: () => void
34
+ }
35
+
36
+ export const ScrollViewChromeHostContext = createContext<ScrollViewChromeHost | null>(null)
37
+ export const ScrollViewChromeContext = createContext<ScrollViewChrome | null>(null)
@@ -0,0 +1,65 @@
1
+ import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
2
+ import { useSharedValue, withTiming } from 'react-native-reanimated'
3
+
4
+ import { ScrollViewChromeContext, ScrollViewChromeHostContext } from './ScrollViewChromeContext'
5
+
6
+ const REVEAL_DURATION = 200
7
+
8
+ const alwaysActive = () => true
9
+
10
+ export type ScrollViewChromeProviderProps = {
11
+ children: ReactNode
12
+ // Height of a persistent bottom bar's resting box (its row + safe-area inset), i.e. how far up from
13
+ // the screen bottom a hosted ScrollViewFooter has to sit to clear it. Passed in as a plain number
14
+ // rather than reported back by the bar, so every hosted screen has the right geometry on its very
15
+ // first render instead of one commit later.
16
+ footprint: number
17
+ // How far the bar pokes ABOVE its resting box (a FAB that dips out of it, say). Hosted footers pad
18
+ // their bottom by this instead of the safe-area inset, and the hide distance covers it.
19
+ overhang?: number
20
+ // Hosted footers reveal on a short scroll-up instead of only near the top. A bar that hides on
21
+ // scroll has to be reachable from anywhere in a list, so this is on by default - and there is no
22
+ // "hide but only reveal near the top" mode: `snapBack={false}` turns hiding off entirely, leaving
23
+ // the bar and hosted footers permanently revealed.
24
+ snapBack?: boolean
25
+ // A hook (not a plain function) returning whether the calling screen is the focused one, e.g.
26
+ // react-navigation's useIsFocused. Injected rather than imported so this package carries no
27
+ // navigation dependency. It has to be a stable reference: it is called as a hook on every render.
28
+ useIsActive?: () => boolean
29
+ }
30
+
31
+ // Mounted once above a navigator (a Tabs layout). Every ScrollViewProvider below it that is focused
32
+ // claims it, and while it does, that screen's scroll handlers drive one shared `offset` that the
33
+ // screen's own ScrollViewFooter AND the persistent bar both translate by - a single value read by
34
+ // both, so the two move as one rigid stack instead of being two animations that have to be kept in
35
+ // step.
36
+ export const ScrollViewChromeProvider = ({ children, footprint, overhang = 0, snapBack = true, useIsActive = alwaysActive }: ScrollViewChromeProviderProps) => {
37
+ const offset = useSharedValue(0)
38
+ const [claims, setClaims] = useState<string[]>([])
39
+ const [pins, setPins] = useState(0)
40
+ const activeId = claims.length > 0 ? claims[claims.length - 1] : null
41
+ const pinned = pins > 0
42
+ const claim = useCallback((id: string) => setClaims((current) => [...current.filter((c) => c !== id), id]), [])
43
+ const release = useCallback((id: string) => setClaims((current) => current.filter((c) => c !== id)), [])
44
+ const addPin = useCallback(() => setPins((current) => current + 1), [])
45
+ const removePin = useCallback(() => setPins((current) => Math.max(0, current - 1)), [])
46
+ const reveal = useCallback(() => {
47
+ offset.value = withTiming(0, { duration: REVEAL_DURATION })
48
+ }, [offset])
49
+ // offset is per-host but the scroll state that produced it is per-screen, and nothing else ever
50
+ // zeroes it except a scroll event landing at the top of a list. Without this, popping back to a
51
+ // screen that was hidden when it lost focus, or focusing one with no scroll view at all, would
52
+ // leave the bar wherever the last screen parked it. Any change of who owns the host, or a pin
53
+ // engaging (which also turns the scroll handlers off, so nothing else would ever bring it back),
54
+ // starts from fully revealed.
55
+ useEffect(() => {
56
+ reveal()
57
+ }, [activeId, pinned, reveal])
58
+ const host = useMemo(() => ({ activeId, claim, footprint, offset, overhang, pinned, release, reveal, snapBack, useIsActive }), [activeId, claim, footprint, offset, overhang, pinned, release, reveal, snapBack, useIsActive])
59
+ const chrome = useMemo(() => ({ addPin, footprint, offset, overhang, removePin, reveal }), [addPin, footprint, offset, overhang, removePin, reveal])
60
+ return (
61
+ <ScrollViewChromeContext.Provider value={chrome}>
62
+ <ScrollViewChromeHostContext.Provider value={host}>{children}</ScrollViewChromeHostContext.Provider>
63
+ </ScrollViewChromeContext.Provider>
64
+ )
65
+ }
@@ -3,6 +3,14 @@ import { SharedValue } from 'react-native-reanimated'
3
3
 
4
4
  export type ScrollViewContextType = {
5
5
  blur: boolean
6
+ // True when this provider is driven by a ScrollViewChromeProvider above the navigator (see
7
+ // ScrollViewChromeProvider). Hosted footers sit above the persistent bar, share its offset, and
8
+ // always snap back.
9
+ chromeHosted: boolean
10
+ chromeOverhang: number
11
+ // Whether this provider's scroll handlers may currently write footerOffset: always true unhosted,
12
+ // and only while this provider owns the host (and nothing has pinned it) when hosted.
13
+ chromeWritable: SharedValue<boolean>
6
14
  footerAboveKeyboard: boolean
7
15
  footerHeight: number | null
8
16
  footerHeightShared: SharedValue<number>
@@ -15,7 +23,7 @@ export type ScrollViewContextType = {
15
23
  // Plain (non-Reanimated) counter for JS-thread-only scroll handlers (FlatList's useScrollHandlerJS).
16
24
  // A SharedValue is deliberately NOT used here: mutating `.value` from inside a React commit-phase
17
25
  // callback (a layout effect) is not synchronously visible even to an immediate subsequent read in
18
- // the same callback — Reanimated defers the actual flush — which silently broke the "did another
26
+ // the same callback - Reanimated defers the actual flush - which silently broke the "did another
19
27
  // list instance just unmount" check this counter exists for. A plain ref has no such batching.
20
28
  jsListGeneration: RefObject<number>
21
29
  listGeneration: SharedValue<number>
@@ -32,11 +40,18 @@ export type ScrollViewContextType = {
32
40
  setHeaderHeight: (h: number | null) => void
33
41
  setProgress: (p: number | null) => void
34
42
  setProgressing: (b: boolean) => void
43
+ // The farthest footerOffset can travel: how far the whole bottom stack has to slide to clear the
44
+ // screen. Unhosted this IS footerHeightShared (the same SharedValue), so unhosted snap-back
45
+ // clamps exactly as it always has.
46
+ stackHeightShared: SharedValue<number>
35
47
  tabBarHeight: number
36
48
  }
37
49
 
38
50
  export const ScrollViewContext = createContext<ScrollViewContextType>({
39
51
  blur: true,
52
+ chromeHosted: false,
53
+ chromeOverhang: 0,
54
+ chromeWritable: null as unknown as SharedValue<boolean>,
40
55
  footerAboveKeyboard: false,
41
56
  footerHeight: null,
42
57
  footerHeightShared: null as unknown as SharedValue<number>,
@@ -61,5 +76,6 @@ export const ScrollViewContext = createContext<ScrollViewContextType>({
61
76
  setProgressing: () => {},
62
77
  snapBackFooterShared: null as unknown as SharedValue<boolean>,
63
78
  snapBackHeaderShared: null as unknown as SharedValue<boolean>,
79
+ stackHeightShared: null as unknown as SharedValue<number>,
64
80
  tabBarHeight: 0
65
81
  })
@@ -1,6 +1,6 @@
1
1
  import { BlurView } from '@rific/auto-paper'
2
- import { type ReactNode, useContext, useEffect, useRef } from 'react'
3
- import { type LayoutChangeEvent, StyleSheet, View, type ViewStyle } from 'react-native'
2
+ import { type ReactNode, useContext, useEffect, useLayoutEffect, useRef } from 'react'
3
+ import { type LayoutChangeEvent, Platform, StyleSheet, View, type ViewStyle } from 'react-native'
4
4
  import Animated, { useAnimatedStyle } from 'react-native-reanimated'
5
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
6
6
 
@@ -13,17 +13,17 @@ export type ScrollViewFooterProps = {
13
13
  }
14
14
 
15
15
  export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) => {
16
- const { blur, footerAboveKeyboard, footerHeight, footerFixed, footerOffset, headerHeightShared, pullSearchHeightShared, scrollPosition, setFooterHeight, snapBackFooterShared, tabBarHeight } = useContext(ScrollViewContext)
16
+ const { blur, chromeHosted, chromeOverhang, footerAboveKeyboard, footerHeight, footerFixed, footerOffset, headerHeightShared, pullSearchHeightShared, scrollPosition, setFooterHeight, snapBackFooterShared, stackHeightShared, tabBarHeight } = useContext(ScrollViewContext)
17
17
  const insets = useSafeAreaInsets()
18
18
  const keyboardHeight = useKeyboardInset()
19
- // setFooterHeight's own identity changes on every real measurement (ScrollViewProvider's
20
- // useCallback closes over `footerHeight` itself, so setting it recreates the callback). Depending
21
- // on it directly here re-ran this effect on every measurement too — tearing down the PREVIOUS
22
- // closure as a "cleanup" and calling ITS setFooterHeight(null) right after the real height had
23
- // just been set, permanently clobbering it back to null with no further layout event left to
24
- // recover it (the DOM node's true size never changed, so onLayout never fires again). Reading
25
- // through a ref sidesteps that: the effect itself only ever runs once, on mount/unmount, so its
26
- // cleanup fires exactly when it's meant to (a real unmount) rather than after every measurement.
19
+ // setFooterHeight used to change identity on every real measurement (ScrollViewProvider's
20
+ // useCallback closed over `footerHeight` itself). Depending on it directly here re-ran this
21
+ // effect on every measurement too - tearing down the PREVIOUS closure as a "cleanup" and calling
22
+ // ITS setFooterHeight(null) right after the real height had just been set, permanently clobbering
23
+ // it back to null with no further layout event left to recover it (the DOM node's true size never
24
+ // changed, so onLayout never fires again). The provider's setter is stable now, but the context
25
+ // type doesn't promise that, so this still reads through a ref: the effect itself only ever runs
26
+ // once, on mount/unmount, so its cleanup fires exactly when it's meant to (a real unmount).
27
27
  // The mirror below has to be effect-based too, not a bare render-time assignment: writing to the
28
28
  // ref during render is a React-render-purity violation (render can be discarded/replayed), so it's
29
29
  // done in its own no-deps effect, which still re-runs on every commit and keeps the ref current.
@@ -46,17 +46,23 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
46
46
  }
47
47
  // See ScrollViewHeader's translateStyle/blurStyle/progressStyle for why every SharedValue read
48
48
  // here (scrollPosition, headerHeightShared, pullSearchHeightShared, snapBackFooterShared,
49
- // footerOffset) also has to be listed explicitly — on web, reanimated doesn't pick up their
49
+ // footerOffset) also has to be listed explicitly - on web, reanimated doesn't pick up their
50
50
  // mutations as reactive triggers unless they're in this array, unlike native.
51
51
  const footerStyle = useAnimatedStyle(() => {
52
+ // Hosted, the offset is the ONE value the persistent bar translates by too, so it is always
53
+ // read straight through - never short-circuited to 0 for footerFixed the way the unhosted path
54
+ // below is. Footer Lock reaches it by animating the shared offset back to 0 instead (see
55
+ // ChromeRegistrar), which keeps this footer and the bar equal on every frame, including the
56
+ // ones where one would otherwise jump and the other animate.
57
+ if (chromeHosted) return { transform: [{ translateY: Math.min(Math.max(footerOffset.value, 0), stackHeightShared.value) }] }
52
58
  if (footerFixed) return { transform: [{ translateY: 0 }] }
53
59
  if (snapBackFooterShared.value) return { transform: [{ translateY: footerOffset.value }] }
54
60
  const effective = scrollPosition.value + headerHeightShared.value - pullSearchHeightShared.value
55
61
  if (effective <= 0) return { transform: [{ translateY: 0 }] }
56
62
  return { transform: [{ translateY: Math.min(effective, footerHeight ?? 0) }] }
57
- }, [footerHeight, footerFixed, headerHeightShared, pullSearchHeightShared, scrollPosition, snapBackFooterShared, footerOffset])
63
+ }, [chromeHosted, footerHeight, footerFixed, headerHeightShared, pullSearchHeightShared, scrollPosition, snapBackFooterShared, footerOffset, stackHeightShared])
58
64
  // Floating above the keyboard is done by GROWING this container (via paddingBottom) rather than
59
- // translating it. Translating the whole bar left its bottom edge — and the BlurView filling it —
65
+ // translating it. Translating the whole bar left its bottom edge - and the BlurView filling it -
60
66
  // floating above the true screen bottom, exposing a gap of whatever sits behind (visible as a
61
67
  // strip above the keyboard, and worse at the keyboard's rounded top corners, which dip below its
62
68
  // reported height). Growing the container instead keeps its `bottom: 0` anchor untouched: the
@@ -64,25 +70,53 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
64
70
  // while the extra height pushes the row (this bar's actual content) up by keyboardHeight, same
65
71
  // as the old translateY did for it.
66
72
  const containerPaddingBottom = footerFixed && footerAboveKeyboard ? keyboardHeight : 0
73
+ // Web only, same as ScrollViewHeader's pre-paint measurement: react-native-web's onLayout lands
74
+ // well after the first paint (a ResizeObserver callback plus a setTimeout measure - over a second
75
+ // on a cold load), and until footerHeight is known this footer draws no backdrop (see the
76
+ // BlurView gate below) and the list reserves no room for it, so the row floated bare over the
77
+ // content and its bar only appeared once the measurement arrived. Measuring the row here, after
78
+ // the DOM is built but before the browser paints, makes the very first frame the final one. The
79
+ // container's own height is the row plus containerPaddingBottom, which is what onLayout reports,
80
+ // so that later onLayout is a no-op. A node with no layout box (hidden ancestor) reports 0 and
81
+ // is left to onLayout; a footer mounting while another's height is already known does too.
82
+ const rowRef = useRef<View>(null)
83
+ useLayoutEffect(() => {
84
+ if (Platform.OS !== 'web' || footerHeight) return
85
+ const height = (rowRef.current as unknown as { offsetHeight?: unknown } | null)?.offsetHeight
86
+ if (typeof height !== 'number' || height <= 0) return
87
+ setFooterHeight(height + containerPaddingBottom)
88
+ // Mount-only: after this, onLayout owns every later change.
89
+ // eslint-disable-next-line react-hooks/exhaustive-deps
90
+ }, [])
67
91
  // The safe-area bottom inset exists to clear the home indicator at the physical screen edge.
68
92
  // Once footerAboveKeyboard has floated this bar's content up above an open keyboard, it's no
69
- // longer sitting at that edge — the keyboard itself now occupies that space — so the inset would
93
+ // longer sitting at that edge - the keyboard itself now occupies that space - so the inset would
70
94
  // just be dead space under the content (e.g. under a Save button) for no reason.
71
95
  //
72
96
  // This has to ramp continuously with keyboardHeight, not flip as a boolean once the keyboard
73
97
  // fully closes: containerPaddingBottom above already tracks keyboardHeight every frame, so a
74
98
  // step-function padding change here lands in the same frame keyboardHeight hits 0, growing this
75
- // row by insets.bottom right as the container finishes shrinking back down — the content visually
99
+ // row by insets.bottom right as the container finishes shrinking back down - the content visually
76
100
  // overshoots to the physical bottom edge and then snaps back up. Ramping this padding by the same
77
101
  // keyboardHeight keeps the content's on-screen position moving continuously throughout: for
78
102
  // keyboardHeight >= insets.bottom the padding is 0 (unchanged from mid-close), and for the last
79
103
  // insets.bottom worth of keyboard travel, containerPaddingBottom's continued shrink and this
80
104
  // padding's growth cancel out exactly, so the content holds still at the safe-area line instead
81
105
  // of dropping past it and correcting back up.
82
- const rowPaddingBottom = footerFixed && footerAboveKeyboard ? Math.max(insets.bottom - keyboardHeight, 0) : insets.bottom
106
+ //
107
+ // Hosted, the bar (which already includes the safe-area inset in its own footprint) sits below
108
+ // this footer, so the row's bottom padding is only what pokes above the bar (a FAB) - not the
109
+ // inset a second time.
110
+ const bottomPadding = chromeHosted ? chromeOverhang : insets.bottom
111
+ const rowPaddingBottom = footerFixed && footerAboveKeyboard ? Math.max(bottomPadding - keyboardHeight, 0) : bottomPadding
112
+ // A hosted, keyboard-floating footer would otherwise float a whole bar-height above the keyboard:
113
+ // containerPaddingBottom above already lifts it by keyboardHeight, on top of `bottom: footprint`.
114
+ // The bar it was clearing is behind the keyboard by then, so the footprint has to shrink away as
115
+ // the keyboard rises.
116
+ const footerBottom = chromeHosted && footerFixed && footerAboveKeyboard ? Math.max(tabBarHeight - keyboardHeight, 0) : tabBarHeight
83
117
  // A consumer's own `style.paddingBottom` (e.g. tightening the row's vertical rhythm to match a
84
118
  // header's actionMargin) is spread after rowPaddingBottom below, so it has to be added to it
85
- // rather than assigned after it — plain style-array merge would otherwise let it silently
119
+ // rather than assigned after it - plain style-array merge would otherwise let it silently
86
120
  // replace the safe-area clearance instead of sitting on top of it, leaving the row's real
87
121
  // content (buttons, etc.) flush against the home indicator.
88
122
  const { paddingBottom: consumerPaddingBottom, ...restStyle } = style ?? {}
@@ -91,9 +125,11 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
91
125
  // tab bar (a fixture outside this package's header/footer concepts entirely, same as
92
126
  // useScrollList's content-inset reservation) sits below this at the true screen bottom, so this
93
127
  // bar has to clear it rather than rendering flush against 0 and overlapping/hiding behind it.
94
- <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[styles.footer, { bottom: tabBarHeight, paddingBottom: containerPaddingBottom }, footerStyle]}>
128
+ <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[styles.footer, { bottom: footerBottom, paddingBottom: containerPaddingBottom }, footerStyle]}>
95
129
  {(footerHeight ?? 0) > 0 && <BlurView blur={blur} style={StyleSheet.absoluteFill} />}
96
- <View style={[styles.row, restStyle, { paddingBottom: rowPaddingBottom + (typeof consumerPaddingBottom === 'number' ? consumerPaddingBottom : 0) }]}>{children}</View>
130
+ <View ref={rowRef} style={[styles.row, restStyle, { paddingBottom: rowPaddingBottom + (typeof consumerPaddingBottom === 'number' ? consumerPaddingBottom : 0) }]}>
131
+ {children}
132
+ </View>
97
133
  </Animated.View>
98
134
  )
99
135
  }
@@ -1,6 +1,6 @@
1
1
  import { BlurView } from '@rific/auto-paper'
2
- import { type ReactNode, useContext } from 'react'
3
- import { type LayoutChangeEvent, StyleSheet, Text, View, type ViewStyle } from 'react-native'
2
+ import { type ReactNode, useContext, useLayoutEffect, useRef } from 'react'
3
+ import { type LayoutChangeEvent, Platform, StyleSheet, Text, View, type ViewStyle } from 'react-native'
4
4
  import { Appbar, ProgressBar, Surface, useTheme } from 'react-native-paper'
5
5
  import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated'
6
6
  import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -12,12 +12,12 @@ export type ScrollViewHeaderProps = {
12
12
  actionSize?: number
13
13
  actionStyle?: ViewStyle
14
14
  // A plain callback renders the default Appbar.BackAction (themed onSurface, sized/positioned by
15
- // actionSize/iconSize below) — the common case, unchanged from before. Passing a ReactNode
15
+ // actionSize/iconSize below) - the common case, unchanged from before. Passing a ReactNode
16
16
  // instead skips that default entirely and renders the given element in its place, same escape
17
17
  // hatch trailingAction already gave the other corner: a caller that wants its own color, icon,
18
18
  // or component altogether (a colored IconButton, say) isn't stuck fighting Appbar.BackAction's
19
19
  // own fixed color/no-customization API to get it. backActionAccessibilityLabel/iconSize below
20
- // only apply to the default-rendered case — a custom element carries its own.
20
+ // only apply to the default-rendered case - a custom element carries its own.
21
21
  backAction?: (() => void) | ReactNode
22
22
  backActionAccessibilityLabel?: string
23
23
  backActionFixed?: boolean
@@ -56,7 +56,7 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
56
56
  const top = topInset ? insets.top : 0
57
57
  // Tracks "has this header's own onLayout fired yet" as a SharedValue rather than deriving it from
58
58
  // headerHeight (a plain, React-state-backed number) so the worklets below never have to list a
59
- // value that flips from null to a real measured number in their dependency arrays — see the
59
+ // value that flips from null to a real measured number in their dependency arrays - see the
60
60
  // comment on translateStyle/blurStyle/progressStyle for why that recreation was a real crash, not
61
61
  // just a perf concern.
62
62
  const measuredShared = useSharedValue(false)
@@ -68,24 +68,45 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
68
68
  measuredShared.value = true
69
69
  if (headerHeight !== height) setHeaderHeight(height)
70
70
  }
71
+ // Web only: measure synchronously before the first paint instead of waiting for onLayout.
72
+ // react-native-web delivers onLayout from a ResizeObserver callback plus a setTimeout measure,
73
+ // which routinely lands after ScrollViewProvider's two-frame "no header, assume 0" fallback -
74
+ // so the first frames painted headerHeight === 0: header absolute with no backdrop, list with no
75
+ // top padding, its first rows (sort/filter chips) drawn up inside the header until the real
76
+ // height arrived. A layout effect runs after the DOM is built but before the browser paints, and
77
+ // a state update made here is applied synchronously before that paint too, so the very first
78
+ // frame on screen is already the final measured layout. `headerHeight` being 0 still runs this
79
+ // (a header mounting after the fallback fired); a node with no layout box (an ancestor hidden
80
+ // via display:none) reports 0 and is left to onLayout. offsetHeight is the same integer
81
+ // react-native-web's onLayout reports, so that later onLayout is a no-op rather than a re-set.
82
+ const contentRef = useRef<View>(null)
83
+ useLayoutEffect(() => {
84
+ if (Platform.OS !== 'web' || headerHeight) return
85
+ const height = (contentRef.current as unknown as { offsetHeight?: unknown } | null)?.offsetHeight
86
+ if (typeof height !== 'number' || height <= 0) return
87
+ measuredShared.value = true
88
+ setHeaderHeight(height)
89
+ // Mount-only: after this, onLayout owns every later change.
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [])
71
92
  const actionMargin = 4
72
93
  const contentMinHeight = actionSize + 2 * actionMargin
73
94
  // Same natural height the header renders at in-flow before headerHeight is ever measured (see
74
95
  // useScrollList's containerStyle comment for that same "in-flow first, absolute once measured"
75
- // pattern) — used as the blur backdrop's own height for that same pre-measurement window, so it
96
+ // pattern) - used as the blur backdrop's own height for that same pre-measurement window, so it
76
97
  // doesn't render as a flat, unblurred box for the ~60-80ms an onLayout round trip takes on web.
77
98
  const unmeasuredHeight = top + contentMinHeight
78
99
  // Every one of these worklets reads SharedValues only (scrollPosition, snapBackHeaderShared,
79
- // headerOffset, pullSearchHeightShared, headerHeightShared, measuredShared) — deliberately NOT the
100
+ // headerOffset, pullSearchHeightShared, headerHeightShared, measuredShared) - deliberately NOT the
80
101
  // plain `headerHeight` context value, even though it holds the same number. `headerHeight` is a
81
102
  // React-state-backed `number | null` that changes identity on every measurement (starting at null,
82
103
  // then flipping to a real pixel value within a frame of mount); putting it in a useAnimatedStyle
83
104
  // dependency array forces Reanimated to tear down and recreate the worklet the moment that happens
84
- // — which crashed outright ("[Worklets] Tried to synchronously call a Remote Function") on
105
+ // - which crashed outright ("[Worklets] Tried to synchronously call a Remote Function") on
85
106
  // react-native-worklets 0.10.1. SharedValues never change identity, only their `.value`, so they're
86
107
  // safe to depend on and don't retrigger this. They still have to be LISTED here despite that: on
87
108
  // web, without the Reanimated Babel plugin's automatic worklet dependency capture, a SharedValue's
88
- // mutations aren't picked up as reactive unless its reference appears in this array — omitting one
109
+ // mutations aren't picked up as reactive unless its reference appears in this array - omitting one
89
110
  // silently freezes that value's contribution to the header/blur/progress-bar's scroll tracking at
90
111
  // whatever it was when the array last changed.
91
112
  const translateStyle = useAnimatedStyle(() => {
@@ -119,7 +140,7 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
119
140
  {headerHeight !== 0 && <BlurView blur={blur} style={[styles.blurInner, { height: headerHeight ?? unmeasuredHeight }]} />}
120
141
  </Animated.View>
121
142
  <Animated.View onLayout={handleLayout} pointerEvents='box-none' style={[headerHeight === null ? styles.headerInit : styles.header, translateStyle]}>
122
- <View style={[{ paddingTop: top }, style]}>
143
+ <View ref={contentRef} style={[{ paddingTop: top }, style]}>
123
144
  {children ?? (
124
145
  <View style={[styles.content, { minHeight: contentMinHeight }]}>
125
146
  <View style={styles.side} />