@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.3.0-alpha.20261017120102",
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
- <View className={screenVariants().root({ className })} {...props}>
34
- {children}
35
- </View>
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
+ }