@rific/scroll-view 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +175 -1
- package/dist/index.d.mts +21 -3
- package/dist/index.d.ts +21 -3
- package/dist/index.js +332 -153
- package/dist/index.mjs +308 -133
- package/package.json +3 -4
- package/src/ScrollViewChromeContext.ts +37 -0
- package/src/ScrollViewChromeProvider.tsx +65 -0
- package/src/ScrollViewContext.ts +16 -0
- package/src/ScrollViewFooter.tsx +51 -15
- package/src/ScrollViewHeader.tsx +24 -3
- package/src/ScrollViewProvider.tsx +96 -23
- package/src/index.ts +3 -0
- package/src/internal/ChromeRegistrar.tsx +37 -0
- package/src/internal/chrome.ts +19 -0
- package/src/internal/useScrollHandler.ts +26 -5
- package/src/internal/useScrollHandlerJS.ts +31 -7
- package/src/internal/useScrollList.ts +22 -5
- package/src/useScrollViewChrome.ts +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rific/scroll-view",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
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",
|
|
@@ -92,15 +92,14 @@
|
|
|
92
92
|
},
|
|
93
93
|
"peerDependencies": {
|
|
94
94
|
"@rific/auto-paper": ">=0.10.0",
|
|
95
|
-
"@rific/core": "
|
|
95
|
+
"@rific/core": ">=0.1.1",
|
|
96
96
|
"react": ">=19.0.0",
|
|
97
97
|
"react-native": ">=0.83.0",
|
|
98
98
|
"react-native-gesture-handler": ">=2.0.0 <3.0.0",
|
|
99
99
|
"react-native-keyboard-controller": ">=1.0.0",
|
|
100
100
|
"react-native-paper": ">=5.0.0",
|
|
101
101
|
"react-native-reanimated": ">=4.0.0",
|
|
102
|
-
"react-native-safe-area-context": ">=5.0.0"
|
|
103
|
-
"react-native-worklets": "0.10.x"
|
|
102
|
+
"react-native-safe-area-context": ">=5.0.0"
|
|
104
103
|
},
|
|
105
104
|
"publishConfig": {
|
|
106
105
|
"access": "public"
|
|
@@ -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
|
+
}
|
package/src/ScrollViewContext.ts
CHANGED
|
@@ -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>
|
|
@@ -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
|
})
|
package/src/ScrollViewFooter.tsx
CHANGED
|
@@ -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
|
|
20
|
-
// useCallback
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// through a ref
|
|
26
|
-
// cleanup fires exactly when it's meant to (a real unmount)
|
|
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.
|
|
@@ -49,12 +49,18 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
|
|
|
49
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
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
|
|
@@ -64,6 +70,24 @@ 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
93
|
// longer sitting at that edge — the keyboard itself now occupies that space — so the inset would
|
|
@@ -79,7 +103,17 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
|
|
|
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
|
-
|
|
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
119
|
// rather than assigned after it — plain style-array merge would otherwise let it silently
|
|
@@ -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:
|
|
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) }]}>
|
|
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
|
}
|
package/src/ScrollViewHeader.tsx
CHANGED
|
@@ -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'
|
|
@@ -68,6 +68,27 @@ 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
|
|
@@ -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} />
|
|
@@ -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
|
}, [])
|
|
@@ -97,15 +156,29 @@ export const ScrollViewProvider = ({ blur, children, fixed = false, footerAboveK
|
|
|
97
156
|
// still on screen (e.g. a wizard cycling through several <ScrollViewFooter> instances).
|
|
98
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/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
|
+
}
|
|
@@ -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
|
+
}
|