@delacour/react-native-ui 0.5.0-alpha.20261024151637 → 0.5.0
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 +3 -3
- package/src/components/carousel/carousel-caption.tsx +1 -1
- package/src/components/carousel/carousel-dots.tsx +2 -2
- package/src/components/carousel/carousel-item.tsx +1 -1
- package/src/components/circular-text/circular-text-spin.ts +1 -2
- package/src/components/compare/compare.tsx +1 -2
- package/src/components/dialog/dialog-content.tsx +1 -1
- package/src/components/drawer/drawer-content.tsx +2 -3
- package/src/components/flip-card/flip-card.tsx +1 -2
- package/src/components/image-viewer/image-viewer-chrome.tsx +1 -1
- package/src/components/image-viewer/image-viewer-layer.tsx +6 -9
- package/src/components/image-viewer/image-viewer-page.tsx +2 -5
- package/src/components/input/AGENTS.md +6 -0
- package/src/components/input/index.ts +1 -0
- package/src/components/input/input.tsx +7 -0
- package/src/components/input/input.variants.test.ts +20 -0
- package/src/components/input/input.variants.ts +19 -0
- package/src/components/marquee/marquee-track.tsx +1 -2
- package/src/components/message-scroller/use-message-scroller-state.ts +1 -2
- package/src/components/page-header/page-header-cover.tsx +2 -2
- package/src/components/screen/screen-footer-background.tsx +7 -10
- package/src/components/screen/screen-navbar-background.tsx +1 -4
- package/src/components/screen/screen-view.tsx +1 -1
- package/src/components/screen/use-screen-scroll-insets.ts +24 -30
- package/src/components/scroll-canvas/use-frame-progress.ts +1 -2
- package/src/components/scroll-fade/AGENTS.md +17 -3
- package/src/components/scroll-fade/scroll-fade-edge.tsx +37 -7
- package/src/components/scroll-fade/scroll-fade.tsx +13 -2
- package/src/components/scroll-header/scroll-header-root.tsx +3 -6
- package/src/components/scroll-header/use-scroll-header-scroll-props.ts +4 -7
- package/src/components/scroll-text/scroll-text-source.ts +1 -2
- package/src/components/section-progress/section-progress.tsx +3 -6
- package/src/components/slider/slider-track.tsx +1 -2
- package/src/components/sortable/sortable-item.tsx +2 -4
- package/src/components/tabs/tabs-scroll-view.tsx +1 -2
- package/src/components/tabs/tabs-separator.tsx +1 -4
- package/src/components/tabs/tabs.tsx +1 -2
- package/src/components/text-animation/text-animation-typing.tsx +1 -2
- package/src/components/timeline/timeline-title.tsx +1 -2
- package/src/components/timeline/use-timeline-reading-edge.ts +8 -11
- package/src/hooks/use-scroll-source.tsx +23 -26
- package/src/lib/scroll-edges.test.ts +36 -1
- package/src/lib/scroll-edges.ts +23 -0
- package/src/lib/scroll-maths.test.ts +27 -0
- package/src/lib/scroll-maths.ts +11 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.5.0
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -182,8 +182,8 @@
|
|
|
182
182
|
"expo-blur": ">=15",
|
|
183
183
|
"@maplibre/maplibre-react-native": "^11.5.0",
|
|
184
184
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
185
|
-
"@delacour/react-native-bottom-sheet": "0.3.0
|
|
186
|
-
"@delacour/react-native-charts": "0.2.0
|
|
185
|
+
"@delacour/react-native-bottom-sheet": ">=0.3.0",
|
|
186
|
+
"@delacour/react-native-charts": ">=0.2.0",
|
|
187
187
|
"@legendapp/list": ">=3.3",
|
|
188
188
|
"react-native-skia": ">=3.7.3",
|
|
189
189
|
"expo-linear-gradient": ">=15",
|
|
@@ -30,7 +30,7 @@ export function CarouselCaption({ className, ...props }: CarouselCaptionProps):
|
|
|
30
30
|
const animatedStyle = useAnimatedStyle(() => {
|
|
31
31
|
const offset = loop ? resolveWrappedOffset(index, position.value, count) : index - position.value;
|
|
32
32
|
return { opacity: resolveCaptionOpacity(offset) };
|
|
33
|
-
}
|
|
33
|
+
});
|
|
34
34
|
|
|
35
35
|
const slots = carouselVariants({ orientation, variant });
|
|
36
36
|
|
|
@@ -104,12 +104,12 @@ function CarouselDot({
|
|
|
104
104
|
const offset = loop ? resolveWrappedOffset(index, position.value, count) : index - position.value;
|
|
105
105
|
const length = resolveDotLength(offset);
|
|
106
106
|
return isHorizontal ? { width: length } : { height: length };
|
|
107
|
-
}
|
|
107
|
+
});
|
|
108
108
|
|
|
109
109
|
const fillStyle = useAnimatedStyle(() => {
|
|
110
110
|
const offset = loop ? resolveWrappedOffset(index, position.value, count) : index - position.value;
|
|
111
111
|
return { opacity: resolveCaptionOpacity(offset) };
|
|
112
|
-
}
|
|
112
|
+
});
|
|
113
113
|
|
|
114
114
|
return (
|
|
115
115
|
<Animated.View className={className} style={dotStyle}>
|
|
@@ -86,7 +86,7 @@ export function CarouselItem({
|
|
|
86
86
|
],
|
|
87
87
|
zIndex: Math.round(100 - distance * 10),
|
|
88
88
|
};
|
|
89
|
-
}
|
|
89
|
+
});
|
|
90
90
|
|
|
91
91
|
const accessibility = resolveSlideAccessibility({ isActive, isPeek });
|
|
92
92
|
const itemClassName = carouselVariants({ orientation, variant }).item();
|
|
@@ -126,8 +126,7 @@ export function DrawerContent({
|
|
|
126
126
|
() => progress.value * (1 - resolveDrawerDragFraction(edge, drag.value, extent)),
|
|
127
127
|
(visibility) => {
|
|
128
128
|
scrimProgress.value = visibility;
|
|
129
|
-
}
|
|
130
|
-
[edge, extent]
|
|
129
|
+
}
|
|
131
130
|
);
|
|
132
131
|
|
|
133
132
|
const panelStyle = useAnimatedStyle(() => {
|
|
@@ -139,7 +138,7 @@ export function DrawerContent({
|
|
|
139
138
|
{ translateY: slide.translateY + (isHorizontal ? 0 : drag.value) },
|
|
140
139
|
],
|
|
141
140
|
};
|
|
142
|
-
}
|
|
141
|
+
});
|
|
143
142
|
|
|
144
143
|
const panel = useMemo<DrawerPanelValue>(() => ({ edge, side }), [edge, side]);
|
|
145
144
|
|
|
@@ -265,8 +265,7 @@ function FlipCardRoot(props: FlipCardProps): ReactElement {
|
|
|
265
265
|
if (!shouldTickFlipHaptic(hapticSide.value, next)) return;
|
|
266
266
|
hapticSide.value = next >= 90 ? 180 : 0;
|
|
267
267
|
if (haptic) playHaptic(haptic);
|
|
268
|
-
}
|
|
269
|
-
[haptic]
|
|
268
|
+
}
|
|
270
269
|
);
|
|
271
270
|
|
|
272
271
|
const isY = axis === "y";
|
|
@@ -50,7 +50,7 @@ export function ImageViewerChrome({
|
|
|
50
50
|
return {
|
|
51
51
|
opacity: fade.value * flight * chrome.value * zoomFade * resolveBackdropOpacity(dismissY.value, windowHeight),
|
|
52
52
|
};
|
|
53
|
-
}
|
|
53
|
+
});
|
|
54
54
|
|
|
55
55
|
const pointerEvents = isVisible ? "box-none" : "none";
|
|
56
56
|
|
|
@@ -493,15 +493,12 @@ export function ImageViewerLayer({
|
|
|
493
493
|
windowWidth,
|
|
494
494
|
]);
|
|
495
495
|
|
|
496
|
-
const backdropStyle = useAnimatedStyle(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}),
|
|
503
|
-
[windowHeight]
|
|
504
|
-
);
|
|
496
|
+
const backdropStyle = useAnimatedStyle(() => ({
|
|
497
|
+
opacity:
|
|
498
|
+
fade.value *
|
|
499
|
+
(progress.value < 0 ? 0 : progress.value > 1 ? 1 : progress.value) *
|
|
500
|
+
resolveBackdropOpacity(dismissY.value, windowHeight),
|
|
501
|
+
}));
|
|
505
502
|
|
|
506
503
|
const stageStyle = useAnimatedStyle(() => ({ opacity: fade.value }));
|
|
507
504
|
|
|
@@ -90,10 +90,7 @@ export function ImageViewerPage({
|
|
|
90
90
|
|
|
91
91
|
const { height: fittedHeight, width: fittedWidth, x: fittedX, y: fittedY } = fitted;
|
|
92
92
|
|
|
93
|
-
const pageStyle = useAnimatedStyle(
|
|
94
|
-
() => ({ transform: [{ translateX: (pageIndex - position.value) * pitch }] }),
|
|
95
|
-
[pageIndex, pitch]
|
|
96
|
-
);
|
|
93
|
+
const pageStyle = useAnimatedStyle(() => ({ transform: [{ translateX: (pageIndex - position.value) * pitch }] }));
|
|
97
94
|
|
|
98
95
|
const frameStyle = useAnimatedStyle(() => {
|
|
99
96
|
const rest = { height: fittedHeight, width: fittedWidth, x: fittedX, y: fittedY };
|
|
@@ -114,7 +111,7 @@ export function ImageViewerPage({
|
|
|
114
111
|
],
|
|
115
112
|
width: frame.width,
|
|
116
113
|
};
|
|
117
|
-
}
|
|
114
|
+
});
|
|
118
115
|
|
|
119
116
|
const fullStyle = useAnimatedStyle(() => ({ opacity: fullOpacity.value }));
|
|
120
117
|
|
|
@@ -97,6 +97,12 @@ Root plus `Input.Group` and its parts: `Prefix`, `Suffix`, `Button`,
|
|
|
97
97
|
[`Text.Label`](../text/AGENTS.md) and `Text.Caption` already are those, and a label defined twice is
|
|
98
98
|
a type scale that can drift. `apps/playground`'s `/input/form` is what the
|
|
99
99
|
trade looks like at a call site.
|
|
100
|
+
- **A `Field.Label` names the field.** `resolveInputAccessibilityLabel` takes an
|
|
101
|
+
explicit `accessibilityLabel` first and the enclosing [`Field`](../field/AGENTS.md)'s
|
|
102
|
+
`field.label` second, so the documented `<Field><Field.Label>Email</Field.Label><Input /></Field>`
|
|
103
|
+
announces "Email" under VoiceOver rather than an unnamed text field. The
|
|
104
|
+
same fallback [`OtpInput`](../otp-input/AGENTS.md) makes; `Textarea` renders
|
|
105
|
+
an `Input`, so it gets it too. With neither it stays `undefined`, not `""`.
|
|
100
106
|
- **`--spacing-input-*` is its own scale**, matching `--spacing-button-*` in
|
|
101
107
|
value and not in name — this is the case [Sizing](../../../AGENTS.md#sizing)
|
|
102
108
|
anticipated. A token test asserts the two stay level, so either can be retuned
|
|
@@ -8,6 +8,7 @@ import { useInputGroupContext } from "./input.context";
|
|
|
8
8
|
import {
|
|
9
9
|
type InputSize,
|
|
10
10
|
type InputVariant,
|
|
11
|
+
resolveInputAccessibilityLabel,
|
|
11
12
|
resolveInputFieldClass,
|
|
12
13
|
resolvePlaceholderAccentClass,
|
|
13
14
|
resolveSelectionAccentClass,
|
|
@@ -60,6 +61,7 @@ function InputRoot({
|
|
|
60
61
|
className,
|
|
61
62
|
placeholderColorClassName,
|
|
62
63
|
selectionColorClassName,
|
|
64
|
+
accessibilityLabel,
|
|
63
65
|
editable,
|
|
64
66
|
multiline,
|
|
65
67
|
onBlur,
|
|
@@ -148,6 +150,7 @@ function InputRoot({
|
|
|
148
150
|
selectionColorClassName={selectionAccent}
|
|
149
151
|
textAlignVertical={multiline ? "top" : undefined}
|
|
150
152
|
{...props}
|
|
153
|
+
accessibilityLabel={resolveInputAccessibilityLabel(accessibilityLabel, field?.label)}
|
|
151
154
|
editable={resolved.isDisabled ? false : editable}
|
|
152
155
|
onBlur={handleBlur}
|
|
153
156
|
onFocus={handleFocus}
|
|
@@ -179,6 +182,10 @@ function InputRoot({
|
|
|
179
182
|
* Wrap it in an `Input.Group` to put an icon, an affix or a control inside the
|
|
180
183
|
* field's own box.
|
|
181
184
|
*
|
|
185
|
+
* Inside a `Field`, the text of its `Field.Label` becomes the field's accessible
|
|
186
|
+
* name when no `accessibilityLabel` is given, so a screen reader announces
|
|
187
|
+
* which value the editor wants.
|
|
188
|
+
*
|
|
182
189
|
* @example
|
|
183
190
|
* <Input onChangeText={setEmail} placeholder="Email" value={email} />
|
|
184
191
|
*
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveGroupButtonClass,
|
|
20
20
|
resolveGroupButtonSize,
|
|
21
21
|
resolveGroupSegments,
|
|
22
|
+
resolveInputAccessibilityLabel,
|
|
22
23
|
resolveInputFieldClass,
|
|
23
24
|
resolveInputGroupClass,
|
|
24
25
|
resolveInputGroupRowClass,
|
|
@@ -658,3 +659,22 @@ describe("resolveGroupSegments", () => {
|
|
|
658
659
|
expect(resolveGroupSegments([])).toEqual([]);
|
|
659
660
|
});
|
|
660
661
|
});
|
|
662
|
+
|
|
663
|
+
describe("resolveInputAccessibilityLabel", () => {
|
|
664
|
+
test("an explicit label wins over the enclosing Field.Label", () => {
|
|
665
|
+
expect(resolveInputAccessibilityLabel("Work email", "Email")).toBe("Work email");
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("falls back to the enclosing Field.Label's text", () => {
|
|
669
|
+
expect(resolveInputAccessibilityLabel(undefined, "Email")).toBe("Email");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("names nothing outside a Field, or under a label with no text", () => {
|
|
673
|
+
expect(resolveInputAccessibilityLabel(undefined, null)).toBeUndefined();
|
|
674
|
+
expect(resolveInputAccessibilityLabel(undefined, undefined)).toBeUndefined();
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("an explicit label still wins when no Field is enclosing", () => {
|
|
678
|
+
expect(resolveInputAccessibilityLabel("Search", null)).toBe("Search");
|
|
679
|
+
});
|
|
680
|
+
});
|
|
@@ -511,4 +511,23 @@ export function resolveSelectionAccentClass({
|
|
|
511
511
|
return cn(isInvalid ? INPUT_INVALID_SELECTION_ACCENT_CLASS : INPUT_SELECTION_ACCENT_CLASS, className);
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
/**
|
|
515
|
+
* The accessible name a screen reader announces when the field takes focus.
|
|
516
|
+
*
|
|
517
|
+
* The caller's `accessibilityLabel` wins; otherwise the text an enclosing
|
|
518
|
+
* `Field.Label` registered with the field's context, so
|
|
519
|
+
* `<Field><Field.Label>Email</Field.Label><Input /></Field>` reads "Email"
|
|
520
|
+
* rather than an unnamed text field. React Native has no `<label for>`; this is
|
|
521
|
+
* that association, the same way `OtpInput` makes it. With neither there is no
|
|
522
|
+
* name, and `undefined` leaves the native default rather than an empty string.
|
|
523
|
+
*
|
|
524
|
+
* Pure, so it is unit-testable. See AGENTS.md.
|
|
525
|
+
*/
|
|
526
|
+
export function resolveInputAccessibilityLabel(
|
|
527
|
+
own: string | undefined,
|
|
528
|
+
fieldLabel: string | null | undefined
|
|
529
|
+
): string | undefined {
|
|
530
|
+
return own ?? fieldLabel ?? undefined;
|
|
531
|
+
}
|
|
532
|
+
|
|
514
533
|
export type InputVariantProps = VariantProps<typeof inputVariants>;
|
|
@@ -123,7 +123,7 @@ export function PageHeaderCover({
|
|
|
123
123
|
height: resolvePageHeaderCoverHeight({ variant, height, topInset: inset, isInsetAware }),
|
|
124
124
|
marginTop: resolvePageHeaderCoverOffset({ variant, isInsetAware, isInScreen, topInset: inset }),
|
|
125
125
|
};
|
|
126
|
-
}
|
|
126
|
+
});
|
|
127
127
|
|
|
128
128
|
const overlayStyle = useAnimatedStyle(() => {
|
|
129
129
|
const inset = navbar
|
|
@@ -136,7 +136,7 @@ export function PageHeaderCover({
|
|
|
136
136
|
return {
|
|
137
137
|
top: resolvePageHeaderCoverHeight({ variant, height, topInset: inset, isInsetAware }) - Math.max(0, height),
|
|
138
138
|
};
|
|
139
|
-
}
|
|
139
|
+
});
|
|
140
140
|
|
|
141
141
|
const isDescribed = accessibilityLabel !== undefined;
|
|
142
142
|
|
|
@@ -35,17 +35,14 @@ export const ScreenFooterBackground = memo(function ScreenFooterBackground({
|
|
|
35
35
|
const debug = useScreenDebug();
|
|
36
36
|
const slots = screenVariants({ placement });
|
|
37
37
|
|
|
38
|
-
const borderStyle = useAnimatedStyle(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
scrollY: scrollY.value,
|
|
45
|
-
}),
|
|
38
|
+
const borderStyle = useAnimatedStyle(() => ({
|
|
39
|
+
opacity: resolveFooterBorderOpacity({
|
|
40
|
+
contentHeight: contentHeight.value,
|
|
41
|
+
fadeOnScroll,
|
|
42
|
+
layoutHeight: layoutHeight.value,
|
|
43
|
+
scrollY: scrollY.value,
|
|
46
44
|
}),
|
|
47
|
-
|
|
48
|
-
);
|
|
45
|
+
}));
|
|
49
46
|
|
|
50
47
|
return (
|
|
51
48
|
<View
|
|
@@ -31,10 +31,7 @@ export const ScreenNavbarBackground = memo(function ScreenNavbarBackground({
|
|
|
31
31
|
}: ScreenNavbarBackgroundProps): ReactElement {
|
|
32
32
|
const { scrollY } = useScreenPart("Screen.Navbar");
|
|
33
33
|
|
|
34
|
-
const borderStyle = useAnimatedStyle(
|
|
35
|
-
() => ({ opacity: resolveNavbarBorderOpacity(scrollY.value, fadeOnScroll) }),
|
|
36
|
-
[fadeOnScroll]
|
|
37
|
-
);
|
|
34
|
+
const borderStyle = useAnimatedStyle(() => ({ opacity: resolveNavbarBorderOpacity(scrollY.value, fadeOnScroll) }));
|
|
38
35
|
|
|
39
36
|
return (
|
|
40
37
|
<View className={screenVariants().navbarBackground({ className })} pointerEvents="none">
|
|
@@ -43,8 +43,7 @@ function useFooterBaselineHeight(estimatedHeight = 0): number {
|
|
|
43
43
|
(current, previous) => {
|
|
44
44
|
if (current <= 0 || current === previous) return;
|
|
45
45
|
scheduleOnRN(setMeasured, current);
|
|
46
|
-
}
|
|
47
|
-
[]
|
|
46
|
+
}
|
|
48
47
|
);
|
|
49
48
|
|
|
50
49
|
return measured;
|
|
@@ -83,8 +82,7 @@ export function useChatComposerInset(): DerivedValue<number> {
|
|
|
83
82
|
const { bottom } = useSafeAreaInsets();
|
|
84
83
|
|
|
85
84
|
return useDerivedValue(
|
|
86
|
-
() => footerOccupancy(footer.height.value, bottom) + footer.overlayHeight.value + CHAT_COMPOSER_GAP
|
|
87
|
-
[bottom]
|
|
85
|
+
() => footerOccupancy(footer.height.value, bottom) + footer.overlayHeight.value + CHAT_COMPOSER_GAP
|
|
88
86
|
);
|
|
89
87
|
}
|
|
90
88
|
|
|
@@ -98,8 +96,7 @@ export function useChatComposerGrowthPadding(): DerivedValue<number> {
|
|
|
98
96
|
const { footer } = useScreenPart("useChatComposerGrowthPadding");
|
|
99
97
|
|
|
100
98
|
return useDerivedValue(
|
|
101
|
-
() => Math.max(0, footer.height.value - footer.initialHeight.value) + footer.overlayHeight.value
|
|
102
|
-
[]
|
|
99
|
+
() => Math.max(0, footer.height.value - footer.initialHeight.value) + footer.overlayHeight.value
|
|
103
100
|
);
|
|
104
101
|
}
|
|
105
102
|
|
|
@@ -177,29 +174,26 @@ export function useScreenScrollInsets(mode: ScreenScrollInsetMode, onScroll?: Sc
|
|
|
177
174
|
const caller = classifyScrollHandler(onScroll);
|
|
178
175
|
const callerCallback = caller.kind === "js" ? caller.handler : undefined;
|
|
179
176
|
|
|
180
|
-
const trackingHandler = useAnimatedScrollHandler(
|
|
181
|
-
{
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
},
|
|
177
|
+
const trackingHandler = useAnimatedScrollHandler({
|
|
178
|
+
onScroll: (event) => {
|
|
179
|
+
scrollY.value = event.contentOffset.y;
|
|
180
|
+
|
|
181
|
+
if (event.contentSize.height !== contentHeight.value) {
|
|
182
|
+
contentHeight.value = event.contentSize.height;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (event.layoutMeasurement.height !== layoutHeight.value) {
|
|
186
|
+
layoutHeight.value = event.layoutMeasurement.height;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Only `nativeEvent` is real: a worklet sees the native payload, not
|
|
190
|
+
// React Native's synthetic wrapper, so `persist()` and the rest of the
|
|
191
|
+
// wrapper do not exist. A caller needing more passes a worklet handler.
|
|
192
|
+
if (callerCallback) {
|
|
193
|
+
scheduleOnRN(callerCallback, { nativeEvent: event } as unknown as NativeSyntheticEvent<NativeScrollEvent>);
|
|
194
|
+
}
|
|
200
195
|
},
|
|
201
|
-
|
|
202
|
-
);
|
|
196
|
+
});
|
|
203
197
|
|
|
204
198
|
// A caller's `useAnimatedScrollHandler` runs beside the tracking on the UI
|
|
205
199
|
// thread rather than replacing it — one `onScroll` prop, two worklets.
|
|
@@ -216,7 +210,7 @@ export function useScreenScrollInsets(mode: ScreenScrollInsetMode, onScroll?: Sc
|
|
|
216
210
|
safeAreaTop: top,
|
|
217
211
|
}),
|
|
218
212
|
};
|
|
219
|
-
}
|
|
213
|
+
});
|
|
220
214
|
|
|
221
215
|
const insetBottomAnimatedStyle = useAnimatedStyle(() => {
|
|
222
216
|
return {
|
|
@@ -229,7 +223,7 @@ export function useScreenScrollInsets(mode: ScreenScrollInsetMode, onScroll?: Sc
|
|
|
229
223
|
safeAreaBottom: bottom,
|
|
230
224
|
}),
|
|
231
225
|
};
|
|
232
|
-
}
|
|
226
|
+
});
|
|
233
227
|
|
|
234
228
|
return { insetBottomAnimatedStyle, insetTopAnimatedStyle, scrollHandler };
|
|
235
229
|
}
|
|
@@ -106,8 +106,7 @@ export function useFrameProgress(options: UseFrameProgressOptions): FrameProgres
|
|
|
106
106
|
() => (contentSize ? contentSize.value : 0),
|
|
107
107
|
(next, previous) => {
|
|
108
108
|
if (scroller && previous !== null && next !== previous) scheduleOnRN(measure);
|
|
109
|
-
}
|
|
110
|
-
[contentSize, scroller, measure]
|
|
109
|
+
}
|
|
111
110
|
);
|
|
112
111
|
|
|
113
112
|
const resting = resolveStaticProgress(effect);
|
|
@@ -13,8 +13,8 @@ none of them; it reads them:
|
|
|
13
13
|
| Module | What ScrollFade takes from it |
|
|
14
14
|
| --- | --- |
|
|
15
15
|
| `src/hooks/use-scroll-source.tsx` | `useResolvedScrollSource` and the `ScrollSource` type |
|
|
16
|
-
| `src/lib/scroll-maths.ts` | `scrollEdgeOpacity`, the per-edge opacity worklet |
|
|
17
|
-
| `src/lib/scroll-edges.ts` | `SCROLL_EDGES`, `resolveFadeEdges`, `resolveFadeBand`, `resolveFadeGradient` |
|
|
16
|
+
| `src/lib/scroll-maths.ts` | `scrollEdgeOpacity`, the per-edge opacity worklet, and its `coveredEnd` |
|
|
17
|
+
| `src/lib/scroll-edges.ts` | `SCROLL_EDGES`, `resolveFadeEdges`, `resolveFadeBand`, `resolveFadeGradient`, `resolveKeyboardLift` |
|
|
18
18
|
|
|
19
19
|
Their rules and tests live with them. Nothing here keeps a copy.
|
|
20
20
|
|
|
@@ -24,7 +24,7 @@ Their rules and tests live with them. Nothing here keeps a copy.
|
|
|
24
24
|
| --- | --- |
|
|
25
25
|
| `index.ts` | → `@delacour/react-native-ui/scroll-fade` |
|
|
26
26
|
| `scroll-fade.tsx` | `ScrollFade` — resolves the source and the colour, mounts the edges |
|
|
27
|
-
| `scroll-fade-edge.tsx` | One animated edge band — internal, memoised |
|
|
27
|
+
| `scroll-fade-edge.tsx` | One animated edge band, and its keyboard-aware wrapper — internal, memoised |
|
|
28
28
|
| `scroll-fade.variants.ts` | Pure `tv()` slots, the defaults, and `SCROLL_FADE_EDGES` — `lib/scroll-edges`' union under ScrollFade's name |
|
|
29
29
|
| `scroll-fade.variants.test.ts` | |
|
|
30
30
|
|
|
@@ -76,6 +76,20 @@ Their rules and tests live with them. Nothing here keeps a copy.
|
|
|
76
76
|
- **`insetStart` / `insetEnd` hold a band in from its edge** — under a bar this
|
|
77
77
|
component cannot see. It is not `Screen.ScrollShadow`'s `coverTop`: there is no
|
|
78
78
|
solid band here.
|
|
79
|
+
- **`keyboardAware` lifts the end band above an open keyboard.** A keyboard
|
|
80
|
+
covers a scrollable without resizing it, so a band pinned to the bottom of its
|
|
81
|
+
box sits under the keyboard. It is not only hidden there:
|
|
82
|
+
`KeyboardAwareScrollView` makes room with a content inset (its default
|
|
83
|
+
`insets` mode), and a scroll event leaves an inset out of `contentSize`, so a
|
|
84
|
+
form that fits the whole frame reads as too short to scroll and the end clears
|
|
85
|
+
for good. The lift is the keyboard less the bottom safe-area inset —
|
|
86
|
+
`resolveKeyboardLift`, the same distance a sticky `Screen.Footer` moves — and
|
|
87
|
+
one animated style spends it twice: as the band's `translateY`, and as
|
|
88
|
+
`coveredEnd`, so the opacity measures against the frame still visible. Off by
|
|
89
|
+
default; only a vertical end band moves. Only a keyboard-aware end edge
|
|
90
|
+
(`ScrollFadeKeyboardEdge`) calls keyboard-controller's hook, so a fade outside
|
|
91
|
+
a `KeyboardProvider` — a card row, a sheet — keeps working without its
|
|
92
|
+
warning.
|
|
79
93
|
- **`enabled={false}` draws nothing without unmounting**, so the scrollable keeps
|
|
80
94
|
its position and state when a fade is toggled.
|
|
81
95
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { LinearGradient } from "expo-linear-gradient";
|
|
2
2
|
import { memo, type ReactElement } from "react";
|
|
3
3
|
import { StyleSheet } from "react-native";
|
|
4
|
-
import
|
|
4
|
+
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
|
5
|
+
import Animated, { type SharedValue, useAnimatedStyle, useDerivedValue } from "react-native-reanimated";
|
|
6
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
5
7
|
import type { ScrollSource } from "../../hooks/use-scroll-source";
|
|
6
|
-
import { resolveFadeBand, resolveFadeGradient } from "../../lib/scroll-edges";
|
|
8
|
+
import { resolveFadeBand, resolveFadeGradient, resolveKeyboardLift } from "../../lib/scroll-edges";
|
|
7
9
|
import { type ScrollEdge, scrollEdgeOpacity } from "../../lib/scroll-maths";
|
|
8
10
|
import { scrollFadeVariants } from "./scroll-fade.variants";
|
|
9
11
|
|
|
@@ -19,6 +21,8 @@ export type ScrollFadeEdgeProps = {
|
|
|
19
21
|
color: string;
|
|
20
22
|
/** The same colour at zero alpha, for the far stop — never the `transparent` keyword. */
|
|
21
23
|
faded: string;
|
|
24
|
+
/** Points the band rises by, and the frame's end it treats as covered. See `resolveKeyboardLift`. */
|
|
25
|
+
lift?: SharedValue<number>;
|
|
22
26
|
};
|
|
23
27
|
|
|
24
28
|
/**
|
|
@@ -39,21 +43,25 @@ export const ScrollFadeEdge = memo(function ScrollFadeEdge({
|
|
|
39
43
|
fadeInDistance,
|
|
40
44
|
color,
|
|
41
45
|
faded,
|
|
46
|
+
lift,
|
|
42
47
|
}: ScrollFadeEdgeProps): ReactElement {
|
|
43
48
|
const { offset, contentSize, layoutSize, orientation } = source;
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
// One value spent twice, so the band's position and its opacity cannot disagree.
|
|
51
|
+
const opacityStyle = useAnimatedStyle(() => {
|
|
52
|
+
const covered = lift === undefined ? 0 : lift.value;
|
|
53
|
+
return {
|
|
47
54
|
opacity: scrollEdgeOpacity({
|
|
48
55
|
edge,
|
|
49
56
|
offset: offset.value,
|
|
50
57
|
contentSize: contentSize.value,
|
|
51
58
|
layoutSize: layoutSize.value,
|
|
52
59
|
fadeInDistance,
|
|
60
|
+
coveredEnd: covered,
|
|
53
61
|
}),
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
);
|
|
62
|
+
transform: [{ translateY: -covered }],
|
|
63
|
+
};
|
|
64
|
+
});
|
|
57
65
|
|
|
58
66
|
const band = resolveFadeBand({ orientation, edge, size, inset });
|
|
59
67
|
const { start, end } = resolveFadeGradient({ orientation, edge });
|
|
@@ -71,3 +79,25 @@ export const ScrollFadeEdge = memo(function ScrollFadeEdge({
|
|
|
71
79
|
);
|
|
72
80
|
});
|
|
73
81
|
ScrollFadeEdge.displayName = "DelacourUI.ScrollFade.Edge";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* An edge band that rises above an open keyboard.
|
|
85
|
+
*
|
|
86
|
+
* A separate component so the keyboard is read only where it is wanted. A plain
|
|
87
|
+
* edge never touches keyboard-controller, so a fade outside a
|
|
88
|
+
* `KeyboardProvider` — a card row, a sheet — keeps working without its warning.
|
|
89
|
+
*/
|
|
90
|
+
export const ScrollFadeKeyboardEdge = memo(function ScrollFadeKeyboardEdge(
|
|
91
|
+
props: Omit<ScrollFadeEdgeProps, "lift">
|
|
92
|
+
): ReactElement {
|
|
93
|
+
const { edge, source } = props;
|
|
94
|
+
const { orientation } = source;
|
|
95
|
+
const keyboard = useReanimatedKeyboardAnimation();
|
|
96
|
+
const { bottom: safeAreaBottom } = useSafeAreaInsets();
|
|
97
|
+
const lift = useDerivedValue(() =>
|
|
98
|
+
resolveKeyboardLift({ orientation, edge, keyboardHeight: keyboard.height.value, safeAreaBottom })
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return <ScrollFadeEdge {...props} lift={lift} />;
|
|
102
|
+
});
|
|
103
|
+
ScrollFadeKeyboardEdge.displayName = "DelacourUI.ScrollFade.KeyboardEdge";
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type ScrollFadeEdges,
|
|
11
11
|
scrollFadeVariants,
|
|
12
12
|
} from "./scroll-fade.variants";
|
|
13
|
-
import { ScrollFadeEdge } from "./scroll-fade-edge";
|
|
13
|
+
import { ScrollFadeEdge, ScrollFadeKeyboardEdge } from "./scroll-fade-edge";
|
|
14
14
|
|
|
15
15
|
export type ScrollFadeProps = Omit<ViewProps, "pointerEvents"> & {
|
|
16
16
|
/** The scrollable (and anything else) the fade overlays. Fades paint after it, so they sit on top. */
|
|
@@ -33,6 +33,14 @@ export type ScrollFadeProps = Omit<ViewProps, "pointerEvents"> & {
|
|
|
33
33
|
insetEnd?: number;
|
|
34
34
|
/** Explicit source; otherwise the nearest `ScrollSourceProvider` / the enclosing `Screen`. */
|
|
35
35
|
scrollSource?: ScrollSource;
|
|
36
|
+
/**
|
|
37
|
+
* Lift the end fade above an open keyboard. A keyboard covers the
|
|
38
|
+
* scrollable without resizing it, so without this the end fade sits under
|
|
39
|
+
* the keyboard. Turn it on over a keyboard-aware scrollable with a sticky
|
|
40
|
+
* `Screen.Footer`; the fade rises by the same distance the footer does.
|
|
41
|
+
* @default false
|
|
42
|
+
*/
|
|
43
|
+
keyboardAware?: boolean;
|
|
36
44
|
/** Draw nothing without unmounting. @default true */
|
|
37
45
|
enabled?: boolean;
|
|
38
46
|
className?: string;
|
|
@@ -83,6 +91,7 @@ export function ScrollFade({
|
|
|
83
91
|
insetStart = 0,
|
|
84
92
|
insetEnd = 0,
|
|
85
93
|
scrollSource,
|
|
94
|
+
keyboardAware = false,
|
|
86
95
|
enabled = true,
|
|
87
96
|
className,
|
|
88
97
|
...props
|
|
@@ -95,6 +104,8 @@ export function ScrollFade({
|
|
|
95
104
|
// Without a colour to fade from there is no honest gradient, and the keyword
|
|
96
105
|
// `transparent` is no substitute — so the content renders alone.
|
|
97
106
|
const canDraw = paint !== undefined && faded !== undefined;
|
|
107
|
+
// Only a keyboard-aware end reads the keyboard; see `ScrollFadeKeyboardEdge`.
|
|
108
|
+
const EndEdge = keyboardAware ? ScrollFadeKeyboardEdge : ScrollFadeEdge;
|
|
98
109
|
|
|
99
110
|
return (
|
|
100
111
|
<View className={scrollFadeVariants().root({ className })} {...props}>
|
|
@@ -111,7 +122,7 @@ export function ScrollFade({
|
|
|
111
122
|
/>
|
|
112
123
|
) : null}
|
|
113
124
|
{canDraw && draw.end ? (
|
|
114
|
-
<
|
|
125
|
+
<EndEdge
|
|
115
126
|
color={paint}
|
|
116
127
|
edge="end"
|
|
117
128
|
fadeInDistance={fadeInDistance}
|
|
@@ -91,8 +91,7 @@ export function ScrollHeaderRoot({
|
|
|
91
91
|
if (target === null) return;
|
|
92
92
|
scrollTo(scrollRef, 0, target, !isCalm);
|
|
93
93
|
snapTarget.value = null;
|
|
94
|
-
}
|
|
95
|
-
[isCalm, scrollRef]
|
|
94
|
+
}
|
|
96
95
|
);
|
|
97
96
|
|
|
98
97
|
const handleCollapsedChange = useCallback(
|
|
@@ -111,16 +110,14 @@ export function ScrollHeaderRoot({
|
|
|
111
110
|
if (next === previous) return;
|
|
112
111
|
if (previous === null && !next) return;
|
|
113
112
|
scheduleOnRN(handleCollapsedChange, next);
|
|
114
|
-
}
|
|
115
|
-
[handleCollapsedChange]
|
|
113
|
+
}
|
|
116
114
|
);
|
|
117
115
|
|
|
118
116
|
useAnimatedReaction(
|
|
119
117
|
() => progress.value,
|
|
120
118
|
(value) => {
|
|
121
119
|
if (progressOutput) progressOutput.value = value;
|
|
122
|
-
}
|
|
123
|
-
[progressOutput]
|
|
120
|
+
}
|
|
124
121
|
);
|
|
125
122
|
|
|
126
123
|
const registerLarge = useCallback(() => {
|
|
@@ -42,14 +42,11 @@ export function useScrollHeaderScrollProps(
|
|
|
42
42
|
const forward = callerFunction ?? noop;
|
|
43
43
|
const hasFunction = callerFunction !== null;
|
|
44
44
|
|
|
45
|
-
const forwarder = useAnimatedScrollHandler(
|
|
46
|
-
{
|
|
47
|
-
|
|
48
|
-
if (hasFunction) scheduleOnRN(forward, event as NativeScrollEvent);
|
|
49
|
-
},
|
|
45
|
+
const forwarder = useAnimatedScrollHandler({
|
|
46
|
+
onScroll: (event) => {
|
|
47
|
+
if (hasFunction) scheduleOnRN(forward, event as NativeScrollEvent);
|
|
50
48
|
},
|
|
51
|
-
|
|
52
|
-
);
|
|
49
|
+
});
|
|
53
50
|
|
|
54
51
|
const composed = useComposedEventHandler([scrollProps.onScroll, callerHandler, hasFunction ? forwarder : null]);
|
|
55
52
|
|
|
@@ -169,8 +169,7 @@ function SectionProgressRoot(props: SectionProgressProps): ReactElement {
|
|
|
169
169
|
} else {
|
|
170
170
|
ringProgress.value = withTiming(next, RING_SETTLE);
|
|
171
171
|
}
|
|
172
|
-
}
|
|
173
|
-
[source]
|
|
172
|
+
}
|
|
174
173
|
);
|
|
175
174
|
|
|
176
175
|
const scrollY = scroll?.scrollY;
|
|
@@ -179,8 +178,7 @@ function SectionProgressRoot(props: SectionProgressProps): ReactElement {
|
|
|
179
178
|
() => (scrollY === undefined ? true : resolveRevealed(scrollY.value, revealAt, false)),
|
|
180
179
|
(now, was) => {
|
|
181
180
|
if (now && was !== true) scheduleOnRN(setRevealed, true);
|
|
182
|
-
}
|
|
183
|
-
[scrollY, revealAt]
|
|
181
|
+
}
|
|
184
182
|
);
|
|
185
183
|
|
|
186
184
|
// The percent only crosses to React while a screen reader is listening; with
|
|
@@ -196,8 +194,7 @@ function SectionProgressRoot(props: SectionProgressProps): ReactElement {
|
|
|
196
194
|
() => (isScreenReaderOn ? resolveProgressPercent(source.value) : -1),
|
|
197
195
|
(next, previous) => {
|
|
198
196
|
if (next >= 0 && next !== previous) scheduleOnRN(setPercent, next);
|
|
199
|
-
}
|
|
200
|
-
[isScreenReaderOn, source]
|
|
197
|
+
}
|
|
201
198
|
);
|
|
202
199
|
|
|
203
200
|
const lastValue = useRef(value);
|
|
@@ -214,8 +214,7 @@ export function SliderTrack({ children, className, ...props }: SliderTrackProps)
|
|
|
214
214
|
if (!changed) return;
|
|
215
215
|
}
|
|
216
216
|
scheduleOnRN(commitValues, [...current]);
|
|
217
|
-
}
|
|
218
|
-
[commitValues]
|
|
217
|
+
}
|
|
219
218
|
);
|
|
220
219
|
|
|
221
220
|
const slots = sliderVariants({ color, isDisabled, isInvalid, orientation, size });
|
|
@@ -77,8 +77,7 @@ export function SortableItem({
|
|
|
77
77
|
() => activeId.value === id,
|
|
78
78
|
(next, previous) => {
|
|
79
79
|
if (previous !== null && next !== previous) scheduleOnRN(setLifted, next);
|
|
80
|
-
}
|
|
81
|
-
[id]
|
|
80
|
+
}
|
|
82
81
|
);
|
|
83
82
|
|
|
84
83
|
const shift = useSharedValue(0);
|
|
@@ -88,8 +87,7 @@ export function SortableItem({
|
|
|
88
87
|
if (next === previous) return;
|
|
89
88
|
if (phase.value === 0 || isReducedMotion) shift.value = next;
|
|
90
89
|
else shift.value = withSpring(next, SORTABLE_SHIFT_SPRING);
|
|
91
|
-
}
|
|
92
|
-
[id, isReducedMotion]
|
|
90
|
+
}
|
|
93
91
|
);
|
|
94
92
|
|
|
95
93
|
const animatedStyle = useAnimatedStyle(() => {
|
|
@@ -54,10 +54,7 @@ export function TabsSeparator({
|
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
const fadeStyle = useAnimatedStyle(
|
|
58
|
-
() => ({ opacity: resolveSeparatorOpacity(position.value, left, right) }),
|
|
59
|
-
[left, right]
|
|
60
|
-
);
|
|
57
|
+
const fadeStyle = useAnimatedStyle(() => ({ opacity: resolveSeparatorOpacity(position.value, left, right) }));
|
|
61
58
|
|
|
62
59
|
const slots = tabsVariants({ size, variant });
|
|
63
60
|
|
|
@@ -216,8 +216,7 @@ export function TextAnimationTyping({
|
|
|
216
216
|
if (previous !== null && previous.phase === "typing" && current.phase !== "typing") {
|
|
217
217
|
scheduleOnRN(handleTyped, previous.phraseIndex, runKey);
|
|
218
218
|
}
|
|
219
|
-
}
|
|
220
|
-
[runKey, handlePhrase, handleTyped]
|
|
219
|
+
}
|
|
221
220
|
);
|
|
222
221
|
|
|
223
222
|
useEffect(() => {
|
|
@@ -72,8 +72,7 @@ function ReadingEdgeTitle({
|
|
|
72
72
|
const isFirstRun = previous === null;
|
|
73
73
|
progress.value =
|
|
74
74
|
isFirstRun || isReducedMotion ? target : withTiming(target, { duration: TIMELINE_READING_EDGE_MS });
|
|
75
|
-
}
|
|
76
|
-
[step, isReducedMotion]
|
|
75
|
+
}
|
|
77
76
|
);
|
|
78
77
|
|
|
79
78
|
const colorStyle = useAnimatedStyle(() => ({
|
|
@@ -37,18 +37,15 @@ export function useTimelineReadingEdge({
|
|
|
37
37
|
[onColumnChange]
|
|
38
38
|
);
|
|
39
39
|
|
|
40
|
-
const scrollHandler = useAnimatedScrollHandler(
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (hasListener) scheduleOnRN(notify, next);
|
|
48
|
-
},
|
|
40
|
+
const scrollHandler = useAnimatedScrollHandler({
|
|
41
|
+
onScroll: (event) => {
|
|
42
|
+
const next = resolveColumnAtOffset(offsets, event.contentOffset.x);
|
|
43
|
+
if (next === activeColumn.value) return;
|
|
44
|
+
activeColumn.value = next;
|
|
45
|
+
if (haptic) playHaptic("selection");
|
|
46
|
+
if (hasListener) scheduleOnRN(notify, next);
|
|
49
47
|
},
|
|
50
|
-
|
|
51
|
-
);
|
|
48
|
+
});
|
|
52
49
|
|
|
53
50
|
return { activeColumn, scrollHandler };
|
|
54
51
|
}
|
|
@@ -80,33 +80,30 @@ export function useScrollSource(options: UseScrollSourceOptions = {}): UseScroll
|
|
|
80
80
|
const contentSize = useSharedValue(0);
|
|
81
81
|
const layoutSize = useSharedValue(0);
|
|
82
82
|
|
|
83
|
-
const onScroll = useAnimatedScrollHandler(
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
},
|
|
83
|
+
const onScroll = useAnimatedScrollHandler({
|
|
84
|
+
onScroll: (event) => {
|
|
85
|
+
const reading = readScrollEvent(event, orientation);
|
|
86
|
+
offset.value = reading.offset;
|
|
87
|
+
if (reading.contentSize !== contentSize.value) contentSize.value = reading.contentSize;
|
|
88
|
+
if (reading.layoutSize !== layoutSize.value) layoutSize.value = reading.layoutSize;
|
|
107
89
|
},
|
|
108
|
-
|
|
109
|
-
|
|
90
|
+
onBeginDrag: (event) => {
|
|
91
|
+
offset.value = readScrollEvent(event, orientation).offset;
|
|
92
|
+
onBeginDrag?.(event);
|
|
93
|
+
},
|
|
94
|
+
onEndDrag: (event) => {
|
|
95
|
+
offset.value = readScrollEvent(event, orientation).offset;
|
|
96
|
+
onEndDrag?.(event);
|
|
97
|
+
},
|
|
98
|
+
onMomentumBegin: (event) => {
|
|
99
|
+
offset.value = readScrollEvent(event, orientation).offset;
|
|
100
|
+
onMomentumBegin?.(event);
|
|
101
|
+
},
|
|
102
|
+
onMomentumEnd: (event) => {
|
|
103
|
+
offset.value = readScrollEvent(event, orientation).offset;
|
|
104
|
+
onMomentumEnd?.(event);
|
|
105
|
+
},
|
|
106
|
+
});
|
|
110
107
|
|
|
111
108
|
const onLayout = useCallback(
|
|
112
109
|
(event: LayoutChangeEvent) => {
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
resolveFadeBand,
|
|
4
|
+
resolveFadeEdges,
|
|
5
|
+
resolveFadeGradient,
|
|
6
|
+
resolveKeyboardLift,
|
|
7
|
+
SCROLL_EDGES,
|
|
8
|
+
type ScrollEdges,
|
|
9
|
+
} from "./scroll-edges";
|
|
3
10
|
|
|
4
11
|
describe("resolveFadeEdges", () => {
|
|
5
12
|
const EXPECTED: Record<ScrollEdges, { start: boolean; end: boolean }> = {
|
|
@@ -75,3 +82,31 @@ describe("resolveFadeGradient", () => {
|
|
|
75
82
|
});
|
|
76
83
|
});
|
|
77
84
|
});
|
|
85
|
+
|
|
86
|
+
describe("resolveKeyboardLift", () => {
|
|
87
|
+
const open = { orientation: "vertical", edge: "end", safeAreaBottom: 34 } as const;
|
|
88
|
+
|
|
89
|
+
test("lifts a vertical end band by the keyboard less the safe-area band it covers", () => {
|
|
90
|
+
expect(resolveKeyboardLift({ ...open, keyboardHeight: 336 })).toBe(302);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("reads keyboard-controller's negative height the same as a positive one", () => {
|
|
94
|
+
expect(resolveKeyboardLift({ ...open, keyboardHeight: -336 })).toBe(302);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("does not lift with the keyboard closed", () => {
|
|
98
|
+
expect(resolveKeyboardLift({ ...open, keyboardHeight: 0 })).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("never lifts below zero while the keyboard is shorter than the safe area", () => {
|
|
102
|
+
expect(resolveKeyboardLift({ ...open, keyboardHeight: 20 })).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("leaves a start band in place", () => {
|
|
106
|
+
expect(resolveKeyboardLift({ ...open, edge: "start", keyboardHeight: 336 })).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("leaves a horizontal band in place", () => {
|
|
110
|
+
expect(resolveKeyboardLift({ ...open, orientation: "horizontal", keyboardHeight: 336 })).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/lib/scroll-edges.ts
CHANGED
|
@@ -79,3 +79,26 @@ export function resolveFadeGradient({
|
|
|
79
79
|
|
|
80
80
|
return { start: { x: near, y: 0 }, end: { x: far, y: 0 } };
|
|
81
81
|
}
|
|
82
|
+
|
|
83
|
+
export type KeyboardLiftInput = {
|
|
84
|
+
orientation: ScrollOrientation;
|
|
85
|
+
edge: ScrollEdge;
|
|
86
|
+
/** The keyboard's height. keyboard-controller reports it negative while open; either sign reads the same. */
|
|
87
|
+
keyboardHeight: number;
|
|
88
|
+
/** The bottom safe-area inset, which an open keyboard covers. */
|
|
89
|
+
safeAreaBottom: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How far a band rises to stay above an open keyboard.
|
|
94
|
+
*
|
|
95
|
+
* A keyboard covers the scrollable without resizing it, so a band pinned to
|
|
96
|
+
* the bottom of its box ends up underneath. Only a vertical end band is
|
|
97
|
+
* there. It rises by the keyboard less the safe-area band, the same distance
|
|
98
|
+
* a sticky `Screen.Footer` moves, so the band stays on the footer's top edge.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveKeyboardLift({ orientation, edge, keyboardHeight, safeAreaBottom }: KeyboardLiftInput): number {
|
|
101
|
+
"worklet";
|
|
102
|
+
if (orientation !== "vertical" || edge !== "end") return 0;
|
|
103
|
+
return Math.max(0, Math.abs(keyboardHeight) - safeAreaBottom);
|
|
104
|
+
}
|
|
@@ -147,3 +147,30 @@ describe("readScrollEvent", () => {
|
|
|
147
147
|
expect(readScrollEvent(bounce, "horizontal").offset).toBe(-40);
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
describe("scrollEdgeOpacity with a covered end", () => {
|
|
152
|
+
// A 600pt frame holding 500pt of form, with 300pt of keyboard over its end.
|
|
153
|
+
// KeyboardAwareScrollView makes room with a content inset, which the scroll
|
|
154
|
+
// event leaves out of contentSize.
|
|
155
|
+
const base = { edge: "end", contentSize: 500, layoutSize: 600, fadeInDistance: 24 } as const;
|
|
156
|
+
|
|
157
|
+
test("clears the end of content that fits the uncovered frame", () => {
|
|
158
|
+
expect(scrollEdgeOpacity({ ...base, offset: 0 })).toBe(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("draws the end while content runs on under the covered band", () => {
|
|
162
|
+
expect(scrollEdgeOpacity({ ...base, offset: 0, coveredEnd: 300 })).toBe(1);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("clears the end once the last content is scrolled above the covered band", () => {
|
|
166
|
+
expect(scrollEdgeOpacity({ ...base, offset: 200, coveredEnd: 300 })).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("fades the end over the last fadeInDistance points above the covered band", () => {
|
|
170
|
+
expect(scrollEdgeOpacity({ ...base, offset: 188, coveredEnd: 300 })).toBe(0.5);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("leaves the start edge reading the offset alone", () => {
|
|
174
|
+
expect(scrollEdgeOpacity({ ...base, edge: "start", offset: 12, coveredEnd: 300 })).toBe(0.5);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/lib/scroll-maths.ts
CHANGED
|
@@ -59,6 +59,14 @@ export type ScrollEdgeState = {
|
|
|
59
59
|
layoutSize: number;
|
|
60
60
|
/** Points of travel from fully clear to fully in. 0 makes it a step. */
|
|
61
61
|
fadeInDistance: number;
|
|
62
|
+
/**
|
|
63
|
+
* Points at the end of the frame covered by something drawn over it — an
|
|
64
|
+
* open keyboard. `KeyboardAwareScrollView` makes room for the keyboard with
|
|
65
|
+
* a content inset, which the scroll event leaves out of `contentSize`, so
|
|
66
|
+
* content that fits the whole frame can still run on under the cover.
|
|
67
|
+
* @default 0
|
|
68
|
+
*/
|
|
69
|
+
coveredEnd?: number;
|
|
62
70
|
};
|
|
63
71
|
|
|
64
72
|
/**
|
|
@@ -77,9 +85,10 @@ export type ScrollEdgeState = {
|
|
|
77
85
|
*/
|
|
78
86
|
export function scrollEdgeOpacity(state: ScrollEdgeState): number {
|
|
79
87
|
"worklet";
|
|
80
|
-
const { edge, offset, contentSize, layoutSize, fadeInDistance } = state;
|
|
88
|
+
const { edge, offset, contentSize, layoutSize, fadeInDistance, coveredEnd = 0 } = state;
|
|
81
89
|
const isMeasured = contentSize > 0 && layoutSize > 0;
|
|
82
|
-
|
|
90
|
+
// Measured against the part of the frame that is still visible.
|
|
91
|
+
const maxScroll = contentSize - (layoutSize - coveredEnd);
|
|
83
92
|
|
|
84
93
|
if (isMeasured && maxScroll <= 0) return 0;
|
|
85
94
|
|