@rific/scroll-view 0.9.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -43
- package/dist/index.js +3 -3
- package/dist/index.mjs +3 -3
- package/package.json +4 -4
- package/src/CustomList.tsx +3 -3
- package/src/FlatList.tsx +4 -4
- package/src/PullSearch.tsx +1 -1
- package/src/ScrollView.tsx +4 -4
- package/src/ScrollViewChromeProvider.tsx +2 -2
- package/src/ScrollViewContext.ts +1 -1
- package/src/ScrollViewFooter.tsx +9 -9
- package/src/ScrollViewHeader.tsx +8 -8
- package/src/ScrollViewProvider.tsx +9 -9
- package/src/SectionList.tsx +4 -4
- package/src/internal/ChromeRegistrar.tsx +1 -1
- package/src/internal/HorizontalDots.tsx +1 -1
- package/src/internal/ScrollViewChip.tsx +3 -3
- package/src/internal/useScrollHandlerJS.ts +4 -4
- package/src/internal/useScrollList.ts +9 -9
- package/src/internal/useStickyHeaders.ts +3 -3
- package/src/redux/scrollViewSlice.ts +1 -1
- package/src/useKeyboardInset.ts +1 -1
- package/src/useScrollInit.ts +12 -12
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jay Deaton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npm install @rific/scroll-view
|
|
|
14
14
|
npm install @rific/auto-paper react-native-reanimated react-native-gesture-handler react-native-safe-area-context react-native-paper react-native-keyboard-controller
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
The exported Redux slice has no dependency on `@reduxjs/toolkit`
|
|
17
|
+
The exported Redux slice has no dependency on `@reduxjs/toolkit` - it works with RTK stores, vanilla Redux, or no Redux at all.
|
|
18
18
|
|
|
19
19
|
## Quick start
|
|
20
20
|
|
|
@@ -57,7 +57,7 @@ App-wide defaults for scroll settings. Wrap your root layout to set global defau
|
|
|
57
57
|
|
|
58
58
|
| Prop | Type | Description |
|
|
59
59
|
|------|------|-------------|
|
|
60
|
-
| `children` | `ReactNode` |
|
|
60
|
+
| `children` | `ReactNode` | - |
|
|
61
61
|
| `initialValue` | `Partial<ScrollViewSettings>` | Initial settings applied once on mount |
|
|
62
62
|
| `onChange` | `(settings: ScrollViewSettings) => void` | Called whenever settings change |
|
|
63
63
|
|
|
@@ -70,15 +70,15 @@ Screen-level provider that owns header/footer state and scroll position. Must wr
|
|
|
70
70
|
| Prop | Type | Default | Description |
|
|
71
71
|
|------|------|---------|-------------|
|
|
72
72
|
| `blur` | `boolean` | system | Enable frosted-glass backdrop on header/footer |
|
|
73
|
-
| `chrome` | `boolean` | `true` | Join an enclosing [`ScrollViewChromeProvider`](#scrollviewchromeprovider) when there is one. Pass `false` to opt out
|
|
73
|
+
| `chrome` | `boolean` | `true` | Join an enclosing [`ScrollViewChromeProvider`](#scrollviewchromeprovider) when there is one. Pass `false` to opt out - for a nested provider that isn't a screen, such as a picker or form modal. No effect when nothing hosts |
|
|
74
74
|
| `fixed` | `boolean` | `false` | Pin both header and footer (overrides `headerFixed`/`footerFixed`) |
|
|
75
|
-
| `footerAboveKeyboard` | `boolean` | `false` | With a fixed footer, float it above the keyboard instead of letting the keyboard cover it
|
|
75
|
+
| `footerAboveKeyboard` | `boolean` | `false` | With a fixed footer, float it above the keyboard instead of letting the keyboard cover it - see [Keyboard awareness](#keyboard-awareness) |
|
|
76
76
|
| `headerFixed` | `boolean` | `false` | Pin header; overrides settings default |
|
|
77
77
|
| `footerFixed` | `boolean` | `false` | Pin footer; overrides settings default |
|
|
78
78
|
| `snapBack` | `boolean` | `false` | Snap header and footer back when scrolling up |
|
|
79
|
-
| `snapBackHeader` | `boolean` |
|
|
80
|
-
| `snapBackFooter` | `boolean` |
|
|
81
|
-
| `tabBarHeight` | `number` | `0` | Extra bottom inset reserved for a host app's own persistent tab bar (added on top of the safe-area inset, independent of any `ScrollViewFooter`). Ignored inside a `ScrollViewChromeProvider`
|
|
79
|
+
| `snapBackHeader` | `boolean` | - | Override `snapBack` for header only |
|
|
80
|
+
| `snapBackFooter` | `boolean` | - | Override `snapBack` for footer only |
|
|
81
|
+
| `tabBarHeight` | `number` | `0` | Extra bottom inset reserved for a host app's own persistent tab bar (added on top of the safe-area inset, independent of any `ScrollViewFooter`). Ignored inside a `ScrollViewChromeProvider` - the host's `footprint` replaces it (see [Persistent bottom chrome](#persistent-bottom-chrome-hiding-a-tab-bar-with-scroll)) |
|
|
82
82
|
|
|
83
83
|
---
|
|
84
84
|
|
|
@@ -88,13 +88,13 @@ Hosts a persistent bottom bar (a tab bar) so it hides on scroll together with ea
|
|
|
88
88
|
|
|
89
89
|
| Prop | Type | Default | Description |
|
|
90
90
|
|------|------|---------|-------------|
|
|
91
|
-
| `children` | `ReactNode` |
|
|
92
|
-
| `footprint` | `number` | required | Height of the bar's resting box, including its bottom safe-area inset
|
|
91
|
+
| `children` | `ReactNode` | - | Usually the navigator (`<Tabs>`) |
|
|
92
|
+
| `footprint` | `number` | required | Height of the bar's resting box, including its bottom safe-area inset - how far above the screen bottom a hosted `ScrollViewFooter` has to sit to clear it. A plain number rather than something the bar reports back, so every screen has the right geometry on its very first render |
|
|
93
93
|
| `overhang` | `number` | `0` | How far the bar pokes *above* its resting box (a raised centre button, say). Hosted footers pad their bottom by this instead of the safe-area inset, and the hide distance covers it |
|
|
94
94
|
| `snapBack` | `boolean` | `true` | Hosted footers reveal on a short scroll-up instead of only near the top. A bar that hides on scroll has to be reachable from anywhere in a list, so leave this on unless you have a reason not to |
|
|
95
|
-
| `useIsActive` | `() => boolean` | `() => true` | A hook returning whether the calling screen is the focused one
|
|
95
|
+
| `useIsActive` | `() => boolean` | `() => true` | A hook returning whether the calling screen is the focused one - pass react-navigation's `useIsFocused` (re-exported by `expo-router`). Only the focused screen drives the bar. Injected so this package carries no navigation dependency. **Must be a stable hook reference** (a module-level hook such as `useIsFocused`, never an inline arrow created per render): it is called as a hook on every render of every hosted provider |
|
|
96
96
|
|
|
97
|
-
The default `() => true` makes every mounted hosted screen claim the host, and the last one to mount owns it
|
|
97
|
+
The default `() => true` makes every mounted hosted screen claim the host, and the last one to mount owns it - only right for a navigator that keeps a single screen mounted. Use `useIsFocused` with `Tabs` or anything else that keeps visited screens alive.
|
|
98
98
|
|
|
99
99
|
---
|
|
100
100
|
|
|
@@ -104,18 +104,18 @@ Floating header with blur backdrop, title, back action, trailing action, and bui
|
|
|
104
104
|
|
|
105
105
|
| Prop | Type | Default | Description |
|
|
106
106
|
|------|------|---------|-------------|
|
|
107
|
-
| `title` | `string` |
|
|
108
|
-
| `caption` | `string` |
|
|
109
|
-
| `centerContent` | `ReactNode` |
|
|
110
|
-
| `children` | `ReactNode` |
|
|
111
|
-
| `backAction` | `() => void` |
|
|
107
|
+
| `title` | `string` | - | Center title text |
|
|
108
|
+
| `caption` | `string` | - | Subtitle below title |
|
|
109
|
+
| `centerContent` | `ReactNode` | - | Replaces title/caption with custom content |
|
|
110
|
+
| `children` | `ReactNode` | - | Left-side content (icon buttons, etc.) |
|
|
111
|
+
| `backAction` | `() => void` | - | Renders a back button and calls this on press |
|
|
112
112
|
| `backActionFixed` | `boolean` | settings | Keep back button visible as header scrolls away |
|
|
113
|
-
| `trailingAction` | `ReactNode` |
|
|
113
|
+
| `trailingAction` | `ReactNode` | - | Right-side floating action |
|
|
114
114
|
| `trailingActionFixed` | `boolean` | `true` | Keep trailing action visible as header scrolls away |
|
|
115
115
|
| `actionSize` | `number` | `48` | Size of floating action buttons |
|
|
116
116
|
| `iconSize` | `number` | `actionSize/2` | Icon size inside action buttons |
|
|
117
|
-
| `actionStyle` | `ViewStyle` |
|
|
118
|
-
| `style` | `ViewStyle` |
|
|
117
|
+
| `actionStyle` | `ViewStyle` | - | Style applied to action button backgrounds |
|
|
118
|
+
| `style` | `ViewStyle` | - | Style applied to the header content area |
|
|
119
119
|
| `topInset` | `boolean` | `true` | Pad for safe area top inset |
|
|
120
120
|
|
|
121
121
|
---
|
|
@@ -189,7 +189,7 @@ When `horizontal` and `pagingEnabled` are both true and `data` has more than one
|
|
|
189
189
|
|
|
190
190
|
### `SectionList`
|
|
191
191
|
|
|
192
|
-
Drop-in for React Native's `SectionList`. Accepts all `SectionListProps<ItemT, SectionT>` (except `horizontal`) plus the same set of library props as `FlatList`. Custom sticky section headers are supported
|
|
192
|
+
Drop-in for React Native's `SectionList`. Accepts all `SectionListProps<ItemT, SectionT>` (except `horizontal`) plus the same set of library props as `FlatList`. Custom sticky section headers are supported - pass `renderSectionHeader` and `stickySectionHeadersEnabled` and the library renders them via an animated overlay so they properly account for the floating header offset.
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
@@ -218,7 +218,7 @@ import { CustomList } from '@rific/scroll-view'
|
|
|
218
218
|
| `renderFilters` | `ReactNode` | Rendered below `ListHeaderComponent`, above list items |
|
|
219
219
|
| `scrollRef` | `RefObject<{ scrollToOffset }>` | Forward ref to the underlying list |
|
|
220
220
|
|
|
221
|
-
The underlying component receives `contentInset`, `contentOffset`, `onScroll`, `refreshControl`, and `scrollEventThrottle`
|
|
221
|
+
The underlying component receives `contentInset`, `contentOffset`, `onScroll`, `refreshControl`, and `scrollEventThrottle` - props it must support for the library to function.
|
|
222
222
|
|
|
223
223
|
---
|
|
224
224
|
|
|
@@ -282,7 +282,7 @@ export default function SearchScreen() {
|
|
|
282
282
|
| Prop | Type | Description |
|
|
283
283
|
|------|------|-------------|
|
|
284
284
|
| `onChangeText` | `(text: string) => void` | Called with trimmed search text |
|
|
285
|
-
| `onHeightChange` | `(height: number) => void` | Required
|
|
285
|
+
| `onHeightChange` | `(height: number) => void` | Required - pass the height back as `pullSearchHeight` |
|
|
286
286
|
| `value` | `string` | Controlled value |
|
|
287
287
|
| `placeholder` | `string` | Placeholder text |
|
|
288
288
|
| `debounce` | `boolean` | Debounce `onChangeText` by 500ms |
|
|
@@ -305,7 +305,7 @@ Pass `keyboardAware` to any scroll component (`ScrollView`, `FlatList`, `Section
|
|
|
305
305
|
</ScrollViewProvider>
|
|
306
306
|
```
|
|
307
307
|
|
|
308
|
-
By default, a fixed `ScrollViewFooter` doesn't follow the keyboard
|
|
308
|
+
By default, a fixed `ScrollViewFooter` doesn't follow the keyboard - it stays pinned to the screen bottom and the keyboard simply covers it. If the footer holds something the user needs while typing (a submit button, say), set `footerAboveKeyboard` on `ScrollViewProvider` so the footer floats above the keyboard instead, reachable without dismissing it first:
|
|
309
309
|
|
|
310
310
|
```tsx
|
|
311
311
|
<ScrollViewProvider footerFixed footerAboveKeyboard>
|
|
@@ -320,25 +320,25 @@ By default, a fixed `ScrollViewFooter` doesn't follow the keyboard — it stays
|
|
|
320
320
|
</ScrollViewProvider>
|
|
321
321
|
```
|
|
322
322
|
|
|
323
|
-
`footerAboveKeyboard` only has an effect when the footer is fixed (`footerFixed`)
|
|
323
|
+
`footerAboveKeyboard` only has an effect when the footer is fixed (`footerFixed`) - a footer that scrolls away with content has nothing to reserve keyboard space for. Always pair it with `keyboardAware: true` on any scroll component that could have a focused input while it's visible. Content always reserves space for the current footer height regardless of `keyboardAware` (so a fixed footer never overlaps it) - and while floating, that height already includes the keyboard, so a list with `keyboardAware: false` ends up reserving keyboard space anyway, just indirectly through the footer rather than directly. There's no useful configuration where you'd want the footer reachable above the keyboard but *not* want that same list's own content clear of it too, so don't rely on `keyboardAware: false` to opt a list out of this while `footerAboveKeyboard` is on - it won't.
|
|
324
324
|
|
|
325
|
-
Floating is done by growing the footer's own container rather than translating it, so its `BlurView` backdrop always stays sealed against the true physical screen bottom
|
|
325
|
+
Floating is done by growing the footer's own container rather than translating it, so its `BlurView` backdrop always stays sealed against the true physical screen bottom - nothing shows through beneath it, including at the keyboard's rounded top corners.
|
|
326
326
|
|
|
327
|
-
While floating above an open keyboard, the footer also drops its safe-area bottom inset
|
|
327
|
+
While floating above an open keyboard, the footer also drops its safe-area bottom inset - it's no longer sitting at the physical screen edge, so that padding would just be dead space under its content. As the keyboard closes, the inset ramps back in continuously over the last bit of its travel rather than snapping back once it's fully closed, so the content settles at the safe-area line smoothly instead of dropping to the physical bottom edge and correcting back up.
|
|
328
328
|
|
|
329
|
-
Keyboard awareness is native-only
|
|
329
|
+
Keyboard awareness is native-only - `useKeyboardInset` (and therefore `keyboardAware`/`footerAboveKeyboard`) always resolves to `0` height on web.
|
|
330
330
|
|
|
331
331
|
---
|
|
332
332
|
|
|
333
333
|
## Persistent bottom chrome (hiding a tab bar with scroll)
|
|
334
334
|
|
|
335
|
-
A `ScrollViewFooter` hides on scroll, but an app's tab bar lives outside this package
|
|
335
|
+
A `ScrollViewFooter` hides on scroll, but an app's tab bar lives outside this package - it stays put, and the footer has to be lifted above it with `tabBarHeight`. `ScrollViewChromeProvider` turns the two into one rigid stack: the screen's footer and the tab bar share a single animated offset, so they hide and reveal together as one piece, frame for frame.
|
|
336
336
|
|
|
337
337
|
Three pieces make it work:
|
|
338
338
|
|
|
339
|
-
1. **Host**
|
|
340
|
-
2. **Screens**
|
|
341
|
-
3. **Bar**
|
|
339
|
+
1. **Host** - mount `ScrollViewChromeProvider` above the navigator, giving it the bar's `footprint`.
|
|
340
|
+
2. **Screens** - nothing to change. Each `ScrollViewProvider` (and its `ScrollViewHeader`, `ScrollViewFooter`, scroll component) below the host joins it while its screen is focused.
|
|
341
|
+
3. **Bar** - your own tab bar reads the shared offset with `useScrollViewChromeStyle()` and applies it as an animated style. It has to overlay the screen (absolutely positioned at the bottom) with a resting box exactly `footprint` tall, since hosted footers are lifted by that number.
|
|
342
342
|
|
|
343
343
|
```tsx
|
|
344
344
|
// app/(tabs)/_layout.tsx
|
|
@@ -389,7 +389,7 @@ const styles = StyleSheet.create({
|
|
|
389
389
|
})
|
|
390
390
|
```
|
|
391
391
|
|
|
392
|
-
Screens are written exactly as they were
|
|
392
|
+
Screens are written exactly as they were - there is no chrome-specific prop on the happy path:
|
|
393
393
|
|
|
394
394
|
```tsx
|
|
395
395
|
// app/(tabs)/library.tsx
|
|
@@ -408,7 +408,7 @@ export default function Library() {
|
|
|
408
408
|
|
|
409
409
|
### Pinning the bar
|
|
410
410
|
|
|
411
|
-
`useScrollViewChromePin(active)` holds the bar fully revealed
|
|
411
|
+
`useScrollViewChromePin(active)` holds the bar fully revealed - and stops scroll from hiding it - for as long as `active` is `true`. Use it whenever something on screen needs the bar to stay put, such as a focused search field or a multi-select in progress. Pins are counted, so two independent pinners can't release each other, and it works from any component under the host (below a `ScrollViewProvider` too).
|
|
412
412
|
|
|
413
413
|
```tsx
|
|
414
414
|
function SearchField() {
|
|
@@ -418,11 +418,11 @@ function SearchField() {
|
|
|
418
418
|
}
|
|
419
419
|
```
|
|
420
420
|
|
|
421
|
-
`useScrollViewChromeReveal()` returns a function that animates the bar back to fully revealed (200ms)
|
|
421
|
+
`useScrollViewChromeReveal()` returns a function that animates the bar back to fully revealed (200ms) - handy for a tab press or a "scroll to top" action, as in the example above.
|
|
422
422
|
|
|
423
423
|
### Nested providers that aren't screens
|
|
424
424
|
|
|
425
|
-
A `ScrollViewProvider` nested inside another one never sees the host: every hosted provider clears it for its own children, so a panel inside a screen can't compete with its parent. A picker or form modal is the exception
|
|
425
|
+
A `ScrollViewProvider` nested inside another one never sees the host: every hosted provider clears it for its own children, so a panel inside a screen can't compete with its parent. A picker or form modal is the exception - a portal can re-parent it out from under its screen's provider, and it would then claim the host and drive the bar from its own scrolling. Pass `chrome={false}` to opt those out:
|
|
426
426
|
|
|
427
427
|
```tsx
|
|
428
428
|
<ScrollViewProvider chrome={false} footerFixed>
|
|
@@ -435,18 +435,18 @@ A `ScrollViewProvider` nested inside another one never sees the host: every host
|
|
|
435
435
|
|
|
436
436
|
### What changes for a hosted screen
|
|
437
437
|
|
|
438
|
-
- **The footer joins the stack.** It sits `footprint` above the screen bottom, translates by the shared offset (clamped to the full hide distance), and pads its bottom by `overhang` instead of the safe-area inset
|
|
438
|
+
- **The footer joins the stack.** It sits `footprint` above the screen bottom, translates by the shared offset (clamped to the full hide distance), and pads its bottom by `overhang` instead of the safe-area inset - the bar's `footprint` already includes that inset.
|
|
439
439
|
- **Hide distance.** The stack slides `footprint + max(footerHeight, overhang) + 2` points: the bar, plus the screen's footer (whose measured height already includes the `overhang` padding; with no footer, just the `overhang`), plus 2 points of slack so a soft shadow bleeding past the bar's box doesn't leave a sliver at the screen edge.
|
|
440
440
|
- **Footer snap-back is forced.** A hosted footer follows the host's `snapBack` (default `true`) whatever the provider, `snapBackFooter` or settings values say, so the stack reveals on a short scroll-up from anywhere in a list. The header's own snap-back is untouched.
|
|
441
|
-
- **Scrolling stops at an end.** Hiding tracks the scroll 1:1, so a drag can end with the stack half hidden. When a drag ends without momentum, or momentum ends, the stack animates (200ms) to whichever end is nearer
|
|
441
|
+
- **Scrolling stops at an end.** Hiding tracks the scroll 1:1, so a drag can end with the stack half hidden. When a drag ends without momentum, or momentum ends, the stack animates (200ms) to whichever end is nearer - fully shown or fully hidden, never cut off.
|
|
442
442
|
- **Short lists never hide.** A list that can't scroll at least the hide distance would strand the stack half hidden, so it never starts hiding and stays revealed.
|
|
443
443
|
- **Footer Lock keeps it revealed.** A fixed footer (`footerFixed`, `fixed`, or the settings default) stops the scroll handlers from writing the offset, and turning it on while the stack is hidden animates the stack back. The hosted footer still reads the shared offset rather than pinning itself to zero, so the footer and the bar stay equal on every frame, including that animation.
|
|
444
|
-
- **The focused screen owns the bar.** Screens that are mounted but unfocused never write the offset. Whenever ownership changes
|
|
445
|
-
- **The reserved bottom inset stays constant while hidden.** Scroll content reserves `footprint` plus the overhang (or a fixed footer's height) at the bottom. That space can't animate, so it stays reserved as the bar slides away rather than the list resizing mid-scroll
|
|
444
|
+
- **The focused screen owns the bar.** Screens that are mounted but unfocused never write the offset. Whenever ownership changes - switching tabs, or landing on a screen with no `ScrollViewProvider` at all - the stack resets to fully revealed instead of staying wherever the last screen parked it. A pin engaging does the same.
|
|
445
|
+
- **The reserved bottom inset stays constant while hidden.** Scroll content reserves `footprint` plus the overhang (or a fixed footer's height) at the bottom. That space can't animate, so it stays reserved as the bar slides away rather than the list resizing mid-scroll - the last row can always scroll clear of a revealed bar, and nothing shifts when the bar hides.
|
|
446
446
|
- **`tabBarHeight` is ignored.** The host's `footprint` is the same "reserve this much at the bottom" number, just known exactly, and takes its place.
|
|
447
447
|
- **With a fixed footer floating above the keyboard** (`footerFixed` + `footerAboveKeyboard`), the footer sits above whichever is taller, the bar or the keyboard, instead of floating a whole bar-height above the keyboard.
|
|
448
448
|
|
|
449
|
-
Outside a host
|
|
449
|
+
Outside a host - or in a provider with `chrome={false}` - none of this applies and every behavior in the rest of this README is unchanged. `useScrollViewChromeStyle`, `useScrollViewChromePin` and `useScrollViewChromeReveal` are also inert there (a permanent zero translate, a no-op pin and a no-op reveal), so a shared tab bar component can call them unconditionally.
|
|
450
450
|
|
|
451
451
|
---
|
|
452
452
|
|
|
@@ -465,7 +465,7 @@ const { scrollPosition, scrollHeight, progressing, progress, setProgress, setPro
|
|
|
465
465
|
| `scrollPosition` | `SharedValue<number>` | Live scroll offset (Reanimated worklet-safe) |
|
|
466
466
|
| `scrollHeight` | `number` | Visible scroll area height (window minus header/footer) |
|
|
467
467
|
| `progressing` | `boolean` | Whether the header progress bar is visible |
|
|
468
|
-
| `progress` | `number \| null` | Progress bar value (0
|
|
468
|
+
| `progress` | `number \| null` | Progress bar value (0-1), or `null` for indeterminate |
|
|
469
469
|
| `setProgress` | `(p: number \| null) => void` | Set progress; pass `null` for indeterminate |
|
|
470
470
|
| `setProgressing` | `(b: boolean) => void` | Show/hide the progress bar |
|
|
471
471
|
|
|
@@ -567,8 +567,8 @@ store.dispatch(scrollViewActions.initialize({ snapBack: true, headerFixed: false
|
|
|
567
567
|
| `headerFixed` | `false` | Pin header globally |
|
|
568
568
|
| `footerFixed` | `false` | Pin footer globally |
|
|
569
569
|
| `snapBack` | `false` | Snap header and footer back when scrolling up |
|
|
570
|
-
| `snapBackHeader` |
|
|
571
|
-
| `snapBackFooter` |
|
|
570
|
+
| `snapBackHeader` | - | Override `snapBack` for header only |
|
|
571
|
+
| `snapBackFooter` | - | Override `snapBack` for footer only |
|
|
572
572
|
| `backActionFixed` | `true` | Keep back button visible as header scrolls away |
|
|
573
573
|
|
|
574
574
|
Settings cascade: `ScrollViewSettingsProvider` → `ScrollViewProvider` → individual scroll component props. More specific values always win.
|
package/dist/index.js
CHANGED
|
@@ -956,7 +956,7 @@ var PullSearch = (0, import_react10.forwardRef)(function PullSearch2({ debounce,
|
|
|
956
956
|
(isHidden, wasHidden) => {
|
|
957
957
|
if (isHidden && !wasHidden) (0, import_react_native_reanimated9.runOnJS)(blurInput)();
|
|
958
958
|
},
|
|
959
|
-
// scrollPosition has to be listed too, not just read
|
|
959
|
+
// scrollPosition has to be listed too, not just read - see useStickyHeaders' useAnimatedReaction
|
|
960
960
|
// for why: on web, without reanimated's Babel plugin active, an unlisted SharedValue mutation
|
|
961
961
|
// never re-triggers this reaction (confirmed empirically elsewhere in this package).
|
|
962
962
|
[barHeight, headerHeight, blurInput, scrollPosition]
|
|
@@ -1393,7 +1393,7 @@ var ScrollViewProvider = ({ blur, chrome, children, fixed = false, footerAboveKe
|
|
|
1393
1393
|
(0, import_react17.useEffect)(() => {
|
|
1394
1394
|
if (!__DEV__ || headerHeight !== null) return;
|
|
1395
1395
|
const timeout = setTimeout(() => {
|
|
1396
|
-
console.warn("[scroll-view] No ScrollViewHeader ever measured inside this ScrollViewProvider
|
|
1396
|
+
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.");
|
|
1397
1397
|
}, 3e3);
|
|
1398
1398
|
return () => clearTimeout(timeout);
|
|
1399
1399
|
}, [headerHeight]);
|
|
@@ -1486,7 +1486,7 @@ function useStickyHeaders(headerFixed, enabled) {
|
|
|
1486
1486
|
// Same web-only reanimated gap as useAnimatedStyle elsewhere in this file: this "prepare"
|
|
1487
1487
|
// function's own SharedValue reads (sectionYPositions, headerHeightShared, scrollPosition,
|
|
1488
1488
|
// snapBackHeaderShared, headerOffset, pullSearchHeightShared) need to be listed explicitly too
|
|
1489
|
-
//
|
|
1489
|
+
// - confirmed empirically that without them, useAnimatedReaction re-evaluates once on mount and
|
|
1490
1490
|
// then never again on web, permanently freezing activeIndex/the active sticky section.
|
|
1491
1491
|
[enabled, headerFixed, insetsTop, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared]
|
|
1492
1492
|
);
|
package/dist/index.mjs
CHANGED
|
@@ -900,7 +900,7 @@ var PullSearch = forwardRef(function PullSearch2({ debounce, onChangeText, onHei
|
|
|
900
900
|
(isHidden, wasHidden) => {
|
|
901
901
|
if (isHidden && !wasHidden) runOnJS3(blurInput)();
|
|
902
902
|
},
|
|
903
|
-
// scrollPosition has to be listed too, not just read
|
|
903
|
+
// scrollPosition has to be listed too, not just read - see useStickyHeaders' useAnimatedReaction
|
|
904
904
|
// for why: on web, without reanimated's Babel plugin active, an unlisted SharedValue mutation
|
|
905
905
|
// never re-triggers this reaction (confirmed empirically elsewhere in this package).
|
|
906
906
|
[barHeight, headerHeight, blurInput, scrollPosition]
|
|
@@ -1337,7 +1337,7 @@ var ScrollViewProvider = ({ blur, chrome, children, fixed = false, footerAboveKe
|
|
|
1337
1337
|
useEffect7(() => {
|
|
1338
1338
|
if (!__DEV__ || headerHeight !== null) return;
|
|
1339
1339
|
const timeout = setTimeout(() => {
|
|
1340
|
-
console.warn("[scroll-view] No ScrollViewHeader ever measured inside this ScrollViewProvider
|
|
1340
|
+
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.");
|
|
1341
1341
|
}, 3e3);
|
|
1342
1342
|
return () => clearTimeout(timeout);
|
|
1343
1343
|
}, [headerHeight]);
|
|
@@ -1430,7 +1430,7 @@ function useStickyHeaders(headerFixed, enabled) {
|
|
|
1430
1430
|
// Same web-only reanimated gap as useAnimatedStyle elsewhere in this file: this "prepare"
|
|
1431
1431
|
// function's own SharedValue reads (sectionYPositions, headerHeightShared, scrollPosition,
|
|
1432
1432
|
// snapBackHeaderShared, headerOffset, pullSearchHeightShared) need to be listed explicitly too
|
|
1433
|
-
//
|
|
1433
|
+
// - confirmed empirically that without them, useAnimatedReaction re-evaluates once on mount and
|
|
1434
1434
|
// then never again on web, permanently freezing activeIndex/the active sticky section.
|
|
1435
1435
|
[enabled, headerFixed, insetsTop, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared]
|
|
1436
1436
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rific/scroll-view",
|
|
3
|
-
"version": "0.9.
|
|
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.
|
|
69
|
-
"@rific/core": "^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
|
+
"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",
|
package/src/CustomList.tsx
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/src/PullSearch.tsx
CHANGED
|
@@ -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
|
|
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]
|
package/src/ScrollView.tsx
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -18,7 +18,7 @@ export type ScrollViewChromeProviderProps = {
|
|
|
18
18
|
// their bottom by this instead of the safe-area inset, and the hide distance covers it.
|
|
19
19
|
overhang?: number
|
|
20
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
|
|
21
|
+
// scroll has to be reachable from anywhere in a list, so this is on by default - and there is no
|
|
22
22
|
// "hide but only reveal near the top" mode: `snapBack={false}` turns hiding off entirely, leaving
|
|
23
23
|
// the bar and hosted footers permanently revealed.
|
|
24
24
|
snapBack?: boolean
|
|
@@ -30,7 +30,7 @@ export type ScrollViewChromeProviderProps = {
|
|
|
30
30
|
|
|
31
31
|
// Mounted once above a navigator (a Tabs layout). Every ScrollViewProvider below it that is focused
|
|
32
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
|
|
33
|
+
// screen's own ScrollViewFooter AND the persistent bar both translate by - a single value read by
|
|
34
34
|
// both, so the two move as one rigid stack instead of being two animations that have to be kept in
|
|
35
35
|
// step.
|
|
36
36
|
export const ScrollViewChromeProvider = ({ children, footprint, overhang = 0, snapBack = true, useIsActive = alwaysActive }: ScrollViewChromeProviderProps) => {
|
package/src/ScrollViewContext.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type ScrollViewContextType = {
|
|
|
23
23
|
// Plain (non-Reanimated) counter for JS-thread-only scroll handlers (FlatList's useScrollHandlerJS).
|
|
24
24
|
// A SharedValue is deliberately NOT used here: mutating `.value` from inside a React commit-phase
|
|
25
25
|
// callback (a layout effect) is not synchronously visible even to an immediate subsequent read in
|
|
26
|
-
// the same callback
|
|
26
|
+
// the same callback - Reanimated defers the actual flush - which silently broke the "did another
|
|
27
27
|
// list instance just unmount" check this counter exists for. A plain ref has no such batching.
|
|
28
28
|
jsListGeneration: RefObject<number>
|
|
29
29
|
listGeneration: SharedValue<number>
|
package/src/ScrollViewFooter.tsx
CHANGED
|
@@ -18,7 +18,7 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
|
|
|
18
18
|
const keyboardHeight = useKeyboardInset()
|
|
19
19
|
// setFooterHeight used to change identity on every real measurement (ScrollViewProvider's
|
|
20
20
|
// useCallback closed over `footerHeight` itself). Depending on it directly here re-ran this
|
|
21
|
-
// effect on every measurement too
|
|
21
|
+
// effect on every measurement too - tearing down the PREVIOUS closure as a "cleanup" and calling
|
|
22
22
|
// ITS setFooterHeight(null) right after the real height had just been set, permanently clobbering
|
|
23
23
|
// it back to null with no further layout event left to recover it (the DOM node's true size never
|
|
24
24
|
// changed, so onLayout never fires again). The provider's setter is stable now, but the context
|
|
@@ -46,11 +46,11 @@ 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
|
|
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
52
|
// Hosted, the offset is the ONE value the persistent bar translates by too, so it is always
|
|
53
|
-
// read straight through
|
|
53
|
+
// read straight through - never short-circuited to 0 for footerFixed the way the unhosted path
|
|
54
54
|
// below is. Footer Lock reaches it by animating the shared offset back to 0 instead (see
|
|
55
55
|
// ChromeRegistrar), which keeps this footer and the bar equal on every frame, including the
|
|
56
56
|
// ones where one would otherwise jump and the other animate.
|
|
@@ -62,7 +62,7 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
|
|
|
62
62
|
return { transform: [{ translateY: Math.min(effective, footerHeight ?? 0) }] }
|
|
63
63
|
}, [chromeHosted, footerHeight, footerFixed, headerHeightShared, pullSearchHeightShared, scrollPosition, snapBackFooterShared, footerOffset, stackHeightShared])
|
|
64
64
|
// Floating above the keyboard is done by GROWING this container (via paddingBottom) rather than
|
|
65
|
-
// translating it. Translating the whole bar left its bottom edge
|
|
65
|
+
// translating it. Translating the whole bar left its bottom edge - and the BlurView filling it -
|
|
66
66
|
// floating above the true screen bottom, exposing a gap of whatever sits behind (visible as a
|
|
67
67
|
// strip above the keyboard, and worse at the keyboard's rounded top corners, which dip below its
|
|
68
68
|
// reported height). Growing the container instead keeps its `bottom: 0` anchor untouched: the
|
|
@@ -71,7 +71,7 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
|
|
|
71
71
|
// as the old translateY did for it.
|
|
72
72
|
const containerPaddingBottom = footerFixed && footerAboveKeyboard ? keyboardHeight : 0
|
|
73
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
|
|
74
|
+
// well after the first paint (a ResizeObserver callback plus a setTimeout measure - over a second
|
|
75
75
|
// on a cold load), and until footerHeight is known this footer draws no backdrop (see the
|
|
76
76
|
// BlurView gate below) and the list reserves no room for it, so the row floated bare over the
|
|
77
77
|
// content and its bar only appeared once the measurement arrived. Measuring the row here, after
|
|
@@ -90,13 +90,13 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
|
|
|
90
90
|
}, [])
|
|
91
91
|
// The safe-area bottom inset exists to clear the home indicator at the physical screen edge.
|
|
92
92
|
// Once footerAboveKeyboard has floated this bar's content up above an open keyboard, it's no
|
|
93
|
-
// longer sitting at that edge
|
|
93
|
+
// longer sitting at that edge - the keyboard itself now occupies that space - so the inset would
|
|
94
94
|
// just be dead space under the content (e.g. under a Save button) for no reason.
|
|
95
95
|
//
|
|
96
96
|
// This has to ramp continuously with keyboardHeight, not flip as a boolean once the keyboard
|
|
97
97
|
// fully closes: containerPaddingBottom above already tracks keyboardHeight every frame, so a
|
|
98
98
|
// step-function padding change here lands in the same frame keyboardHeight hits 0, growing this
|
|
99
|
-
// row by insets.bottom right as the container finishes shrinking back down
|
|
99
|
+
// row by insets.bottom right as the container finishes shrinking back down - the content visually
|
|
100
100
|
// overshoots to the physical bottom edge and then snaps back up. Ramping this padding by the same
|
|
101
101
|
// keyboardHeight keeps the content's on-screen position moving continuously throughout: for
|
|
102
102
|
// keyboardHeight >= insets.bottom the padding is 0 (unchanged from mid-close), and for the last
|
|
@@ -105,7 +105,7 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
|
|
|
105
105
|
// of dropping past it and correcting back up.
|
|
106
106
|
//
|
|
107
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)
|
|
108
|
+
// this footer, so the row's bottom padding is only what pokes above the bar (a FAB) - not the
|
|
109
109
|
// inset a second time.
|
|
110
110
|
const bottomPadding = chromeHosted ? chromeOverhang : insets.bottom
|
|
111
111
|
const rowPaddingBottom = footerFixed && footerAboveKeyboard ? Math.max(bottomPadding - keyboardHeight, 0) : bottomPadding
|
|
@@ -116,7 +116,7 @@ export const ScrollViewFooter = ({ children, style }: ScrollViewFooterProps) =>
|
|
|
116
116
|
const footerBottom = chromeHosted && footerFixed && footerAboveKeyboard ? Math.max(tabBarHeight - keyboardHeight, 0) : tabBarHeight
|
|
117
117
|
// A consumer's own `style.paddingBottom` (e.g. tightening the row's vertical rhythm to match a
|
|
118
118
|
// header's actionMargin) is spread after rowPaddingBottom below, so it has to be added to it
|
|
119
|
-
// rather than assigned after it
|
|
119
|
+
// rather than assigned after it - plain style-array merge would otherwise let it silently
|
|
120
120
|
// replace the safe-area clearance instead of sitting on top of it, leaving the row's real
|
|
121
121
|
// content (buttons, etc.) flush against the home indicator.
|
|
122
122
|
const { paddingBottom: consumerPaddingBottom, ...restStyle } = style ?? {}
|
package/src/ScrollViewHeader.tsx
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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)
|
|
@@ -70,7 +70,7 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
|
|
|
70
70
|
}
|
|
71
71
|
// Web only: measure synchronously before the first paint instead of waiting for onLayout.
|
|
72
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
|
|
73
|
+
// which routinely lands after ScrollViewProvider's two-frame "no header, assume 0" fallback -
|
|
74
74
|
// so the first frames painted headerHeight === 0: header absolute with no backdrop, list with no
|
|
75
75
|
// top padding, its first rows (sort/filter chips) drawn up inside the header until the real
|
|
76
76
|
// height arrived. A layout effect runs after the DOM is built but before the browser paints, and
|
|
@@ -93,20 +93,20 @@ export const ScrollViewHeader = ({ actionSize = 48, actionStyle, backAction, bac
|
|
|
93
93
|
const contentMinHeight = actionSize + 2 * actionMargin
|
|
94
94
|
// Same natural height the header renders at in-flow before headerHeight is ever measured (see
|
|
95
95
|
// useScrollList's containerStyle comment for that same "in-flow first, absolute once measured"
|
|
96
|
-
// pattern)
|
|
96
|
+
// pattern) - used as the blur backdrop's own height for that same pre-measurement window, so it
|
|
97
97
|
// doesn't render as a flat, unblurred box for the ~60-80ms an onLayout round trip takes on web.
|
|
98
98
|
const unmeasuredHeight = top + contentMinHeight
|
|
99
99
|
// Every one of these worklets reads SharedValues only (scrollPosition, snapBackHeaderShared,
|
|
100
|
-
// headerOffset, pullSearchHeightShared, headerHeightShared, measuredShared)
|
|
100
|
+
// headerOffset, pullSearchHeightShared, headerHeightShared, measuredShared) - deliberately NOT the
|
|
101
101
|
// plain `headerHeight` context value, even though it holds the same number. `headerHeight` is a
|
|
102
102
|
// React-state-backed `number | null` that changes identity on every measurement (starting at null,
|
|
103
103
|
// then flipping to a real pixel value within a frame of mount); putting it in a useAnimatedStyle
|
|
104
104
|
// dependency array forces Reanimated to tear down and recreate the worklet the moment that happens
|
|
105
|
-
//
|
|
105
|
+
// - which crashed outright ("[Worklets] Tried to synchronously call a Remote Function") on
|
|
106
106
|
// react-native-worklets 0.10.1. SharedValues never change identity, only their `.value`, so they're
|
|
107
107
|
// safe to depend on and don't retrigger this. They still have to be LISTED here despite that: on
|
|
108
108
|
// web, without the Reanimated Babel plugin's automatic worklet dependency capture, a SharedValue's
|
|
109
|
-
// mutations aren't picked up as reactive unless its reference appears in this array
|
|
109
|
+
// mutations aren't picked up as reactive unless its reference appears in this array - omitting one
|
|
110
110
|
// silently freezes that value's contribution to the header/blur/progress-bar's scroll tracking at
|
|
111
111
|
// whatever it was when the array last changed.
|
|
112
112
|
const translateStyle = useAnimatedStyle(() => {
|
|
@@ -12,7 +12,7 @@ import { ScrollViewSettingsContext } from './ScrollViewSettingsContext'
|
|
|
12
12
|
export type ScrollViewProviderProps = {
|
|
13
13
|
blur?: boolean
|
|
14
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
|
|
15
|
+
// host by default; a nested provider that is NOT a screen (a picker or form modal - Paper's Portal
|
|
16
16
|
// can re-parent those above the clearing this provider does for its own children) has to say so.
|
|
17
17
|
chrome?: boolean
|
|
18
18
|
children: ReactNode
|
|
@@ -58,13 +58,13 @@ export const ScrollViewProvider = ({ blur, chrome, children, fixed = false, foot
|
|
|
58
58
|
const chromeWritable = useSharedValue(!chromeHosted)
|
|
59
59
|
// Only the registrar ever writes this while hosted. If a provider stops being hosted while it stays
|
|
60
60
|
// mounted (chrome flipping to false, the host unmounting), the registrar goes away with it and
|
|
61
|
-
// would leave the last value behind
|
|
61
|
+
// would leave the last value behind - possibly false, which would silently switch off this
|
|
62
62
|
// provider's own snap-back footer for good.
|
|
63
63
|
useEffect(() => {
|
|
64
64
|
if (!chromeHosted) chromeWritable.value = true
|
|
65
65
|
}, [chromeHosted, chromeWritable])
|
|
66
66
|
const revealChrome = chromeHost?.reveal
|
|
67
|
-
// Unhosted, the hide distance is simply the footer's own height
|
|
67
|
+
// Unhosted, the hide distance is simply the footer's own height - the SAME SharedValue, not a
|
|
68
68
|
// copy, so nothing about existing snap-back behavior changes. Hosted, the whole stack (screen
|
|
69
69
|
// footer + bar + whatever pokes above the bar) has to clear the screen, and the footer's own
|
|
70
70
|
// measured height already includes the overhang as its bottom padding.
|
|
@@ -96,7 +96,7 @@ export const ScrollViewProvider = ({ blur, chrome, children, fixed = false, foot
|
|
|
96
96
|
// react-native-screens' web shim hides an inactive screen via display:none on an
|
|
97
97
|
// ancestor instead of unmounting it, so the header's ResizeObserver-backed onLayout
|
|
98
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
|
|
99
|
+
// revealed. Never let a spurious 0 clobber a real height - only a true remount
|
|
100
100
|
// (headerHeight reset to null) should ever legitimately shrink it back to 0.
|
|
101
101
|
if (h === 0 && headerHeightRef.current) return
|
|
102
102
|
headerHeightRef.current = h
|
|
@@ -105,7 +105,7 @@ export const ScrollViewProvider = ({ blur, chrome, children, fixed = false, foot
|
|
|
105
105
|
// Mirrored into the SharedValue in the same commit as the state, never ahead of it. Writing it
|
|
106
106
|
// straight from onLayout let the header's translate worklet see the new height while the list
|
|
107
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)
|
|
108
|
+
// (useScrollInit does that in its own layout effect on this commit) - so for the whole re-render
|
|
109
109
|
// (~50ms on web) the header computed translateY = -headerHeight and slid off-screen, a visible
|
|
110
110
|
// flash of a header-less screen with the list's first rows at the very top.
|
|
111
111
|
useLayoutEffect(() => {
|
|
@@ -125,14 +125,14 @@ export const ScrollViewProvider = ({ blur, chrome, children, fixed = false, foot
|
|
|
125
125
|
if (!__DEV__ || headerHeight !== null) return
|
|
126
126
|
const timeout = setTimeout(() => {
|
|
127
127
|
// eslint-disable-next-line no-console
|
|
128
|
-
console.warn('[scroll-view] No ScrollViewHeader ever measured inside this ScrollViewProvider
|
|
128
|
+
console.warn('[scroll-view] No ScrollViewHeader ever measured inside this ScrollViewProvider: scroll content stays hidden (opacity: 0) until one does. Render a <ScrollViewHeader> (it can be empty / zero height) inside this provider.')
|
|
129
129
|
}, 3000)
|
|
130
130
|
return () => clearTimeout(timeout)
|
|
131
131
|
}, [headerHeight])
|
|
132
132
|
// Web-only recovery for a real race: a header's ResizeObserver-backed onLayout can simply never
|
|
133
|
-
// 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 -
|
|
134
134
|
// content stuck at opacity: 0 indefinitely, with no error, only escaping via an unrelated resize
|
|
135
|
-
// forcing a fresh layout pass). Native RN doesn't have this race
|
|
135
|
+
// forcing a fresh layout pass). Native RN doesn't have this race - onLayout is reliable there, and
|
|
136
136
|
// a persistently-null headerHeight really does mean "no header was ever rendered," which the
|
|
137
137
|
// warning above should keep flagging. On web, give a real first layout two frames to arrive before
|
|
138
138
|
// assuming there isn't one; setHeaderHeight(0) is exactly what a genuinely header-less screen would
|
|
@@ -154,7 +154,7 @@ export const ScrollViewProvider = ({ blur, chrome, children, fixed = false, foot
|
|
|
154
154
|
// shim (and a footer's own unmount cleanup below, which now passes null instead of 0 for
|
|
155
155
|
// exactly this reason) can report/force a spurious 0 while a real footer is effectively
|
|
156
156
|
// still on screen (e.g. a wizard cycling through several <ScrollViewFooter> instances).
|
|
157
|
-
// Never let that clobber a known height
|
|
157
|
+
// Never let that clobber a known height - only an explicit null (genuinely no footer
|
|
158
158
|
// mounted) may reset it.
|
|
159
159
|
if (h === 0 && footerHeightRef.current) return
|
|
160
160
|
footerHeightRef.current = h
|
package/src/SectionList.tsx
CHANGED
|
@@ -36,13 +36,13 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
36
36
|
|
|
37
37
|
const { chipHidden, chipStyle, containerStyle, contentInset, contentOffset, contentPadding, footerFixed, headerFixed, headerHeight } = useScrollList({ footerFixed: footerFixedProp, headerFixed: headerFixedProp, hideUntilMeasured: true, keyboardAware, pullSearchHeight, style })
|
|
38
38
|
const { listGeneration, onListUnmount } = useContext(ScrollViewContext)
|
|
39
|
-
// -1 never matches a real generation (starts at 0, only increments)
|
|
39
|
+
// -1 never matches a real generation (starts at 0, only increments) - the runOnUI call below
|
|
40
40
|
// corrects it before any scroll event could observe the placeholder.
|
|
41
41
|
const capturedGeneration = useSharedValue(-1)
|
|
42
42
|
// Layout effect (not passive effect): React guarantees all layout-effect cleanups for fibers
|
|
43
43
|
// removed in a commit run before any layout-effect setups for fibers added in that same commit,
|
|
44
44
|
// but gives no such ordering guarantee for passive effects. On a key-forced remount, the outgoing
|
|
45
|
-
// instance's unmount and the incoming instance's mount land in the same commit
|
|
45
|
+
// instance's unmount and the incoming instance's mount land in the same commit - with passive
|
|
46
46
|
// effects, the incoming instance's sync could run before the outgoing instance's bump, permanently
|
|
47
47
|
// capturing a stale generation and silently disabling this instance's onScroll handling forever.
|
|
48
48
|
// Correct ordering alone isn't sufficient, though: the onScroll worklet reads capturedGeneration
|
|
@@ -52,7 +52,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
52
52
|
// own serial queue, so it's guaranteed to run before any onScroll worklet invocation that could
|
|
53
53
|
// only be queued after this component finishes mounting.
|
|
54
54
|
//
|
|
55
|
-
// Web has no such thread split, though
|
|
55
|
+
// Web has no such thread split, though - see ScrollView.tsx's own doc comment on this same
|
|
56
56
|
// pattern for why runOnUI's deferred-to-next-frame behavior there leaves a real gap, and why a
|
|
57
57
|
// synchronous assignment is the correct fix rather than a workaround.
|
|
58
58
|
useLayoutEffect(() => {
|
|
@@ -71,7 +71,7 @@ const SectionListInner = <ItemT, SectionT extends { data: ReadonlyArray<ItemT> }
|
|
|
71
71
|
const { activeIndex, clipStyle, measureHeader, overlayStyle, resetPositions } = useStickyHeaders(headerFixed, usesCustomSticky)
|
|
72
72
|
|
|
73
73
|
// useLayoutEffect (not useEffect) so the reset runs before native layout fires onLayout
|
|
74
|
-
// for the newly mounted section headers
|
|
74
|
+
// for the newly mounted section headers - otherwise the reset would clear correct positions.
|
|
75
75
|
useLayoutEffect(() => {
|
|
76
76
|
if (usesCustomSticky) resetPositions(sections.length)
|
|
77
77
|
}, [sections, usesCustomSticky, resetPositions, headerHeight])
|
|
@@ -11,7 +11,7 @@ type ChromeRegistrarProps = {
|
|
|
11
11
|
|
|
12
12
|
// Rendered by a hosted ScrollViewProvider as a sibling of its children. It is a component rather
|
|
13
13
|
// than a hook call inside the provider because host.useIsActive is itself a hook, and a provider
|
|
14
|
-
// only sometimes has a host
|
|
14
|
+
// only sometimes has a host - mounting this only when hosted keeps the hook order stable.
|
|
15
15
|
//
|
|
16
16
|
// While its screen is active it claims the host; the provider's scroll handlers only write the
|
|
17
17
|
// shared offset (`writable`) while this provider is the claim's owner and nothing has pinned the
|
|
@@ -31,7 +31,7 @@ type DotProps = {
|
|
|
31
31
|
const Dot = ({ activeColor, index, inactiveColor, pageWidth, scrollPosition }: DotProps) => {
|
|
32
32
|
// Explicit array required on web even though nothing here looks platform-specific: without
|
|
33
33
|
// reanimated's Babel plugin active for the web bundle, useAnimatedStyle has no way to
|
|
34
|
-
// auto-extract dependencies and throws outright rather than silently under-tracking
|
|
34
|
+
// auto-extract dependencies and throws outright rather than silently under-tracking - omitting
|
|
35
35
|
// this array isn't a lesser version of the header/footer bug elsewhere in this package, it's a
|
|
36
36
|
// hard crash (confirmed live on the horizontal-pager demo).
|
|
37
37
|
const circleStyle = useAnimatedStyle(() => {
|
|
@@ -4,11 +4,11 @@ import { StyleSheet, type ViewStyle } from 'react-native'
|
|
|
4
4
|
import { Icon, useTheme } from 'react-native-paper'
|
|
5
5
|
import Animated, { type AnimatedStyle } from 'react-native-reanimated'
|
|
6
6
|
|
|
7
|
-
// 'children' omitted alongside the rest
|
|
7
|
+
// 'children' omitted alongside the rest - this chip's own text always comes from `label` (or the
|
|
8
8
|
// isHorizontal-driven 'Start'/'Top' default below), passed as the real JSX children the underlying
|
|
9
9
|
// Chip renders with, which is what actually wins: React always takes the children an element is
|
|
10
10
|
// given between its tags over one merely passed as a "children" prop via spread. A `children` in
|
|
11
|
-
// chipProps would therefore type-check but silently do nothing at runtime
|
|
11
|
+
// chipProps would therefore type-check but silently do nothing at runtime - omitting it here means
|
|
12
12
|
// TypeScript catches that mismatch instead of a caller finding out the hard way.
|
|
13
13
|
export type ChipProps = Omit<AutoPaperChipProps, 'compact' | 'icon' | 'onPress' | 'children'> & {
|
|
14
14
|
label?: ReactNode
|
|
@@ -34,7 +34,7 @@ export const ScrollViewChip = ({ chipProps, isHorizontal, onChipPress, onPress,
|
|
|
34
34
|
// Rendered via `avatar`, not Chip's own `icon` prop: react-native-paper's MD3 Chip hardcodes
|
|
35
35
|
// `icon`'s color to theme.colors.primary and ignores `selectedColor` entirely (its own doc
|
|
36
36
|
// comment: "With theme version 3 selectedColor doesn't apply to the icon. If you want [a]
|
|
37
|
-
// custom color for the icon, render your own Icon component")
|
|
37
|
+
// custom color for the icon, render your own Icon component") - a chip whose own background
|
|
38
38
|
// IS theme.colors.primary (this library's callers all set that; see Hangman's own vivid-chip
|
|
39
39
|
// convention) renders that icon invisible against itself. `avatar` has no such override; it
|
|
40
40
|
// clones whatever element/color it's given. Falls back to the same default Paper itself would
|
|
@@ -33,17 +33,17 @@ export type ScrollHandlerJS = {
|
|
|
33
33
|
// Plain JS-thread scroll handler for a non-Animated scrollable. Reanimated shared values are
|
|
34
34
|
// readable/writable from any thread, so driving them via a regular onScroll callback (rather than
|
|
35
35
|
// a useAnimatedScrollHandler worklet, which requires an Animated-wrapped component to attach to at
|
|
36
|
-
// the native level) works fine here
|
|
36
|
+
// the native level) works fine here - the header/footer animations just pick up the change on
|
|
37
37
|
// their next UI-thread frame. This trades a small amount of latency for a plain scrollable that
|
|
38
38
|
// reliably honors the initial `contentOffset` prop on (re)mount, which Animated.FlatList does not.
|
|
39
39
|
export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThreshold = 100, footerFixed, headerFixed, isHorizontal, jsListGeneration, remountTarget = null, scrollTo }: UseScrollHandlerJSOptions): ScrollHandlerJS {
|
|
40
40
|
const { chromeHosted, chromeWritable, footerOffset, headerHeightShared, headerOffset, pullSearchHeightShared, scrollPosition, snapBackFooterShared, snapBackHeaderShared, stackHeightShared } = useContext(ScrollViewContext)
|
|
41
41
|
const snapUpAccum = useRef(0)
|
|
42
42
|
const fromBottomBounce = useRef(false)
|
|
43
|
-
// On a remount, the very first native position after mount isn't reliable
|
|
43
|
+
// On a remount, the very first native position after mount isn't reliable - Fabric can skip
|
|
44
44
|
// reapplying `contentOffset` when the target is numerically unchanged from before, and native
|
|
45
45
|
// scroll settling can overshoot or undershoot it. Rather than trust either the declarative prop
|
|
46
|
-
// or a single imperative scrollTo call, keep correcting (without hiding anything
|
|
46
|
+
// or a single imperative scrollTo call, keep correcting (without hiding anything - content stays
|
|
47
47
|
// visible throughout) until an onScroll event actually confirms the intended resting position.
|
|
48
48
|
const pendingRemountTarget = useRef(remountTarget)
|
|
49
49
|
const remountAttempts = useRef(0)
|
|
@@ -127,7 +127,7 @@ export function useScrollHandlerJS({ capturedGeneration, chipHidden, chipThresho
|
|
|
127
127
|
|
|
128
128
|
// Hosted: scroll tracks 1:1, so it can stop with the stack half hidden; it settles to fully shown
|
|
129
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
|
|
130
|
+
// the 10pt scroll-up latch has already started animating the offset back to 0 - settling on the
|
|
131
131
|
// in-flight value instead could pick "hidden" and reverse a deliberate reveal.
|
|
132
132
|
const settle = useCallback(
|
|
133
133
|
(revealing: boolean) => {
|
|
@@ -29,7 +29,7 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
29
29
|
|
|
30
30
|
const headerFixed = isHorizontal ? true : (headerFixedProp ?? contextHeaderFixed)
|
|
31
31
|
// Hosted, the footer and the bar share ONE offset that the provider's registrar reveals when Footer
|
|
32
|
-
// Lock turns on
|
|
32
|
+
// Lock turns on - and the registrar only sees the provider-level flag. A per-list override that
|
|
33
33
|
// turned the handlers off on its own would leave the stack stuck wherever it was hidden.
|
|
34
34
|
const footerFixed = isHorizontal ? true : chromeHosted ? contextFooterFixed : (footerFixedProp ?? contextFooterFixed)
|
|
35
35
|
|
|
@@ -37,12 +37,12 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
37
37
|
pullSearchHeightShared.value = usesContentInset ? (pullSearchHeight ?? 0) : 0
|
|
38
38
|
}, [pullSearchHeight, pullSearchHeightShared])
|
|
39
39
|
|
|
40
|
-
// 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 -
|
|
41
41
|
// same visual position content ends up in after the swap below (absoluteFill + paddingTop
|
|
42
42
|
// matching the now-measured headerHeight), so no hiding is needed for this transition on its
|
|
43
43
|
// own. hideUntilMeasured is only for SectionList: its key={headerHeight>0?1:0} forces a real
|
|
44
44
|
// remount of the underlying native list on this exact transition, and that remount is what
|
|
45
|
-
// needs hiding, not this container swap itself
|
|
45
|
+
// needs hiding, not this container swap itself - ScrollView/FlatList/CustomList never remount
|
|
46
46
|
// here, so unconditionally hiding this container regressed a real "flash of blank" on every
|
|
47
47
|
// fresh mount for them (2026-08-03).
|
|
48
48
|
const containerStyle = useMemo(() => (headerHeight === null ? [{ flex: 1, opacity: hideUntilMeasured ? 0 : 1 }, style] : [StyleSheet.absoluteFill, style]), [headerHeight, hideUntilMeasured, style])
|
|
@@ -53,7 +53,7 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
53
53
|
// ScrollViewFooter's own paddingBottom).
|
|
54
54
|
//
|
|
55
55
|
// When footerAboveKeyboard opts the (fixed) footer into floating above the keyboard instead of
|
|
56
|
-
// sitting behind it, footerHeight ALREADY includes the current keyboardHeight
|
|
56
|
+
// sitting behind it, footerHeight ALREADY includes the current keyboardHeight - ScrollViewFooter
|
|
57
57
|
// grows its own container by keyboardHeight to float (see its containerPaddingBottom), rather
|
|
58
58
|
// than translating a fixed-height bar, so onLayout reports the combined height. Adding
|
|
59
59
|
// keyboardHeight again here would double-count it. Otherwise (not floating) ScrollViewFooter pins
|
|
@@ -64,7 +64,7 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
64
64
|
// is actually taller right now.
|
|
65
65
|
//
|
|
66
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)
|
|
67
|
+
// base reserve is only what pokes above the bar (chromeOverhang) - not insets.bottom a second
|
|
68
68
|
// time. Hosted also reserves the screen footer's own height whether or not it is fixed: unlike an
|
|
69
69
|
// unhosted footer (whose position is a function of scroll, so scrolling to the end always hides
|
|
70
70
|
// it), a hosted stack settles wherever the last gesture left it, so a revealed footer at the end
|
|
@@ -82,7 +82,7 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
82
82
|
return { bottom, top: headerHeight ?? 0 }
|
|
83
83
|
}, [chromeHosted, chromeOverhang, footerAboveKeyboard, footerFixed, footerHeight, insets.bottom, tabBarHeight, headerHeight, keyboardAware, keyboardHeight])
|
|
84
84
|
// Outside inset mode the same geometry is applied as content padding instead; contentInset is
|
|
85
|
-
// zeroed (not omitted) so consumers' offset math
|
|
85
|
+
// zeroed (not omitted) so consumers' offset math - scroll-to-top targets, minHeight - stays
|
|
86
86
|
// correct in the raw 0-based coordinate space those platforms actually scroll in.
|
|
87
87
|
const contentInset = usesContentInset ? insetGeometry : ZERO_INSET
|
|
88
88
|
const contentPadding = useMemo(() => (usesContentInset || isHorizontal ? null : { paddingBottom: insetGeometry.bottom, paddingTop: insetGeometry.top }), [insetGeometry, isHorizontal])
|
|
@@ -90,19 +90,19 @@ export function useScrollList({ footerFixed: footerFixedProp, headerFixed: heade
|
|
|
90
90
|
// Use hideY offset only when this component mounted with headerHeight already known (mode switch).
|
|
91
91
|
// On initial load, headerHeight is null at mount so we start at showY and let useScrollInit
|
|
92
92
|
// call scrollTo after the phase transition. On mode switch, headerHeight is already set so we
|
|
93
|
-
// can position the FlatList at hideY atomically via contentOffset
|
|
93
|
+
// can position the FlatList at hideY atomically via contentOffset - no scrollTo needed.
|
|
94
94
|
const [startedWithHeader] = useState(headerHeight !== null)
|
|
95
95
|
const insetContentOffset = useMemo(() => ({ x: 0, y: headerHeight !== null ? -headerHeight + (startedWithHeader && !isHorizontal && pullSearchHeight ? pullSearchHeight : 0) : 0 }), [headerHeight, isHorizontal, pullSearchHeight, startedWithHeader])
|
|
96
96
|
const contentOffset = usesContentInset ? insetContentOffset : ZERO_OFFSET
|
|
97
97
|
|
|
98
98
|
const chipHidden = useSharedValue(1)
|
|
99
99
|
// chipHidden/snapBackHeaderShared/headerOffset/scrollPosition/pullSearchHeightShared/
|
|
100
|
-
// headerHeightShared all need to be listed here too, not just read
|
|
100
|
+
// headerHeightShared all need to be listed here too, not just read - see ScrollViewHeader's
|
|
101
101
|
// translateStyle for why (web-only reanimated reactivity gap: a SharedValue's mutations don't
|
|
102
102
|
// retrigger useAnimatedStyle unless the value itself is in this array). headerHeightShared is
|
|
103
103
|
// used here rather than the plain `headerHeight` context value specifically because the latter
|
|
104
104
|
// changes identity (null -> a real number) shortly after mount, which forces Reanimated to
|
|
105
|
-
// recreate this worklet
|
|
105
|
+
// recreate this worklet - a crash on react-native-worklets 0.10.1, not just a perf concern. See
|
|
106
106
|
// ScrollViewHeader's translateStyle/blurStyle/progressStyle for the full explanation.
|
|
107
107
|
const chipStyle = useAnimatedStyle(() => {
|
|
108
108
|
const pointerEvents = chipHidden.value ? ('none' as const) : ('box-none' as const)
|
|
@@ -26,7 +26,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
|
|
|
26
26
|
contentIndexShared.value = activeIndex
|
|
27
27
|
}, [activeIndex, contentIndexShared])
|
|
28
28
|
|
|
29
|
-
// JS-side source of truth
|
|
29
|
+
// JS-side source of truth - sectionYPositions is a snapshot pushed to the worklet.
|
|
30
30
|
// Reading sectionYPositions.value from JS returns the UI-thread's stale copy, so
|
|
31
31
|
// rapid async measure callbacks would overwrite each other; refs avoid that.
|
|
32
32
|
const positionsCache = useRef<number[]>([])
|
|
@@ -65,7 +65,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
|
|
|
65
65
|
// Same web-only reanimated gap as useAnimatedStyle elsewhere in this file: this "prepare"
|
|
66
66
|
// function's own SharedValue reads (sectionYPositions, headerHeightShared, scrollPosition,
|
|
67
67
|
// snapBackHeaderShared, headerOffset, pullSearchHeightShared) need to be listed explicitly too
|
|
68
|
-
//
|
|
68
|
+
// - confirmed empirically that without them, useAnimatedReaction re-evaluates once on mount and
|
|
69
69
|
// then never again on web, permanently freezing activeIndex/the active sticky section.
|
|
70
70
|
[enabled, headerFixed, insetsTop, sectionYPositions, headerHeightShared, scrollPosition, snapBackHeaderShared, headerOffset, pullSearchHeightShared]
|
|
71
71
|
)
|
|
@@ -74,7 +74,7 @@ export function useStickyHeaders(headerFixed: boolean, enabled: boolean): Sticky
|
|
|
74
74
|
// header as it slides above the sticky line during a push transition.
|
|
75
75
|
// Every SharedValue read below (headerHeightShared, scrollPosition, snapBackHeaderShared,
|
|
76
76
|
// headerOffset, pullSearchHeightShared, and for overlayStyle also contentIndexShared/
|
|
77
|
-
// sectionYPositions/sectionHeights) has to be listed in the array too
|
|
77
|
+
// sectionYPositions/sectionHeights) has to be listed in the array too - see
|
|
78
78
|
// ScrollViewHeader's translateStyle for why: on web, reanimated doesn't treat mutations to a
|
|
79
79
|
// SharedValue as a reactive trigger for useAnimatedStyle unless it's in this array.
|
|
80
80
|
const clipStyle = useAnimatedStyle(() => {
|
|
@@ -2,7 +2,7 @@ import { createSettingsSlice } from '@rific/core'
|
|
|
2
2
|
|
|
3
3
|
import { defaultScrollViewSettings, type ScrollViewSettings } from '../ScrollViewSettingsContext'
|
|
4
4
|
|
|
5
|
-
// scrollView has no per-field setters, just a full-replace initialize
|
|
5
|
+
// scrollView has no per-field setters, just a full-replace initialize - matches the previous
|
|
6
6
|
// hand-rolled slice exactly (action type 'scrollView/initialize', full-state-replace behavior).
|
|
7
7
|
const scrollViewSlice = createSettingsSlice<ScrollViewSettings>('scrollView', {
|
|
8
8
|
initialState: defaultScrollViewSettings,
|
package/src/useKeyboardInset.ts
CHANGED
|
@@ -23,6 +23,6 @@ const useKeyboardInsetNative = () => {
|
|
|
23
23
|
|
|
24
24
|
const useKeyboardInsetWeb = () => 0
|
|
25
25
|
|
|
26
|
-
// react-native-keyboard-controller has no web implementation
|
|
26
|
+
// react-native-keyboard-controller has no web implementation - its handler hook must never be
|
|
27
27
|
// called there. Platform is constant for the life of the bundle, so this select is hook-safe.
|
|
28
28
|
export const useKeyboardInset = Platform.OS === 'web' ? useKeyboardInsetWeb : useKeyboardInsetNative
|
package/src/useScrollInit.ts
CHANGED
|
@@ -12,7 +12,7 @@ const REMOUNT_PRIME_FRAMES = 40
|
|
|
12
12
|
const SNAP_VERIFY_TOLERANCE = 1
|
|
13
13
|
const SNAP_VERIFY_MAX_ATTEMPTS = 5
|
|
14
14
|
// Long enough to comfortably outlast RN's default animated scrollTo duration (iOS's native
|
|
15
|
-
// setContentOffset:animated: for a typical pull-search distance settles well within this)
|
|
15
|
+
// setContentOffset:animated: for a typical pull-search distance settles well within this) - see
|
|
16
16
|
// the comment on scheduleSnapVerification for why a native onMomentumScrollEnd event can't be used
|
|
17
17
|
// as this signal instead.
|
|
18
18
|
const SNAP_VERIFY_INITIAL_DELAY_MS = 400
|
|
@@ -51,7 +51,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
51
51
|
// switch). Only enter 'measuring' on the very first mount when headerHeight hasn't been set yet.
|
|
52
52
|
const [phase, setPhase] = useState<'measuring' | 'ready'>(hasPullSearch && !headerHeight ? 'measuring' : 'ready')
|
|
53
53
|
const [refreshing, setRefreshing] = useState(false)
|
|
54
|
-
// A plain-ref mirror of `refreshing`, read from inside the deferred timeout below
|
|
54
|
+
// A plain-ref mirror of `refreshing`, read from inside the deferred timeout below - that
|
|
55
55
|
// callback can fire well after the render that scheduled it, so it needs the *current* value,
|
|
56
56
|
// not whatever `refreshing` closed over at schedule time.
|
|
57
57
|
const refreshingRef = useRef(refreshing)
|
|
@@ -60,7 +60,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
60
60
|
}, [refreshing])
|
|
61
61
|
const dragStartY = useRef<number | null>(null)
|
|
62
62
|
// Which pull-search resting position (showY or hideY) a drag-release most recently committed
|
|
63
|
-
// to, so a late fallback in handleMomentumScrollEnd knows not to fight it
|
|
63
|
+
// to, so a late fallback in handleMomentumScrollEnd knows not to fight it - see
|
|
64
64
|
// scheduleSnapVerification below for how this actually gets enforced.
|
|
65
65
|
const pendingSnapTarget = useRef<number | null>(null)
|
|
66
66
|
const snapVerifyTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
@@ -69,9 +69,9 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
69
69
|
// Verifies a pull-search drag-release (handleScrollEndDrag) actually landed on `target`, and
|
|
70
70
|
// keeps correcting (non-animated, so it lands immediately) until it does. This is deliberately
|
|
71
71
|
// time-based rather than triggered by onMomentumScrollEnd: a slow/controlled release with near-
|
|
72
|
-
// zero velocity fires onMomentumScrollEnd within a couple of milliseconds of the drag ending
|
|
72
|
+
// zero velocity fires onMomentumScrollEnd within a couple of milliseconds of the drag ending -
|
|
73
73
|
// long before the *animated* scrollTo call issued moments earlier has had any chance to actually
|
|
74
|
-
// move
|
|
74
|
+
// move - so reacting to that event by immediately snapping (non-animated) to the target replaced
|
|
75
75
|
// the intended smooth spring-back animation with a jarring instant jump on every normal pull,
|
|
76
76
|
// not just the pathological case this was built for. Waiting out a fixed delay long enough for a
|
|
77
77
|
// normal animated scrollTo to have finished, then checking the *actual* position via
|
|
@@ -111,7 +111,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
111
111
|
[]
|
|
112
112
|
)
|
|
113
113
|
|
|
114
|
-
// Remount sync: this instance mounted with headerHeight already known
|
|
114
|
+
// Remount sync: this instance mounted with headerHeight already known - a key-forced remount
|
|
115
115
|
// (e.g. numColumns toggling between a list and a grid), not a true first mount. The native
|
|
116
116
|
// scroll view's actual position right after such a remount isn't reliable (Fabric's ScrollView
|
|
117
117
|
// recycling and Reanimated's Animated.FlatList can both leave it at a stale or zeroed offset
|
|
@@ -159,7 +159,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
159
159
|
}, REMOUNT_SYNC_FALLBACK_MS)
|
|
160
160
|
return () => clearTimeout(fallback)
|
|
161
161
|
// Intentionally runs once per mount only, using whatever remountTarget was captured at
|
|
162
|
-
// construction
|
|
162
|
+
// construction - not a dep that should retrigger this effect.
|
|
163
163
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
164
164
|
}, [])
|
|
165
165
|
|
|
@@ -170,10 +170,10 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
170
170
|
setPhase('ready')
|
|
171
171
|
}, [phase, headerHeight, pullSearchHeight, scrollPosition])
|
|
172
172
|
|
|
173
|
-
// Effect A applies ONLY on an actual remount (remountTarget !== null
|
|
173
|
+
// Effect A applies ONLY on an actual remount (remountTarget !== null - this instance mounted with
|
|
174
174
|
// headerHeight already known, e.g. a key-forced remount elsewhere in the tree). It syncs
|
|
175
175
|
// scrollPosition synchronously before first paint and issues a short bounded chain of imperative
|
|
176
|
-
// scrollTo calls, alternating between two offsets far enough apart (30pt
|
|
176
|
+
// scrollTo calls, alternating between two offsets far enough apart (30pt - comfortably more than
|
|
177
177
|
// any plausible rounding/measurement noise) to guarantee at least one call can't be mistaken for a
|
|
178
178
|
// no-op. The declarative `contentOffset` prop alone isn't enough on a remount: when the target is
|
|
179
179
|
// numerically identical to what it was before (headerHeight/pullSearchHeight don't change across a
|
|
@@ -181,7 +181,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
181
181
|
// be left at whatever position it actually drifted to. Worse, iOS's imperative scrollToOffset
|
|
182
182
|
// short-circuits with a CGPointEqualToPoint check against the native view's *actual* current
|
|
183
183
|
// contentOffset before it will fire the native scroll-finished callback that RN turns into an
|
|
184
|
-
// onScroll event
|
|
184
|
+
// onScroll event - so a single differing call can still go unnoticed if it happens to race content
|
|
185
185
|
// that's still growing. Alternating across the *entire* correction window, not just the first
|
|
186
186
|
// couple of frames, forces a real scroll-finished callback so drift becomes observable and the
|
|
187
187
|
// onScroll-driven retry loop in useScrollHandlerJS can correct it.
|
|
@@ -189,7 +189,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
189
189
|
// On a true first mount (remountTarget === null) this effect does nothing beyond a single
|
|
190
190
|
// insurance call against the destination view silently ignoring the very first scroll command
|
|
191
191
|
// because it isn't fully installed in the window hierarchy yet (e.g. mid native-stack push
|
|
192
|
-
// transition)
|
|
192
|
+
// transition) - a fresh, non-recycled native view reliably honors the declarative contentOffset
|
|
193
193
|
// prop otherwise. Repeating or oscillating that call here fights Effect B below, whose entire job
|
|
194
194
|
// on a true first mount with pull-search is to push the position on from here to hideY: if this
|
|
195
195
|
// effect keeps re-asserting its own target for tens of frames, it can win the race and leave the
|
|
@@ -277,7 +277,7 @@ export function useScrollInit({ listHeaderComponent, onMomentumScrollEnd: extern
|
|
|
277
277
|
if (pullSearchHeight && headerHeight && !refreshing && pendingSnapTarget.current === null) {
|
|
278
278
|
// No drag committed to a target above (e.g. a fast fling whose whole reveal/overscroll
|
|
279
279
|
// motion happened during momentum, never during the drag itself, so handleScrollEndDrag
|
|
280
|
-
// never saw a y near the pull-search zone)
|
|
280
|
+
// never saw a y near the pull-search zone) - fall back to auto-closing if we've ended up
|
|
281
281
|
// revealed without ever meaning to.
|
|
282
282
|
const y = e.nativeEvent.contentOffset.y
|
|
283
283
|
const hideY = -headerHeight + pullSearchHeight
|