@delacour/react-native-ui 0.3.0-alpha.20261017120102 → 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 +4 -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-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/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",
|
|
@@ -94,6 +94,7 @@
|
|
|
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
96
|
"./hooks/use-scroll-sections": "./src/hooks/use-scroll-sections.ts",
|
|
97
|
+
"./hooks/use-scroll-source": "./src/hooks/use-scroll-source.tsx",
|
|
97
98
|
"./hooks/use-theme-color": "./src/hooks/use-theme-color.ts",
|
|
98
99
|
"./lib/calm-motion": "./src/lib/calm-motion.ts",
|
|
99
100
|
"./lib/cn": "./src/lib/cn.ts",
|
|
@@ -103,6 +104,8 @@
|
|
|
103
104
|
"./lib/merge-props": "./src/lib/merge-props.ts",
|
|
104
105
|
"./lib/navigation-theme": "./src/lib/navigation-theme.ts",
|
|
105
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",
|
|
106
109
|
"./lib/scroll-sections": "./src/lib/scroll-sections.ts",
|
|
107
110
|
"./lib/slot": "./src/lib/slot.tsx",
|
|
108
111
|
"./lib/tv": "./src/lib/tv.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,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
|
+
}
|