@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.3.0-alpha.20261017084646",
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
- <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,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
+ }