@delacour/react-native-ui 0.3.0-alpha.20261017084646 → 0.3.0-alpha.20261017122152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -1
- package/src/components/screen/AGENTS.md +11 -0
- package/src/components/screen/screen-root.tsx +8 -3
- package/src/components/screen/screen-scroll-source.tsx +38 -0
- package/src/hooks/use-scroll-sections.ts +137 -0
- package/src/hooks/use-scroll-source.tsx +173 -0
- package/src/lib/scroll-edges.test.ts +77 -0
- package/src/lib/scroll-edges.ts +81 -0
- package/src/lib/scroll-maths.test.ts +149 -0
- package/src/lib/scroll-maths.ts +114 -0
- package/src/lib/scroll-sections.test.ts +64 -0
- package/src/lib/scroll-sections.ts +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.3.0-alpha.
|
|
3
|
+
"version": "0.3.0-alpha.20261017122152",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -93,6 +93,8 @@
|
|
|
93
93
|
"./hooks/use-controllable-state": "./src/hooks/use-controllable-state.ts",
|
|
94
94
|
"./hooks/use-keyboard-state-sync": "./src/hooks/use-keyboard-state-sync.tsx",
|
|
95
95
|
"./hooks/use-navigation-theme": "./src/hooks/use-navigation-theme.ts",
|
|
96
|
+
"./hooks/use-scroll-sections": "./src/hooks/use-scroll-sections.ts",
|
|
97
|
+
"./hooks/use-scroll-source": "./src/hooks/use-scroll-source.tsx",
|
|
96
98
|
"./hooks/use-theme-color": "./src/hooks/use-theme-color.ts",
|
|
97
99
|
"./lib/calm-motion": "./src/lib/calm-motion.ts",
|
|
98
100
|
"./lib/cn": "./src/lib/cn.ts",
|
|
@@ -102,6 +104,9 @@
|
|
|
102
104
|
"./lib/merge-props": "./src/lib/merge-props.ts",
|
|
103
105
|
"./lib/navigation-theme": "./src/lib/navigation-theme.ts",
|
|
104
106
|
"./lib/paging": "./src/lib/paging.ts",
|
|
107
|
+
"./lib/scroll-edges": "./src/lib/scroll-edges.ts",
|
|
108
|
+
"./lib/scroll-maths": "./src/lib/scroll-maths.ts",
|
|
109
|
+
"./lib/scroll-sections": "./src/lib/scroll-sections.ts",
|
|
105
110
|
"./lib/slot": "./src/lib/slot.tsx",
|
|
106
111
|
"./lib/tv": "./src/lib/tv.ts",
|
|
107
112
|
"./icons/central": "./src/icons/central.ts"
|
|
@@ -14,6 +14,7 @@ whole-screen states.
|
|
|
14
14
|
| `index.ts` | → `@registry/ui/screen` |
|
|
15
15
|
| `screen.tsx` | The `Object.assign` compound surface |
|
|
16
16
|
| `screen-root.tsx` | The root box and its provider — see below for why it is split out |
|
|
17
|
+
| `screen-scroll-source.tsx` | The bridge publishing the screen's scroll values as a `ScrollSource` — internal |
|
|
17
18
|
| `screen-navbar.tsx` | `Screen.Navbar`, plus its own nested surface |
|
|
18
19
|
| `screen-navbar-title.tsx` | `Screen.Navbar.Title` |
|
|
19
20
|
| `screen-navbar-subtitle.tsx` | `Screen.Navbar.Subtitle` |
|
|
@@ -265,6 +266,16 @@ the first row is still leaving.
|
|
|
265
266
|
- **A nested `<Screen>` passes the outer context through** rather than shadowing
|
|
266
267
|
it, so `Screen.Loading` returned from inside a screen does not start a second,
|
|
267
268
|
unread set of measurements.
|
|
269
|
+
- **A screen publishes a `ScrollSource`.** `ScreenRoot` mounts
|
|
270
|
+
`ScreenScrollSourceBridge` inside its provider, which hands the same
|
|
271
|
+
`scrollY` / `contentHeight` / `layoutHeight` to a `ScrollSourceProvider`
|
|
272
|
+
(`hooks/use-scroll-source.tsx`) as `offset` / `contentSize` / `layoutSize`,
|
|
273
|
+
vertical, with no ref. That is what lets the scroll family — `ScrollFade`,
|
|
274
|
+
`ScrollBlur`, `ScrollCanvas`, `ScrollHeader`; see [The scroll family's shared
|
|
275
|
+
code](../../../AGENTS.md#the-scroll-familys-shared-code) — work inside a
|
|
276
|
+
`<Screen>` with no wiring. It adds no handler and no new shared value — the
|
|
277
|
+
scrollables write exactly what they did — and the import points one way,
|
|
278
|
+
screen to hook, so installing the hook never pulls `Screen` in after it.
|
|
268
279
|
|
|
269
280
|
## Dependencies
|
|
270
281
|
|
|
@@ -2,6 +2,7 @@ import type { ReactElement } from "react";
|
|
|
2
2
|
import { View, type ViewProps } from "react-native";
|
|
3
3
|
import { ScreenProvider, useScreenContext } from "./screen.context";
|
|
4
4
|
import { screenVariants } from "./screen.variants";
|
|
5
|
+
import { ScreenScrollSourceBridge } from "./screen-scroll-source";
|
|
5
6
|
|
|
6
7
|
export type ScreenRootProps = ViewProps & {
|
|
7
8
|
/**
|
|
@@ -28,11 +29,15 @@ export function ScreenRoot({ debug = false, className, children, ...props }: Scr
|
|
|
28
29
|
// second, unread set of measurements.
|
|
29
30
|
const parentContext = useScreenContext();
|
|
30
31
|
|
|
32
|
+
// The bridge republishes the scroll values as a `ScrollSource`, which is what
|
|
33
|
+
// lets the scroll family work inside a screen with no wiring.
|
|
31
34
|
return (
|
|
32
35
|
<ScreenProvider debug={debug} parentContext={parentContext}>
|
|
33
|
-
<
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
+
<ScreenScrollSourceBridge>
|
|
37
|
+
<View className={screenVariants().root({ className })} {...props}>
|
|
38
|
+
{children}
|
|
39
|
+
</View>
|
|
40
|
+
</ScreenScrollSourceBridge>
|
|
36
41
|
</ScreenProvider>
|
|
37
42
|
);
|
|
38
43
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, useMemo } from "react";
|
|
2
|
+
import { type ScrollSource, ScrollSourceProvider } from "../../hooks/use-scroll-source";
|
|
3
|
+
import { useScreen } from "./screen.context";
|
|
4
|
+
|
|
5
|
+
export type ScreenScrollSourceBridgeProps = { children: ReactNode };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Publishes the screen's scroll values as a `ScrollSource`, so every
|
|
9
|
+
* scroll-linked effect — `ScrollFade` and the rest of its family — works inside
|
|
10
|
+
* a `<Screen>` with no wiring.
|
|
11
|
+
*
|
|
12
|
+
* Built from the shared values the screen already holds rather than new ones:
|
|
13
|
+
* `scrollY` is the offset, `contentHeight` and `layoutHeight` the sizes. Every
|
|
14
|
+
* Screen scrollable writes those from its own scroll handler, so nothing here
|
|
15
|
+
* intercepts a scroll event, and `Screen.ScrollShadow` keeps reading the same
|
|
16
|
+
* values it always did. A Screen body only scrolls vertically, and its
|
|
17
|
+
* scrollable is whichever one is mounted, so there is no ref to hand on.
|
|
18
|
+
*
|
|
19
|
+
* The import runs one way — screen to hook — so installing `useScrollSource`
|
|
20
|
+
* never drags `Screen` and its peers in after it.
|
|
21
|
+
*/
|
|
22
|
+
export function ScreenScrollSourceBridge({ children }: ScreenScrollSourceBridgeProps): ReactElement {
|
|
23
|
+
const { scrollY, contentHeight, layoutHeight } = useScreen();
|
|
24
|
+
|
|
25
|
+
const source = useMemo<ScrollSource>(
|
|
26
|
+
() => ({
|
|
27
|
+
offset: scrollY,
|
|
28
|
+
contentSize: contentHeight,
|
|
29
|
+
layoutSize: layoutHeight,
|
|
30
|
+
orientation: "vertical",
|
|
31
|
+
ref: null,
|
|
32
|
+
}),
|
|
33
|
+
[scrollY, contentHeight, layoutHeight]
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return <ScrollSourceProvider source={source}>{children}</ScrollSourceProvider>;
|
|
37
|
+
}
|
|
38
|
+
ScreenScrollSourceBridge.displayName = "DelacourUI.Screen.ScrollSourceBridge";
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
3
|
+
import type Animated from "react-native-reanimated";
|
|
4
|
+
import {
|
|
5
|
+
type AnimatedRef,
|
|
6
|
+
type SharedValue,
|
|
7
|
+
scrollTo as scrollToWorklet,
|
|
8
|
+
useAnimatedRef,
|
|
9
|
+
useAnimatedScrollHandler,
|
|
10
|
+
useSharedValue,
|
|
11
|
+
} from "react-native-reanimated";
|
|
12
|
+
import { scheduleOnRN, scheduleOnUI } from "react-native-worklets";
|
|
13
|
+
import {
|
|
14
|
+
resolveActiveSection,
|
|
15
|
+
resolveScrollProgress,
|
|
16
|
+
resolveSectionScrollTarget,
|
|
17
|
+
type SectionOffset,
|
|
18
|
+
} from "../lib/scroll-sections";
|
|
19
|
+
|
|
20
|
+
export type UseScrollSectionsOptions = {
|
|
21
|
+
/** Points below the viewport's top where a section counts as entered. Default 0. */
|
|
22
|
+
readingLine?: number;
|
|
23
|
+
/** Whether `scrollTo` animates. Default true. */
|
|
24
|
+
isAnimated?: boolean;
|
|
25
|
+
/** Fires when the reader crosses into a different section. */
|
|
26
|
+
onActiveChange?: (id: string) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type UseScrollSectionsResult = {
|
|
30
|
+
/** Attach to the `Animated.ScrollView` whose sections are tracked. */
|
|
31
|
+
scrollRef: AnimatedRef<Animated.ScrollView>;
|
|
32
|
+
/** Pass as that scroll view's `onScroll`, with `scrollEventThrottle={16}`. */
|
|
33
|
+
scrollHandler: ReturnType<typeof useAnimatedScrollHandler>;
|
|
34
|
+
/** `onLayout` for a section's box — a direct child of the scroll content. Stable per id. */
|
|
35
|
+
register: (id: string) => (event: LayoutChangeEvent) => void;
|
|
36
|
+
/** The section being read. React state: changes only when the section does. */
|
|
37
|
+
activeId: string | undefined;
|
|
38
|
+
/** 0–1, how far down the content the reader is. Updated on the UI thread. */
|
|
39
|
+
progress: SharedValue<number>;
|
|
40
|
+
/** The raw scroll offset. */
|
|
41
|
+
scrollY: SharedValue<number>;
|
|
42
|
+
/** Scrolls a section's top onto the reading line and makes it active at once. */
|
|
43
|
+
scrollTo: (id: string) => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tracks which section of a scroll view the reader is in, and how far down they are, on the
|
|
48
|
+
* UI thread. `SectionProgress` and `SectionRail` read it; any custom table of contents can too.
|
|
49
|
+
*
|
|
50
|
+
* The scroll handler resolves the active section every frame but crosses to React only when it
|
|
51
|
+
* changes, so a scroll costs no renders. A `scrollTo` pins the target as active until the scroll
|
|
52
|
+
* arrives there (or the reader drags), so a jump from the first section to the last does not
|
|
53
|
+
* flick the label through every section in between.
|
|
54
|
+
*
|
|
55
|
+
* Offsets come from each section's `onLayout`, which reports `y` relative to the parent — so a
|
|
56
|
+
* registered section must be a direct child of the scroll content.
|
|
57
|
+
*/
|
|
58
|
+
export function useScrollSections({
|
|
59
|
+
readingLine = 0,
|
|
60
|
+
isAnimated = true,
|
|
61
|
+
onActiveChange,
|
|
62
|
+
}: UseScrollSectionsOptions = {}): UseScrollSectionsResult {
|
|
63
|
+
const scrollRef = useAnimatedRef<Animated.ScrollView>();
|
|
64
|
+
const sections = useSharedValue<SectionOffset[]>([]);
|
|
65
|
+
const scrollY = useSharedValue(0);
|
|
66
|
+
const progress = useSharedValue(0);
|
|
67
|
+
const lastActive = useSharedValue<string | undefined>(undefined);
|
|
68
|
+
const pinned = useSharedValue<string | undefined>(undefined);
|
|
69
|
+
const [activeId, setActiveId] = useState<string | undefined>(undefined);
|
|
70
|
+
|
|
71
|
+
const report = useCallback(
|
|
72
|
+
(id: string) => {
|
|
73
|
+
setActiveId(id);
|
|
74
|
+
onActiveChange?.(id);
|
|
75
|
+
},
|
|
76
|
+
[onActiveChange]
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const scrollHandler = useAnimatedScrollHandler({
|
|
80
|
+
onBeginDrag: () => {
|
|
81
|
+
pinned.value = undefined;
|
|
82
|
+
},
|
|
83
|
+
onScroll: (event) => {
|
|
84
|
+
const y = event.contentOffset.y;
|
|
85
|
+
scrollY.value = y;
|
|
86
|
+
progress.value = resolveScrollProgress(y, event.contentSize.height, event.layoutMeasurement.height);
|
|
87
|
+
const next = resolveActiveSection(sections.value, y, readingLine);
|
|
88
|
+
if (pinned.value !== undefined) {
|
|
89
|
+
if (next !== pinned.value) return;
|
|
90
|
+
pinned.value = undefined;
|
|
91
|
+
}
|
|
92
|
+
if (next !== undefined && next !== lastActive.value) {
|
|
93
|
+
lastActive.value = next;
|
|
94
|
+
scheduleOnRN(report, next);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const handlers = useRef(new Map<string, (event: LayoutChangeEvent) => void>());
|
|
100
|
+
const register = useCallback(
|
|
101
|
+
(id: string) => {
|
|
102
|
+
const cached = handlers.current.get(id);
|
|
103
|
+
if (cached) return cached;
|
|
104
|
+
const handler = (event: LayoutChangeEvent) => {
|
|
105
|
+
const y = event.nativeEvent.layout.y;
|
|
106
|
+
sections.modify((list) => {
|
|
107
|
+
"worklet";
|
|
108
|
+
const index = list.findIndex((section) => section.id === id);
|
|
109
|
+
if (index === -1) list.push({ id, y });
|
|
110
|
+
else list[index] = { id, y };
|
|
111
|
+
return list;
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
handlers.current.set(id, handler);
|
|
115
|
+
return handler;
|
|
116
|
+
},
|
|
117
|
+
[sections]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const scrollTo = useCallback(
|
|
121
|
+
(id: string) => {
|
|
122
|
+
const section = sections.value.find((candidate) => candidate.id === id);
|
|
123
|
+
if (section === undefined) return;
|
|
124
|
+
const target = resolveSectionScrollTarget(section.y, readingLine);
|
|
125
|
+
pinned.value = id;
|
|
126
|
+
lastActive.value = id;
|
|
127
|
+
report(id);
|
|
128
|
+
scheduleOnUI(() => {
|
|
129
|
+
"worklet";
|
|
130
|
+
scrollToWorklet(scrollRef, 0, target, isAnimated);
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
[isAnimated, lastActive, pinned, readingLine, report, scrollRef, sections]
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return { scrollRef, scrollHandler, register, activeId, progress, scrollY, scrollTo };
|
|
137
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use, useCallback, useMemo } from "react";
|
|
2
|
+
import type { LayoutChangeEvent } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
type AnimatedRef,
|
|
5
|
+
type SharedValue,
|
|
6
|
+
useAnimatedRef,
|
|
7
|
+
useAnimatedScrollHandler,
|
|
8
|
+
useSharedValue,
|
|
9
|
+
} from "react-native-reanimated";
|
|
10
|
+
import { readScrollEvent, type ScrollEventFields, type ScrollOrientation } from "../lib/scroll-maths";
|
|
11
|
+
|
|
12
|
+
export type { ScrollEventFields, ScrollOrientation } from "../lib/scroll-maths";
|
|
13
|
+
|
|
14
|
+
/** Everything a scroll-linked effect reads, all of it on the UI thread. */
|
|
15
|
+
export type ScrollSource = {
|
|
16
|
+
/** Scroll offset along `orientation`; negative while rubber-banding at the start. */
|
|
17
|
+
offset: SharedValue<number>;
|
|
18
|
+
/** Content extent along `orientation`; 0 until measured. */
|
|
19
|
+
contentSize: SharedValue<number>;
|
|
20
|
+
/** Viewport extent along `orientation`; 0 until measured. */
|
|
21
|
+
layoutSize: SharedValue<number>;
|
|
22
|
+
orientation: ScrollOrientation;
|
|
23
|
+
/**
|
|
24
|
+
* Present when the source came from `useScrollSource`; null from a Screen.
|
|
25
|
+
* Lets an effect `measureLayout` against the scroller.
|
|
26
|
+
*/
|
|
27
|
+
ref: AnimatedRef | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type UseScrollSourceOptions = {
|
|
31
|
+
/** @default "vertical" */
|
|
32
|
+
orientation?: ScrollOrientation;
|
|
33
|
+
/** Worklets composed after the source has written its values. */
|
|
34
|
+
onBeginDrag?: (event: ScrollEventFields) => void;
|
|
35
|
+
onEndDrag?: (event: ScrollEventFields) => void;
|
|
36
|
+
onMomentumBegin?: (event: ScrollEventFields) => void;
|
|
37
|
+
onMomentumEnd?: (event: ScrollEventFields) => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Props to spread onto an `Animated.ScrollView` / `Animated.FlatList` / `Animated.SectionList`. */
|
|
41
|
+
export type ScrollSourceProps = {
|
|
42
|
+
ref: AnimatedRef;
|
|
43
|
+
onScroll: ReturnType<typeof useAnimatedScrollHandler>;
|
|
44
|
+
onLayout: (event: LayoutChangeEvent) => void;
|
|
45
|
+
onContentSizeChange: (width: number, height: number) => void;
|
|
46
|
+
scrollEventThrottle: 16;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type UseScrollSourceResult = { source: ScrollSource; scrollProps: ScrollSourceProps };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A scroll source for a scrollable that is not a `Screen` body — a horizontal
|
|
53
|
+
* card row, a list inside a card, a sheet body — and the props that feed it.
|
|
54
|
+
*
|
|
55
|
+
* **Spread `scrollProps` onto the scrollable; nothing clones it for you.** A
|
|
56
|
+
* wrapper that cloned its child to attach `onScroll` would collide with
|
|
57
|
+
* Reanimated's handler composition and with every handler a scrollable already
|
|
58
|
+
* owns. Spread them last, or compose your own `onLayout` with them by hand.
|
|
59
|
+
*
|
|
60
|
+
* **Sizes are measured on layout, not only on scroll.** `onLayout` and
|
|
61
|
+
* `onContentSizeChange` write `layoutSize` and `contentSize` as soon as the
|
|
62
|
+
* scrollable lays out, so an effect over a list too short to scroll is right
|
|
63
|
+
* before anyone touches it — a `Screen`'s source cannot do that, and an edge
|
|
64
|
+
* fade over a short Screen body waits for the first scroll event.
|
|
65
|
+
*
|
|
66
|
+
* `orientation` is fixed here, by whoever creates the source, so no effect can
|
|
67
|
+
* disagree with the scroller about which axis it moves on.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* const { source, scrollProps } = useScrollSource({ orientation: "horizontal" });
|
|
71
|
+
*
|
|
72
|
+
* <ScrollFade scrollSource={source}>
|
|
73
|
+
* <Animated.ScrollView horizontal {...scrollProps}>{cards}</Animated.ScrollView>
|
|
74
|
+
* </ScrollFade>
|
|
75
|
+
*/
|
|
76
|
+
export function useScrollSource(options: UseScrollSourceOptions = {}): UseScrollSourceResult {
|
|
77
|
+
const { orientation = "vertical", onBeginDrag, onEndDrag, onMomentumBegin, onMomentumEnd } = options;
|
|
78
|
+
const ref = useAnimatedRef();
|
|
79
|
+
const offset = useSharedValue(0);
|
|
80
|
+
const contentSize = useSharedValue(0);
|
|
81
|
+
const layoutSize = useSharedValue(0);
|
|
82
|
+
|
|
83
|
+
const onScroll = useAnimatedScrollHandler(
|
|
84
|
+
{
|
|
85
|
+
onScroll: (event) => {
|
|
86
|
+
const reading = readScrollEvent(event, orientation);
|
|
87
|
+
offset.value = reading.offset;
|
|
88
|
+
if (reading.contentSize !== contentSize.value) contentSize.value = reading.contentSize;
|
|
89
|
+
if (reading.layoutSize !== layoutSize.value) layoutSize.value = reading.layoutSize;
|
|
90
|
+
},
|
|
91
|
+
onBeginDrag: (event) => {
|
|
92
|
+
offset.value = readScrollEvent(event, orientation).offset;
|
|
93
|
+
onBeginDrag?.(event);
|
|
94
|
+
},
|
|
95
|
+
onEndDrag: (event) => {
|
|
96
|
+
offset.value = readScrollEvent(event, orientation).offset;
|
|
97
|
+
onEndDrag?.(event);
|
|
98
|
+
},
|
|
99
|
+
onMomentumBegin: (event) => {
|
|
100
|
+
offset.value = readScrollEvent(event, orientation).offset;
|
|
101
|
+
onMomentumBegin?.(event);
|
|
102
|
+
},
|
|
103
|
+
onMomentumEnd: (event) => {
|
|
104
|
+
offset.value = readScrollEvent(event, orientation).offset;
|
|
105
|
+
onMomentumEnd?.(event);
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
[orientation, onBeginDrag, onEndDrag, onMomentumBegin, onMomentumEnd]
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const onLayout = useCallback(
|
|
112
|
+
(event: LayoutChangeEvent) => {
|
|
113
|
+
const { width, height } = event.nativeEvent.layout;
|
|
114
|
+
layoutSize.value = orientation === "horizontal" ? width : height;
|
|
115
|
+
},
|
|
116
|
+
[orientation, layoutSize]
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const onContentSizeChange = useCallback(
|
|
120
|
+
(width: number, height: number) => {
|
|
121
|
+
contentSize.value = orientation === "horizontal" ? width : height;
|
|
122
|
+
},
|
|
123
|
+
[orientation, contentSize]
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
return useMemo(
|
|
127
|
+
() => ({
|
|
128
|
+
source: { offset, contentSize, layoutSize, orientation, ref },
|
|
129
|
+
scrollProps: { ref, onScroll, onLayout, onContentSizeChange, scrollEventThrottle: 16 },
|
|
130
|
+
}),
|
|
131
|
+
[offset, contentSize, layoutSize, orientation, ref, onScroll, onLayout, onContentSizeChange]
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ScrollSourceContext = createContext<ScrollSource | null>(null);
|
|
136
|
+
|
|
137
|
+
export type ScrollSourceProviderProps = {
|
|
138
|
+
source: ScrollSource;
|
|
139
|
+
children: ReactNode;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Publishes a scroll source to every scroll-linked effect beneath it, so none
|
|
144
|
+
* of them needs a `scrollSource` prop. A `Screen` mounts one around its body
|
|
145
|
+
* already; mount one by hand to share a single scrollable's source with several
|
|
146
|
+
* effects, or with an effect a few components away from the list.
|
|
147
|
+
*/
|
|
148
|
+
export function ScrollSourceProvider({ source, children }: ScrollSourceProviderProps): ReactElement {
|
|
149
|
+
return <ScrollSourceContext value={source}>{children}</ScrollSourceContext>;
|
|
150
|
+
}
|
|
151
|
+
ScrollSourceProvider.displayName = "DelacourUI.ScrollSourceProvider";
|
|
152
|
+
|
|
153
|
+
/** The nearest provider's source, or null. For an effect that can work without one. */
|
|
154
|
+
export function useScrollSourceContext(): ScrollSource | null {
|
|
155
|
+
return use(ScrollSourceContext);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The source a scroll-linked effect follows: the one it was given, else the
|
|
160
|
+
* nearest `ScrollSourceProvider` (a `Screen` counts), else a thrown error
|
|
161
|
+
* naming the component — an effect with nothing to follow would silently draw
|
|
162
|
+
* its resting state forever.
|
|
163
|
+
*/
|
|
164
|
+
export function useResolvedScrollSource(component: string, explicit?: ScrollSource): ScrollSource {
|
|
165
|
+
const ambient = useScrollSourceContext();
|
|
166
|
+
const source = explicit ?? ambient;
|
|
167
|
+
if (!source) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`\`${component}\` must be given a \`scrollSource\` or rendered inside a Screen / ScrollSourceProvider.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return source;
|
|
173
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveFadeBand, resolveFadeEdges, resolveFadeGradient, SCROLL_EDGES, type ScrollEdges } from "./scroll-edges";
|
|
3
|
+
|
|
4
|
+
describe("resolveFadeEdges", () => {
|
|
5
|
+
const EXPECTED: Record<ScrollEdges, { start: boolean; end: boolean }> = {
|
|
6
|
+
both: { start: true, end: true },
|
|
7
|
+
start: { start: true, end: false },
|
|
8
|
+
end: { start: false, end: true },
|
|
9
|
+
none: { start: false, end: false },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
for (const edges of SCROLL_EDGES) {
|
|
13
|
+
test(`${edges} draws ${JSON.stringify(EXPECTED[edges])}`, () => {
|
|
14
|
+
expect(resolveFadeEdges(edges)).toEqual(EXPECTED[edges]);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("resolveFadeBand", () => {
|
|
20
|
+
test("a vertical start band holds its inset from the top", () => {
|
|
21
|
+
expect(resolveFadeBand({ orientation: "vertical", edge: "start", size: 48, inset: 10 })).toStrictEqual({
|
|
22
|
+
top: 10,
|
|
23
|
+
height: 48,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a vertical end band sits on the bottom", () => {
|
|
28
|
+
expect(resolveFadeBand({ orientation: "vertical", edge: "end", size: 48, inset: 0 })).toStrictEqual({
|
|
29
|
+
bottom: 0,
|
|
30
|
+
height: 48,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("a horizontal start band sits on the left", () => {
|
|
35
|
+
expect(resolveFadeBand({ orientation: "horizontal", edge: "start", size: 48, inset: 0 })).toStrictEqual({
|
|
36
|
+
left: 0,
|
|
37
|
+
width: 48,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a horizontal end band holds its inset from the right", () => {
|
|
42
|
+
expect(resolveFadeBand({ orientation: "horizontal", edge: "end", size: 48, inset: 6 })).toStrictEqual({
|
|
43
|
+
right: 6,
|
|
44
|
+
width: 48,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("resolveFadeGradient", () => {
|
|
50
|
+
test("vertical start runs top to bottom", () => {
|
|
51
|
+
expect(resolveFadeGradient({ orientation: "vertical", edge: "start" })).toEqual({
|
|
52
|
+
start: { x: 0, y: 0 },
|
|
53
|
+
end: { x: 0, y: 1 },
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("vertical end runs bottom to top", () => {
|
|
58
|
+
expect(resolveFadeGradient({ orientation: "vertical", edge: "end" })).toEqual({
|
|
59
|
+
start: { x: 0, y: 1 },
|
|
60
|
+
end: { x: 0, y: 0 },
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("horizontal start runs left to right", () => {
|
|
65
|
+
expect(resolveFadeGradient({ orientation: "horizontal", edge: "start" })).toEqual({
|
|
66
|
+
start: { x: 0, y: 0 },
|
|
67
|
+
end: { x: 1, y: 0 },
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("horizontal end runs right to left", () => {
|
|
72
|
+
expect(resolveFadeGradient({ orientation: "horizontal", edge: "end" })).toEqual({
|
|
73
|
+
start: { x: 1, y: 0 },
|
|
74
|
+
end: { x: 0, y: 0 },
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure geometry of an affordance drawn at a scrollable's edges — where each
|
|
3
|
+
* band sits and which way its gradient runs. `ScrollFade` paints a gradient in
|
|
4
|
+
* these bands and `ScrollBlur` a blur, so the geometry lives here rather than in
|
|
5
|
+
* either component's folder, and neither has to import the other.
|
|
6
|
+
*
|
|
7
|
+
* Every function is a self-contained worklet, for the reason `scroll-maths.ts`
|
|
8
|
+
* gives: a helper called from inside a worklet can be missing from its closure
|
|
9
|
+
* on the UI thread. Free of React Native imports, so `bun test` reaches it all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ScrollEdge, ScrollOrientation } from "./scroll-maths";
|
|
13
|
+
|
|
14
|
+
/** Which edges of a scrollable draw an affordance. */
|
|
15
|
+
export const SCROLL_EDGES = ["both", "start", "end", "none"] as const;
|
|
16
|
+
export type ScrollEdges = (typeof SCROLL_EDGES)[number];
|
|
17
|
+
|
|
18
|
+
/** Whether each edge draws a band at all. */
|
|
19
|
+
export function resolveFadeEdges(edges: ScrollEdges): { start: boolean; end: boolean } {
|
|
20
|
+
"worklet";
|
|
21
|
+
return {
|
|
22
|
+
start: edges === "both" || edges === "start",
|
|
23
|
+
end: edges === "both" || edges === "end",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The placement of one band, as absolute-position style props. Only the keys that apply are present. */
|
|
28
|
+
export type FadeBandStyle =
|
|
29
|
+
| { top: number; height: number }
|
|
30
|
+
| { bottom: number; height: number }
|
|
31
|
+
| { left: number; width: number }
|
|
32
|
+
| { right: number; width: number };
|
|
33
|
+
|
|
34
|
+
export type FadeBandInput = {
|
|
35
|
+
orientation: ScrollOrientation;
|
|
36
|
+
edge: ScrollEdge;
|
|
37
|
+
/** Depth in points along the scroll axis. */
|
|
38
|
+
size: number;
|
|
39
|
+
/** Points to hold the band in from its edge. */
|
|
40
|
+
inset: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Where one band sits: pinned `inset` points in from its edge, `size` deep
|
|
45
|
+
* along the scroll axis. The band spans the other axis through its class
|
|
46
|
+
* (`inset-x-0` / `inset-y-0`), not through this style.
|
|
47
|
+
*
|
|
48
|
+
* `start` and `end` follow the scroll axis in layout space — top and bottom,
|
|
49
|
+
* left and right — so they do not flip under right-to-left on their own.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveFadeBand({ orientation, edge, size, inset }: FadeBandInput): FadeBandStyle {
|
|
52
|
+
"worklet";
|
|
53
|
+
if (orientation === "vertical") {
|
|
54
|
+
return edge === "start" ? { top: inset, height: size } : { bottom: inset, height: size };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return edge === "start" ? { left: inset, width: size } : { right: inset, width: size };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A gradient's direction, in the unit square `expo-linear-gradient` takes. */
|
|
61
|
+
export type FadeGradientVector = { start: { x: number; y: number }; end: { x: number; y: number } };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The direction of one band's gradient: from its own edge, where the colour is
|
|
65
|
+
* solid, inward to where it has faded out. A blur mask runs the same way.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveFadeGradient({
|
|
68
|
+
orientation,
|
|
69
|
+
edge,
|
|
70
|
+
}: {
|
|
71
|
+
orientation: ScrollOrientation;
|
|
72
|
+
edge: ScrollEdge;
|
|
73
|
+
}): FadeGradientVector {
|
|
74
|
+
"worklet";
|
|
75
|
+
const near = edge === "start" ? 0 : 1;
|
|
76
|
+
const far = 1 - near;
|
|
77
|
+
|
|
78
|
+
if (orientation === "vertical") return { start: { x: 0, y: near }, end: { x: 0, y: far } };
|
|
79
|
+
|
|
80
|
+
return { start: { x: near, y: 0 }, end: { x: far, y: 0 } };
|
|
81
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readScrollEvent, type ScrollEdge, scrollEdgeOpacity, scrollProgress } from "./scroll-maths";
|
|
3
|
+
|
|
4
|
+
type EdgeCase = {
|
|
5
|
+
edge: ScrollEdge;
|
|
6
|
+
offset: number;
|
|
7
|
+
contentSize: number;
|
|
8
|
+
layoutSize: number;
|
|
9
|
+
fadeInDistance: number;
|
|
10
|
+
expected: number;
|
|
11
|
+
why: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const EDGE_CASES: readonly EdgeCase[] = [
|
|
15
|
+
{ edge: "start", offset: 0, contentSize: 1000, layoutSize: 600, fadeInDistance: 24, expected: 0, why: "at rest" },
|
|
16
|
+
{
|
|
17
|
+
edge: "start",
|
|
18
|
+
offset: 12,
|
|
19
|
+
contentSize: 1000,
|
|
20
|
+
layoutSize: 600,
|
|
21
|
+
fadeInDistance: 24,
|
|
22
|
+
expected: 0.5,
|
|
23
|
+
why: "halfway in",
|
|
24
|
+
},
|
|
25
|
+
{ edge: "start", offset: 100, contentSize: 1000, layoutSize: 600, fadeInDistance: 24, expected: 1, why: "fully in" },
|
|
26
|
+
{
|
|
27
|
+
edge: "start",
|
|
28
|
+
offset: -30,
|
|
29
|
+
contentSize: 1000,
|
|
30
|
+
layoutSize: 600,
|
|
31
|
+
fadeInDistance: 24,
|
|
32
|
+
expected: 0,
|
|
33
|
+
why: "rubber-banding past the start",
|
|
34
|
+
},
|
|
35
|
+
{ edge: "end", offset: 0, contentSize: 1000, layoutSize: 600, fadeInDistance: 24, expected: 1, why: "at rest" },
|
|
36
|
+
{
|
|
37
|
+
edge: "end",
|
|
38
|
+
offset: 388,
|
|
39
|
+
contentSize: 1000,
|
|
40
|
+
layoutSize: 600,
|
|
41
|
+
fadeInDistance: 24,
|
|
42
|
+
expected: 0.5,
|
|
43
|
+
why: "halfway out",
|
|
44
|
+
},
|
|
45
|
+
{ edge: "end", offset: 400, contentSize: 1000, layoutSize: 600, fadeInDistance: 24, expected: 0, why: "at the end" },
|
|
46
|
+
{
|
|
47
|
+
edge: "end",
|
|
48
|
+
offset: 430,
|
|
49
|
+
contentSize: 1000,
|
|
50
|
+
layoutSize: 600,
|
|
51
|
+
fadeInDistance: 24,
|
|
52
|
+
expected: 0,
|
|
53
|
+
why: "bouncing past the end",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
edge: "start",
|
|
57
|
+
offset: 50,
|
|
58
|
+
contentSize: 300,
|
|
59
|
+
layoutSize: 600,
|
|
60
|
+
fadeInDistance: 24,
|
|
61
|
+
expected: 0,
|
|
62
|
+
why: "short content",
|
|
63
|
+
},
|
|
64
|
+
{ edge: "end", offset: 50, contentSize: 300, layoutSize: 600, fadeInDistance: 24, expected: 0, why: "short content" },
|
|
65
|
+
{ edge: "start", offset: 0, contentSize: 0, layoutSize: 0, fadeInDistance: 24, expected: 0, why: "unmeasured" },
|
|
66
|
+
{ edge: "end", offset: 0, contentSize: 0, layoutSize: 0, fadeInDistance: 24, expected: 1, why: "unmeasured" },
|
|
67
|
+
{
|
|
68
|
+
edge: "start",
|
|
69
|
+
offset: 0.5,
|
|
70
|
+
contentSize: 1000,
|
|
71
|
+
layoutSize: 600,
|
|
72
|
+
fadeInDistance: 0,
|
|
73
|
+
expected: 1,
|
|
74
|
+
why: "a step, moved",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
edge: "start",
|
|
78
|
+
offset: 0,
|
|
79
|
+
contentSize: 1000,
|
|
80
|
+
layoutSize: 600,
|
|
81
|
+
fadeInDistance: 0,
|
|
82
|
+
expected: 0,
|
|
83
|
+
why: "a step, at rest",
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
describe("scrollEdgeOpacity", () => {
|
|
88
|
+
for (const { expected, why, ...state } of EDGE_CASES) {
|
|
89
|
+
test(`${state.edge} at ${state.offset} (${why}) is ${expected}`, () => {
|
|
90
|
+
expect(scrollEdgeOpacity(state)).toBeCloseTo(expected, 6);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A Screen's source only learns its sizes from a scroll event, so a tall
|
|
95
|
+
// list must show its end fade at rest — while the start still follows the
|
|
96
|
+
// finger, since the offset is real even before the sizes are.
|
|
97
|
+
test("an unmeasured start still follows the offset", () => {
|
|
98
|
+
expect(scrollEdgeOpacity({ edge: "start", offset: 12, contentSize: 0, layoutSize: 0, fadeInDistance: 24 })).toBe(
|
|
99
|
+
0.5
|
|
100
|
+
);
|
|
101
|
+
expect(scrollEdgeOpacity({ edge: "end", offset: 12, contentSize: 800, layoutSize: 0, fadeInDistance: 24 })).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a step at the end is in until the last point", () => {
|
|
105
|
+
const base = { edge: "end", contentSize: 1000, layoutSize: 600, fadeInDistance: 0 } as const;
|
|
106
|
+
expect(scrollEdgeOpacity({ ...base, offset: 399.5 })).toBe(1);
|
|
107
|
+
expect(scrollEdgeOpacity({ ...base, offset: 400 })).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("content exactly the viewport's size fades neither edge", () => {
|
|
111
|
+
const base = { offset: 0, contentSize: 600, layoutSize: 600, fadeInDistance: 24 } as const;
|
|
112
|
+
expect(scrollEdgeOpacity({ ...base, edge: "start" })).toBe(0);
|
|
113
|
+
expect(scrollEdgeOpacity({ ...base, edge: "end" })).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("scrollProgress", () => {
|
|
118
|
+
test.each([
|
|
119
|
+
[5, 0, 10, 0.5],
|
|
120
|
+
[-5, 0, 10, 0],
|
|
121
|
+
[15, 0, 10, 1],
|
|
122
|
+
[3, 10, 0, 0.7],
|
|
123
|
+
[5, 5, 5, 1],
|
|
124
|
+
[4, 5, 5, 0],
|
|
125
|
+
])("(%p, %p, %p) is %p", (value, from, to, expected) => {
|
|
126
|
+
expect(scrollProgress(value, from, to)).toBeCloseTo(expected, 6);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("readScrollEvent", () => {
|
|
131
|
+
const event = {
|
|
132
|
+
contentOffset: { x: 30, y: -12 },
|
|
133
|
+
contentSize: { width: 900, height: 1400 },
|
|
134
|
+
layoutMeasurement: { width: 390, height: 700 },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
test("reads y and heights on a vertical scroller", () => {
|
|
138
|
+
expect(readScrollEvent(event, "vertical")).toEqual({ offset: -12, contentSize: 1400, layoutSize: 700 });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("reads x and widths on a horizontal scroller", () => {
|
|
142
|
+
expect(readScrollEvent(event, "horizontal")).toEqual({ offset: 30, contentSize: 900, layoutSize: 390 });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("passes a negative offset through", () => {
|
|
146
|
+
const bounce = { ...event, contentOffset: { x: -40, y: 0 } };
|
|
147
|
+
expect(readScrollEvent(bounce, "horizontal").offset).toBe(-40);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure maths behind the scroll-linked components — `ScrollFade` and the
|
|
3
|
+
* effects that build on `useScrollSource`.
|
|
4
|
+
*
|
|
5
|
+
* **Every function here is a self-contained worklet.** A module-scope helper
|
|
6
|
+
* called from inside a worklet can be missing from that worklet's closure on
|
|
7
|
+
* the UI thread, which surfaces as `undefined is not a function` mid-scroll and
|
|
8
|
+
* is invisible to `bun test`. So each function writes its own clamp inline and
|
|
9
|
+
* none of them calls another — do not "tidy" the repetition into a helper.
|
|
10
|
+
*
|
|
11
|
+
* Free of React Native imports, so the whole table is reachable from `bun test`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The axis a scrollable moves along. */
|
|
15
|
+
export type ScrollOrientation = "vertical" | "horizontal";
|
|
16
|
+
|
|
17
|
+
/** One end of a scrollable, along its own axis — top or left is `start`. */
|
|
18
|
+
export type ScrollEdge = "start" | "end";
|
|
19
|
+
|
|
20
|
+
/** The scroll-event fields a handler worklet receives, narrowed to what the scroll family reads. */
|
|
21
|
+
export type ScrollEventFields = {
|
|
22
|
+
contentOffset: { x: number; y: number };
|
|
23
|
+
contentSize: { width: number; height: number };
|
|
24
|
+
layoutMeasurement: { width: number; height: number };
|
|
25
|
+
velocity?: { x: number; y: number };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One scroll event, read along one axis. */
|
|
29
|
+
export type ScrollReading = { offset: number; contentSize: number; layoutSize: number };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The offset and sizes of a scroll event along `orientation` — `y` and heights
|
|
33
|
+
* vertically, `x` and widths horizontally.
|
|
34
|
+
*
|
|
35
|
+
* A negative offset passes through untouched: it is the rubber band at the
|
|
36
|
+
* start, and an effect that wants to stretch with it needs to see it.
|
|
37
|
+
*/
|
|
38
|
+
export function readScrollEvent(event: ScrollEventFields, orientation: ScrollOrientation): ScrollReading {
|
|
39
|
+
"worklet";
|
|
40
|
+
if (orientation === "horizontal") {
|
|
41
|
+
return {
|
|
42
|
+
offset: event.contentOffset.x,
|
|
43
|
+
contentSize: event.contentSize.width,
|
|
44
|
+
layoutSize: event.layoutMeasurement.width,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
offset: event.contentOffset.y,
|
|
50
|
+
contentSize: event.contentSize.height,
|
|
51
|
+
layoutSize: event.layoutMeasurement.height,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type ScrollEdgeState = {
|
|
56
|
+
edge: ScrollEdge;
|
|
57
|
+
offset: number;
|
|
58
|
+
contentSize: number;
|
|
59
|
+
layoutSize: number;
|
|
60
|
+
/** Points of travel from fully clear to fully in. 0 makes it a step. */
|
|
61
|
+
fadeInDistance: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The 0..1 opacity of an affordance at one edge of a scrollable — a fade, a
|
|
66
|
+
* blur, a hairline — which is in exactly while there is content past that edge.
|
|
67
|
+
*
|
|
68
|
+
* - **start** is clear at rest and arrives over the first `fadeInDistance`
|
|
69
|
+
* points of travel. Rubber-banding past the start keeps it clear.
|
|
70
|
+
* - **end** is in at rest and leaves over the last `fadeInDistance` points.
|
|
71
|
+
* Bouncing past the end keeps it clear.
|
|
72
|
+
* - **Short content** — no further to scroll — clears both: an edge that
|
|
73
|
+
* promises content which does not exist is worse than a hard edge.
|
|
74
|
+
* - **Unmeasured** — either size still 0 — lets the start follow the offset
|
|
75
|
+
* and draws the end. A `Screen`'s source learns its sizes only from the first
|
|
76
|
+
* scroll event, and a tall list must show its end fade at rest.
|
|
77
|
+
*/
|
|
78
|
+
export function scrollEdgeOpacity(state: ScrollEdgeState): number {
|
|
79
|
+
"worklet";
|
|
80
|
+
const { edge, offset, contentSize, layoutSize, fadeInDistance } = state;
|
|
81
|
+
const isMeasured = contentSize > 0 && layoutSize > 0;
|
|
82
|
+
const maxScroll = contentSize - layoutSize;
|
|
83
|
+
|
|
84
|
+
if (isMeasured && maxScroll <= 0) return 0;
|
|
85
|
+
|
|
86
|
+
// Points of content past this edge.
|
|
87
|
+
let past: number;
|
|
88
|
+
if (edge === "start") {
|
|
89
|
+
past = offset;
|
|
90
|
+
} else {
|
|
91
|
+
if (!isMeasured) return 1;
|
|
92
|
+
past = maxScroll - offset;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (fadeInDistance <= 0) return past > 0 ? 1 : 0;
|
|
96
|
+
|
|
97
|
+
const ratio = past / fadeInDistance;
|
|
98
|
+
return ratio < 0 ? 0 : ratio > 1 ? 1 : ratio;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Where `value` sits between `from` and `to`, as a clamped 0..1.
|
|
103
|
+
*
|
|
104
|
+
* `from` may exceed `to` — the ramp then runs downward, which is how an effect
|
|
105
|
+
* that leaves as the scroll arrives is written. With `from === to` it is a
|
|
106
|
+
* step: 1 at or past the point, 0 before it.
|
|
107
|
+
*/
|
|
108
|
+
export function scrollProgress(value: number, from: number, to: number): number {
|
|
109
|
+
"worklet";
|
|
110
|
+
if (from === to) return value >= to ? 1 : 0;
|
|
111
|
+
|
|
112
|
+
const ratio = (value - from) / (to - from);
|
|
113
|
+
return ratio < 0 ? 0 : ratio > 1 ? 1 : ratio;
|
|
114
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveActiveSection, resolveScrollProgress, resolveSectionScrollTarget } from "./scroll-sections";
|
|
3
|
+
|
|
4
|
+
const sections = [
|
|
5
|
+
{ id: "intro", y: 0 },
|
|
6
|
+
{ id: "setup", y: 400 },
|
|
7
|
+
{ id: "usage", y: 1000 },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe("resolveActiveSection", () => {
|
|
11
|
+
test("returns undefined with no sections", () => {
|
|
12
|
+
expect(resolveActiveSection([], 100, 0)).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("picks the last section whose top has crossed the reading line", () => {
|
|
16
|
+
expect(resolveActiveSection(sections, 0, 0)).toBe("intro");
|
|
17
|
+
expect(resolveActiveSection(sections, 399, 0)).toBe("intro");
|
|
18
|
+
expect(resolveActiveSection(sections, 400, 0)).toBe("setup");
|
|
19
|
+
expect(resolveActiveSection(sections, 2000, 0)).toBe("usage");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the reading line moves the switch earlier", () => {
|
|
23
|
+
expect(resolveActiveSection(sections, 304, 96)).toBe("setup");
|
|
24
|
+
expect(resolveActiveSection(sections, 303, 96)).toBe("intro");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("above the first section reads as the first section", () => {
|
|
28
|
+
expect(resolveActiveSection([{ id: "a", y: 200 }], 0, 0)).toBe("a");
|
|
29
|
+
expect(resolveActiveSection(sections, -80, 0)).toBe("intro");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("reads sections in offset order, not registration order", () => {
|
|
33
|
+
const shuffled = [sections[2], sections[0], sections[1]].filter((s) => s !== undefined);
|
|
34
|
+
expect(resolveActiveSection(shuffled, 500, 0)).toBe("setup");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("resolveScrollProgress", () => {
|
|
39
|
+
test("is 0 at the top and 1 at the end", () => {
|
|
40
|
+
expect(resolveScrollProgress(0, 2000, 800)).toBe(0);
|
|
41
|
+
expect(resolveScrollProgress(1200, 2000, 800)).toBe(1);
|
|
42
|
+
expect(resolveScrollProgress(600, 2000, 800)).toBe(0.5);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("clamps overscroll at both ends", () => {
|
|
46
|
+
expect(resolveScrollProgress(-50, 2000, 800)).toBe(0);
|
|
47
|
+
expect(resolveScrollProgress(1500, 2000, 800)).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("is 1 when nothing scrolls", () => {
|
|
51
|
+
expect(resolveScrollProgress(0, 500, 800)).toBe(1);
|
|
52
|
+
expect(resolveScrollProgress(0, 800, 800)).toBe(1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("resolveSectionScrollTarget", () => {
|
|
57
|
+
test("puts the section's top on the reading line", () => {
|
|
58
|
+
expect(resolveSectionScrollTarget(400, 96)).toBe(304);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("never scrolls above the top", () => {
|
|
62
|
+
expect(resolveSectionScrollTarget(40, 96)).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** A section of a scrolled screen: its id and the top of its box in content coordinates. */
|
|
2
|
+
export type SectionOffset = { id: string; y: number };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The section the reader is in: the last one whose top has crossed the reading line, a fixed
|
|
6
|
+
* distance below the top of the viewport. Above the first section's top the first section is
|
|
7
|
+
* still the answer — a screen with a header before its first section is reading that section.
|
|
8
|
+
*
|
|
9
|
+
* Sections are read in offset order, not registration order: `onLayout` reports in whatever
|
|
10
|
+
* order the native views measure, and a section mounted late would otherwise win the tie.
|
|
11
|
+
*/
|
|
12
|
+
export function resolveActiveSection(
|
|
13
|
+
sections: readonly SectionOffset[],
|
|
14
|
+
scrollY: number,
|
|
15
|
+
readingLine: number
|
|
16
|
+
): string | undefined {
|
|
17
|
+
"worklet";
|
|
18
|
+
if (sections.length === 0) return undefined;
|
|
19
|
+
const line = scrollY + readingLine;
|
|
20
|
+
let first: SectionOffset | undefined;
|
|
21
|
+
let active: SectionOffset | undefined;
|
|
22
|
+
for (const section of sections) {
|
|
23
|
+
if (first === undefined || section.y < first.y) first = section;
|
|
24
|
+
if (section.y <= line && (active === undefined || section.y >= active.y)) active = section;
|
|
25
|
+
}
|
|
26
|
+
return (active ?? first)?.id;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How far down the content the reader is, 0 to 1. Overscroll clamps at both ends, and a screen
|
|
31
|
+
* whose content fits reads as finished — there is nothing left to read.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveScrollProgress(scrollY: number, contentHeight: number, viewportHeight: number): number {
|
|
34
|
+
"worklet";
|
|
35
|
+
const range = contentHeight - viewportHeight;
|
|
36
|
+
if (range <= 0) return 1;
|
|
37
|
+
return Math.min(1, Math.max(0, scrollY / range));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The scroll offset that puts a section's top on the reading line, never above the top. */
|
|
41
|
+
export function resolveSectionScrollTarget(y: number, readingLine: number): number {
|
|
42
|
+
"worklet";
|
|
43
|
+
return Math.max(0, y - readingLine);
|
|
44
|
+
}
|