react-native-terra-ui 0.11.10 → 0.11.12
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/CHANGELOG.md +17 -0
- package/lib/module/components/box/Box.js +35 -10
- package/lib/module/components/box/Box.js.map +1 -1
- package/lib/module/components/header/hooks/use-header-color.js +16 -0
- package/lib/module/components/header/hooks/use-header-color.js.map +1 -0
- package/lib/module/components/header/hooks/use-large-title-header.js +81 -0
- package/lib/module/components/header/hooks/use-large-title-header.js.map +1 -0
- package/lib/module/components/header/parts/HeaderAccessory.js +21 -6
- package/lib/module/components/header/parts/HeaderAccessory.js.map +1 -1
- package/lib/module/components/header/parts/HeaderBar.js +122 -0
- package/lib/module/components/header/parts/HeaderBar.js.map +1 -0
- package/lib/module/components/header/parts/HeaderGradientOverlay.js +20 -76
- package/lib/module/components/header/parts/HeaderGradientOverlay.js.map +1 -1
- package/lib/module/components/header/parts/HeaderSurface.js +16 -37
- package/lib/module/components/header/parts/HeaderSurface.js.map +1 -1
- package/lib/module/components/header/variants/LargeTitleHeader/index.js +51 -388
- package/lib/module/components/header/variants/LargeTitleHeader/index.js.map +1 -1
- package/lib/module/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.js +72 -0
- package/lib/module/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.js.map +1 -0
- package/lib/module/components/header/variants/TitleHeader/index.js +32 -161
- package/lib/module/components/header/variants/TitleHeader/index.js.map +1 -1
- package/lib/module/components/surface/Surface.js +2 -0
- package/lib/module/components/surface/Surface.js.map +1 -1
- package/lib/typescript/src/components/box/Box.d.ts +28 -4
- package/lib/typescript/src/components/box/Box.d.ts.map +1 -1
- package/lib/typescript/src/components/header/hooks/use-header-color.d.ts +4 -0
- package/lib/typescript/src/components/header/hooks/use-header-color.d.ts.map +1 -0
- package/lib/typescript/src/components/header/hooks/use-large-title-header.d.ts +36 -0
- package/lib/typescript/src/components/header/hooks/use-large-title-header.d.ts.map +1 -0
- package/lib/typescript/src/components/header/parts/HeaderAccessory.d.ts +8 -5
- package/lib/typescript/src/components/header/parts/HeaderAccessory.d.ts.map +1 -1
- package/lib/typescript/src/components/header/parts/HeaderBar.d.ts +17 -0
- package/lib/typescript/src/components/header/parts/HeaderBar.d.ts.map +1 -0
- package/lib/typescript/src/components/header/parts/HeaderGradientOverlay.d.ts +12 -15
- package/lib/typescript/src/components/header/parts/HeaderGradientOverlay.d.ts.map +1 -1
- package/lib/typescript/src/components/header/parts/HeaderSurface.d.ts +8 -9
- package/lib/typescript/src/components/header/parts/HeaderSurface.d.ts.map +1 -1
- package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts +3 -23
- package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts.map +1 -1
- package/lib/typescript/src/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.d.ts +11 -0
- package/lib/typescript/src/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.d.ts.map +1 -0
- package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts +3 -19
- package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts.map +1 -1
- package/lib/typescript/src/components/surface/Surface.d.ts +1 -1
- package/lib/typescript/src/components/surface/Surface.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/Toast.d.ts +1 -1
- package/lib/typescript/src/components/toast/Toast.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/box/Box.tsx +59 -16
- package/src/components/header/hooks/use-header-color.ts +14 -0
- package/src/components/header/hooks/use-large-title-header.ts +78 -0
- package/src/components/header/parts/HeaderAccessory.tsx +26 -7
- package/src/components/header/parts/HeaderBar.tsx +125 -0
- package/src/components/header/parts/HeaderGradientOverlay.tsx +18 -70
- package/src/components/header/parts/HeaderSurface.tsx +16 -35
- package/src/components/header/variants/LargeTitleHeader/index.tsx +44 -371
- package/src/components/header/variants/LargeTitleHeader/parts/LargeTitleContent.tsx +88 -0
- package/src/components/header/variants/TitleHeader/index.tsx +32 -149
- package/src/components/surface/Surface.tsx +2 -1
|
@@ -3,91 +3,39 @@ import { useId } from "react";
|
|
|
3
3
|
import Svg, { Defs, Rect, Stop, LinearGradient as SvgLinearGradient } from "react-native-svg";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the curve has to be steep enough to read as a band. Overhanging lets the tail
|
|
10
|
-
* finish in open space, so the header releases content instead of cutting it.
|
|
11
|
-
*/
|
|
12
|
-
export const HEADER_FADE_EXTENT = 8;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Where the fade begins, as a fraction of the overlay's height — the bar plus
|
|
16
|
-
* {@link HEADER_FADE_EXTENT}.
|
|
17
|
-
*
|
|
18
|
-
* Sits inside the bar rather than at its bottom edge, so the tint has already
|
|
19
|
-
* begun thinning before content reaches the boundary. That is the trade this
|
|
20
|
-
* value makes: a longer, gentler ramp, paid for with content being faintly
|
|
21
|
-
* visible through the lower part of the bar.
|
|
6
|
+
* Opacity at the overlay's bottom edge. The top is fully opaque, so the header
|
|
7
|
+
* thins slightly toward the boundary rather than cutting content off square —
|
|
8
|
+
* but never far enough to read as see-through.
|
|
22
9
|
*/
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Stops used to approximate the fade curve. Below ~10 the ramp shows as a series
|
|
27
|
-
* of bands on an 8-bit display; above ~14 the extra stops cost SVG parse time
|
|
28
|
-
* with nothing visible in return.
|
|
29
|
-
*/
|
|
30
|
-
const FADE_STOPS = 4;
|
|
31
|
-
|
|
32
|
-
/** Ceiling on the fade's opacity — see {@link alphaAt}. */
|
|
33
|
-
const MAX_FADE_ALPHA = 0.65;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Raised cosine, capped at {@link MAX_FADE_ALPHA}, reaching 0 at the overlay's
|
|
37
|
-
* bottom.
|
|
38
|
-
*
|
|
39
|
-
* The curve's slope is zero at both ends, so neither the start of the fade nor
|
|
40
|
-
* its finish leaves a seam. A two-stop linear ramp has a constant slope that
|
|
41
|
-
* begins and ends abruptly, and those two hard edges are what read as a line
|
|
42
|
-
* drawn across the content rather than the header letting go of it.
|
|
43
|
-
*
|
|
44
|
-
* The cap holds the ramp just short of solid, so the whole overlay reads as one
|
|
45
|
-
* continuous gradient rather than an opaque plateau with a curve bolted on.
|
|
46
|
-
*/
|
|
47
|
-
const alphaAt = (t: number) => (1 + Math.cos(Math.PI * t) * MAX_FADE_ALPHA) / 2;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Every input is a module constant, so the ramp is built once at load rather
|
|
51
|
-
* than rebuilt per render.
|
|
52
|
-
*
|
|
53
|
-
* The leading stop belongs to this list rather than being a sibling element:
|
|
54
|
-
* `LinearGradient` types its children as a flat `ReactElement[]`, so an element
|
|
55
|
-
* beside a mapped array would nest and fail to type-check.
|
|
56
|
-
*/
|
|
57
|
-
const STOPS = [
|
|
58
|
-
{ offset: 0, opacity: 1 },
|
|
59
|
-
...Array.from({ length: FADE_STOPS }, (_, index) => {
|
|
60
|
-
const t = index / (FADE_STOPS - 1);
|
|
61
|
-
const offset = FADE_START + t * (1 - FADE_START);
|
|
62
|
-
return { offset, opacity: offset === 1 ? 0 : alphaAt(t) };
|
|
63
|
-
}),
|
|
64
|
-
];
|
|
10
|
+
export const HEADER_FADE_END_OPACITY = 0.85;
|
|
65
11
|
|
|
66
12
|
export interface HeaderGradientOverlayProps {
|
|
67
|
-
/** Fill color,
|
|
13
|
+
/** Fill color, ramped from opaque to {@link HEADER_FADE_END_OPACITY}. */
|
|
68
14
|
color: string;
|
|
15
|
+
startOpacity?: number;
|
|
16
|
+
endOpacity?: number;
|
|
69
17
|
}
|
|
70
18
|
|
|
71
19
|
/**
|
|
72
|
-
* Vertical fade
|
|
73
|
-
*
|
|
74
|
-
* Reaches zero rather than stopping short. An overlay ending at partial opacity
|
|
75
|
-
* leaves a hard step wherever it is clipped, and that step is far more visible
|
|
76
|
-
* than the fade it was meant to hide — it is what cuts content off square as it
|
|
77
|
-
* scrolls under the header.
|
|
20
|
+
* Vertical fade behind header content, shared by header variants.
|
|
78
21
|
*
|
|
79
|
-
*
|
|
22
|
+
* A two-stop linear ramp spanning the overlay's own box: it neither overhangs
|
|
23
|
+
* the surface nor reserves a band inside it, so callers size the surface and
|
|
24
|
+
* the ramp simply spans whatever they gave it.
|
|
80
25
|
*/
|
|
81
|
-
export function HeaderGradientOverlay({
|
|
26
|
+
export function HeaderGradientOverlay({
|
|
27
|
+
color,
|
|
28
|
+
startOpacity = 1,
|
|
29
|
+
endOpacity = HEADER_FADE_END_OPACITY,
|
|
30
|
+
}: HeaderGradientOverlayProps) {
|
|
82
31
|
const gradientId = useId();
|
|
83
32
|
|
|
84
33
|
return (
|
|
85
34
|
<Svg height="100%" width="100%">
|
|
86
35
|
<Defs>
|
|
87
36
|
<SvgLinearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
88
|
-
{
|
|
89
|
-
|
|
90
|
-
))}
|
|
37
|
+
<Stop offset={0} stopColor={color} stopOpacity={startOpacity} />
|
|
38
|
+
<Stop offset={1} stopColor={color} stopOpacity={endOpacity} />
|
|
91
39
|
</SvgLinearGradient>
|
|
92
40
|
</Defs>
|
|
93
41
|
<Rect width="100%" height="100%" fill={`url(#${gradientId})`} />
|
|
@@ -1,62 +1,43 @@
|
|
|
1
|
-
import { View } from "react-native";
|
|
2
|
-
|
|
3
1
|
import Animated, { type useAnimatedStyle } from "react-native-reanimated";
|
|
4
2
|
import { StyleSheet } from "react-native-unistyles";
|
|
5
3
|
|
|
6
|
-
import {
|
|
4
|
+
import { HEADER_FADE_END_OPACITY, HeaderGradientOverlay } from "./HeaderGradientOverlay";
|
|
7
5
|
|
|
8
6
|
type HeaderOpacityStyle = ReturnType<typeof useAnimatedStyle<{ opacity: number }>>;
|
|
9
7
|
|
|
10
8
|
export interface HeaderSurfaceProps {
|
|
11
|
-
/** Fill color,
|
|
9
|
+
/** Fill color, ramped toward the surface's bottom edge. */
|
|
12
10
|
color: string;
|
|
13
11
|
/** Opacity style for variants that fade the whole surface in as they collapse. */
|
|
14
12
|
animatedStyle?: HeaderOpacityStyle;
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
* @defaultValue The themed bar height plus `HEADER_FADE_EXTENT`.
|
|
18
|
-
*/
|
|
19
|
-
fadeHeight?: number;
|
|
13
|
+
/** Continue the bar's gradient from 0.85 to 0.80 beneath an accessory. */
|
|
14
|
+
accessory?: boolean;
|
|
20
15
|
}
|
|
21
16
|
|
|
22
17
|
/**
|
|
23
|
-
* Header background with
|
|
24
|
-
*
|
|
18
|
+
* Header background with matching gradient boundaries for the bar and accessory.
|
|
19
|
+
*
|
|
20
|
+
* The ramp spans the surface itself: it is sized by the caller and stays inside
|
|
21
|
+
* those bounds, so a taller surface stretches the fade rather than adding to it.
|
|
25
22
|
*/
|
|
26
|
-
export function HeaderSurface({ color, animatedStyle,
|
|
23
|
+
export function HeaderSurface({ color, animatedStyle, accessory }: HeaderSurfaceProps) {
|
|
27
24
|
return (
|
|
28
25
|
<Animated.View style={[styles.surface, animatedStyle]} pointerEvents="none">
|
|
29
|
-
<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
<HeaderGradientOverlay
|
|
27
|
+
color={color}
|
|
28
|
+
startOpacity={accessory ? HEADER_FADE_END_OPACITY : 1}
|
|
29
|
+
endOpacity={accessory ? 0.8 : HEADER_FADE_END_OPACITY}
|
|
30
|
+
/>
|
|
33
31
|
</Animated.View>
|
|
34
32
|
);
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
const styles = StyleSheet.create((
|
|
38
|
-
// Extend the fade below the header's layout bounds.
|
|
35
|
+
const styles = StyleSheet.create(() => ({
|
|
39
36
|
surface: {
|
|
40
37
|
position: "absolute",
|
|
41
38
|
top: 0,
|
|
42
39
|
left: 0,
|
|
43
40
|
right: 0,
|
|
44
|
-
bottom: -HEADER_FADE_EXTENT,
|
|
45
|
-
},
|
|
46
|
-
// Fill the region above the gradient.
|
|
47
|
-
fill: (color: string, fadeHeight: number | undefined) => ({
|
|
48
|
-
position: "absolute",
|
|
49
|
-
top: 0,
|
|
50
|
-
left: 0,
|
|
51
|
-
right: 0,
|
|
52
|
-
bottom: fadeHeight ?? theme.layout.header.height + HEADER_FADE_EXTENT,
|
|
53
|
-
backgroundColor: color,
|
|
54
|
-
}),
|
|
55
|
-
fade: (fadeHeight: number | undefined) => ({
|
|
56
|
-
position: "absolute",
|
|
57
|
-
left: 0,
|
|
58
|
-
right: 0,
|
|
59
41
|
bottom: 0,
|
|
60
|
-
|
|
61
|
-
}),
|
|
42
|
+
},
|
|
62
43
|
}));
|
|
@@ -1,29 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { type LayoutChangeEvent, Text, View } from "react-native";
|
|
1
|
+
import { View } from "react-native";
|
|
3
2
|
|
|
4
|
-
import
|
|
5
|
-
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
3
|
+
import { StyleSheet } from "react-native-unistyles";
|
|
6
4
|
|
|
7
|
-
import { resolveThemeColor } from "#utils/resolve-theme-color";
|
|
8
|
-
import { FONT_WEIGHT_VALUE } from "#utils/typography";
|
|
9
5
|
import { Portal } from "../../../portal";
|
|
10
6
|
import { useHeaderBackground } from "../../../screen/parts/ScreenHeaderAppearance";
|
|
11
|
-
import { SCREEN_HEADER_PORTAL_HOST
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
7
|
+
import { SCREEN_HEADER_PORTAL_HOST } from "../../../screen/ScreenContext";
|
|
8
|
+
import { useHeaderColor } from "../../hooks/use-header-color";
|
|
9
|
+
import { useLargeTitleHeader } from "../../hooks/use-large-title-header";
|
|
14
10
|
import { HeaderAccessory, HeaderAccessorySpacer } from "../../parts/HeaderAccessory";
|
|
15
|
-
import {
|
|
16
|
-
import { HeaderSurface } from "../../parts/HeaderSurface";
|
|
11
|
+
import { HeaderBar } from "../../parts/HeaderBar";
|
|
17
12
|
import type { HeaderAccessoryBehavior } from "../../types";
|
|
18
|
-
import { isCustomTitle, isTextTitle } from "../../utils";
|
|
19
13
|
import type { TitleHeaderProps } from "../TitleHeader";
|
|
20
|
-
|
|
21
|
-
const AnimatedText = Animated.createAnimatedComponent(Text);
|
|
22
|
-
|
|
23
|
-
/** Compact title offset (px) when the large title is fully expanded. */
|
|
24
|
-
const COMPACT_TITLE_SLIDE_OFFSET = 8;
|
|
25
|
-
/** Large title offset (px) when fully collapsed. */
|
|
26
|
-
const LARGE_TITLE_SLIDE_OFFSET = 6;
|
|
14
|
+
import { LargeTitleContent } from "./parts/LargeTitleContent";
|
|
27
15
|
|
|
28
16
|
export interface LargeTitleHeaderProps extends TitleHeaderProps {
|
|
29
17
|
/** Optional small line shown above the large title. */
|
|
@@ -35,7 +23,7 @@ export interface LargeTitleHeaderProps extends TitleHeaderProps {
|
|
|
35
23
|
/**
|
|
36
24
|
* How `accessory` moves while scrolling. `'sticky'` starts below the large title
|
|
37
25
|
* and pins below the compact bar when scrolled up. A hidden large title always pins.
|
|
38
|
-
* @defaultValue '
|
|
26
|
+
* @defaultValue 'sticky'
|
|
39
27
|
*/
|
|
40
28
|
accessoryBehavior?: HeaderAccessoryBehavior;
|
|
41
29
|
/**
|
|
@@ -45,98 +33,7 @@ export interface LargeTitleHeaderProps extends TitleHeaderProps {
|
|
|
45
33
|
titleAlignment?: "center" | "left";
|
|
46
34
|
}
|
|
47
35
|
|
|
48
|
-
|
|
49
|
-
title,
|
|
50
|
-
overline,
|
|
51
|
-
subtitle,
|
|
52
|
-
hasAccessoryBelow,
|
|
53
|
-
onHeightChange,
|
|
54
|
-
}: Pick<LargeTitleHeaderProps, "title" | "overline" | "subtitle"> & {
|
|
55
|
-
/** Reserve a gap before a sticky accessory below this block. */
|
|
56
|
-
hasAccessoryBelow: boolean;
|
|
57
|
-
/** Reports the large-title block's layout height in dp. */
|
|
58
|
-
onHeightChange?: (height: number) => void;
|
|
59
|
-
}) {
|
|
60
|
-
const { theme } = useUnistyles();
|
|
61
|
-
const { scrollY } = useScreen();
|
|
62
|
-
|
|
63
|
-
const headerCollapseHeight = theme.layout.header.height;
|
|
64
|
-
|
|
65
|
-
const largeTitleAnimatedStyle = useAnimatedStyle(() => {
|
|
66
|
-
const height = headerCollapseHeight;
|
|
67
|
-
if (height <= 0) {
|
|
68
|
-
return { opacity: 1, transform: [{ translateY: 0 }] };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
opacity: interpolate(scrollY.value, [0, height], [1, 0], "clamp"),
|
|
73
|
-
transform: [
|
|
74
|
-
{
|
|
75
|
-
translateY: interpolate(scrollY.value, [0, height], [0, -LARGE_TITLE_SLIDE_OFFSET], "clamp"),
|
|
76
|
-
},
|
|
77
|
-
],
|
|
78
|
-
};
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
// Layout height excludes the collapse transform.
|
|
82
|
-
const handleLayout = (event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height);
|
|
83
|
-
|
|
84
|
-
return (
|
|
85
|
-
<Animated.View
|
|
86
|
-
style={[styles.portalContent(hasAccessoryBelow), largeTitleAnimatedStyle]}
|
|
87
|
-
onLayout={handleLayout}
|
|
88
|
-
>
|
|
89
|
-
{!!overline && (
|
|
90
|
-
<Text
|
|
91
|
-
style={styles.overline}
|
|
92
|
-
maxFontSizeMultiplier={theme.typography.variants["label-sm"].maxFontSizeMultiplier}
|
|
93
|
-
>
|
|
94
|
-
{overline}
|
|
95
|
-
</Text>
|
|
96
|
-
)}
|
|
97
|
-
{isTextTitle(title) ? (
|
|
98
|
-
<Text
|
|
99
|
-
style={styles.largeTitle}
|
|
100
|
-
maxFontSizeMultiplier={theme.typography.variants["headline-lg"].maxFontSizeMultiplier}
|
|
101
|
-
>
|
|
102
|
-
{title}
|
|
103
|
-
</Text>
|
|
104
|
-
) : (
|
|
105
|
-
title
|
|
106
|
-
)}
|
|
107
|
-
{!!subtitle && (
|
|
108
|
-
<Text
|
|
109
|
-
style={styles.subtitle}
|
|
110
|
-
maxFontSizeMultiplier={theme.typography.variants["body-lg"].maxFontSizeMultiplier}
|
|
111
|
-
>
|
|
112
|
-
{subtitle}
|
|
113
|
-
</Text>
|
|
114
|
-
)}
|
|
115
|
-
</Animated.View>
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* iOS-style collapsing large-title header. The large title is injected (via the
|
|
121
|
-
* `Screen`'s portal) into the top of the scroll content, so it scrolls away
|
|
122
|
-
* naturally; the compact title cross-fades over the theme header height.
|
|
123
|
-
* The top safe area and bar overlay fade from transparent when expanded to
|
|
124
|
-
* the header background when collapsed. The large-title content stays transparent.
|
|
125
|
-
* Snapping is opt-in through the scroll container's snapToOffsets.
|
|
126
|
-
*
|
|
127
|
-
* Must be used inside a `Screen` with a `Screen.ScrollView` or `Screen.List`.
|
|
128
|
-
*
|
|
129
|
-
* @example
|
|
130
|
-
* ```tsx
|
|
131
|
-
* <Screen.Header>
|
|
132
|
-
* <Header.LargeTitle
|
|
133
|
-
* overline="Today"
|
|
134
|
-
* title="Meditate"
|
|
135
|
-
* subtitle="Good morning"
|
|
136
|
-
* />
|
|
137
|
-
* </Screen.Header>
|
|
138
|
-
* ```
|
|
139
|
-
*/
|
|
36
|
+
/** Collapsing header. The expanded title lives in scroll content; the bar and accessory overlay it. */
|
|
140
37
|
export function LargeTitleHeader({
|
|
141
38
|
title,
|
|
142
39
|
overline,
|
|
@@ -148,205 +45,61 @@ export function LargeTitleHeader({
|
|
|
148
45
|
accessory,
|
|
149
46
|
accessoryPadding = "x",
|
|
150
47
|
accessoryBehavior = "sticky",
|
|
151
|
-
dismissAction = "none",
|
|
152
|
-
navigation,
|
|
153
|
-
onDismiss,
|
|
154
48
|
titleAlignment = "center",
|
|
155
49
|
isLargeTitleHidden = false,
|
|
156
50
|
background,
|
|
157
51
|
bg,
|
|
52
|
+
...dismissProps
|
|
158
53
|
}: LargeTitleHeaderProps) {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
(colorToken ? resolveThemeColor(colorToken, theme) : screenBackground) ?? theme.color["surface.canvas"];
|
|
165
|
-
const leadingContent = leadingActions !== undefined ? leadingActions : LeftToolbar;
|
|
166
|
-
const trailingContent = trailingActions !== undefined ? trailingActions : RightToolbar;
|
|
167
|
-
|
|
168
|
-
const gradientColor = bgColor;
|
|
169
|
-
const isSticky = accessoryBehavior === "sticky" && !isLargeTitleHidden;
|
|
170
|
-
const hasStickyAccessory = isSticky && accessory != null;
|
|
171
|
-
// Sticky accessories reserve space below the large title; pinned ones add top padding.
|
|
172
|
-
const [stickyAccessoryHeight, setStickyAccessoryHeight] = useState(0);
|
|
173
|
-
const [largeTitleHeight, setLargeTitleHeight] = useState(0);
|
|
174
|
-
const isLargeTitleHiddenValue = useSharedValue(isLargeTitleHidden);
|
|
175
|
-
|
|
176
|
-
const handleAccessoryHeightChange = useCallback(
|
|
177
|
-
(height: number) => {
|
|
178
|
-
if (isSticky) setStickyAccessoryHeight(Math.max(0, Math.round(height)));
|
|
179
|
-
else setHeaderAccessoryHeight(height);
|
|
180
|
-
},
|
|
181
|
-
[isSticky, setHeaderAccessoryHeight]
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
const handleLargeTitleHeightChange = useCallback((height: number) => {
|
|
185
|
-
setLargeTitleHeight(Math.max(0, Math.round(height)));
|
|
186
|
-
}, []);
|
|
187
|
-
|
|
188
|
-
// Rest below the title, including the scroll container's top padding.
|
|
189
|
-
const stickyAccessoryOffset = Math.max(
|
|
190
|
-
0,
|
|
191
|
-
contentTopPadding + largeTitleHeight - theme.layout.header.height
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
// Zero translation places the accessory directly below the compact bar.
|
|
195
|
-
const stickyAccessoryStyle = useAnimatedStyle(() => {
|
|
196
|
-
if (isLargeTitleHiddenValue.value) {
|
|
197
|
-
return { transform: [{ translateY: 0 }] };
|
|
198
|
-
}
|
|
199
|
-
return {
|
|
200
|
-
transform: [{ translateY: Math.max(0, stickyAccessoryOffset - scrollY.value) }],
|
|
201
|
-
};
|
|
54
|
+
const color = useHeaderColor(background ?? bg);
|
|
55
|
+
const layout = useLargeTitleHeader({
|
|
56
|
+
hasAccessory: accessory != null,
|
|
57
|
+
accessoryBehavior,
|
|
58
|
+
isLargeTitleHidden,
|
|
202
59
|
});
|
|
203
|
-
|
|
204
|
-
styles.useVariants({ titleAlignment });
|
|
205
|
-
|
|
206
|
-
// Follow the accessory's bottom edge while keeping the background anchored to the bar.
|
|
207
|
-
const stickySurfaceStyle = useAnimatedStyle(() => ({
|
|
208
|
-
height: headerCollapseHeight + stickyAccessoryHeight + Math.max(0, stickyAccessoryOffset - scrollY.value),
|
|
209
|
-
}));
|
|
210
|
-
|
|
211
|
-
// Blend the accessory's resting surface into the shared header background.
|
|
212
|
-
const accessorySurfaceStyle = useAnimatedStyle(() => ({
|
|
213
|
-
opacity:
|
|
214
|
-
stickyAccessoryOffset > 0 ? interpolate(scrollY.value, [0, stickyAccessoryOffset], [1, 0], "clamp") : 0,
|
|
215
|
-
}));
|
|
216
|
-
|
|
217
|
-
// Animate the gradient overlay's opacity: 0 (expanded) → 1 (collapsed).
|
|
218
|
-
const gradientOpacityStyle = useAnimatedStyle(() => {
|
|
219
|
-
if (isLargeTitleHiddenValue.value) {
|
|
220
|
-
return { opacity: 1 };
|
|
221
|
-
}
|
|
222
|
-
const height = hasStickyAccessory ? stickyAccessoryOffset : headerCollapseHeight;
|
|
223
|
-
if (height <= 0) {
|
|
224
|
-
return { opacity: hasStickyAccessory ? 1 : 0 };
|
|
225
|
-
}
|
|
226
|
-
return {
|
|
227
|
-
opacity: interpolate(scrollY.value, [0, height], [0, 1], "clamp"),
|
|
228
|
-
};
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
useHeaderBackground(bgColor, gradientOpacityStyle);
|
|
232
|
-
|
|
233
|
-
const compactTitleAnimatedStyle = useAnimatedStyle(() => {
|
|
234
|
-
if (isLargeTitleHiddenValue.value) {
|
|
235
|
-
return { opacity: 1, transform: [{ translateY: 0 }] };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const height = headerCollapseHeight;
|
|
239
|
-
if (height <= 0) {
|
|
240
|
-
return {
|
|
241
|
-
opacity: 0,
|
|
242
|
-
transform: [{ translateY: COMPACT_TITLE_SLIDE_OFFSET }],
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
opacity: interpolate(scrollY.value, [0, height], [0, 1], "clamp"),
|
|
248
|
-
transform: [
|
|
249
|
-
{
|
|
250
|
-
translateY: interpolate(scrollY.value, [0, height], [COMPACT_TITLE_SLIDE_OFFSET, 0], "clamp"),
|
|
251
|
-
},
|
|
252
|
-
],
|
|
253
|
-
};
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
useEffect(() => {
|
|
257
|
-
isLargeTitleHiddenValue.value = isLargeTitleHidden;
|
|
258
|
-
}, [isLargeTitleHidden, isLargeTitleHiddenValue]);
|
|
259
|
-
|
|
260
|
-
const leading =
|
|
261
|
-
dismissAction === "back" ? (
|
|
262
|
-
<>
|
|
263
|
-
<HeaderDismissButton dismissAction={dismissAction} navigation={navigation} onDismiss={onDismiss} />
|
|
264
|
-
{leadingContent}
|
|
265
|
-
</>
|
|
266
|
-
) : (
|
|
267
|
-
leadingContent
|
|
268
|
-
);
|
|
269
|
-
|
|
270
|
-
const trailing =
|
|
271
|
-
dismissAction === "close" ? (
|
|
272
|
-
<>
|
|
273
|
-
{trailingContent}
|
|
274
|
-
<HeaderDismissButton dismissAction={dismissAction} navigation={navigation} onDismiss={onDismiss} />
|
|
275
|
-
</>
|
|
276
|
-
) : (
|
|
277
|
-
trailingContent
|
|
278
|
-
);
|
|
279
|
-
|
|
60
|
+
useHeaderBackground(color, layout.surfaceStyle);
|
|
280
61
|
return (
|
|
281
|
-
|
|
282
|
-
<View style={styles.container(isSticky ? stickyAccessoryOffset : 0)} pointerEvents="box-none">
|
|
62
|
+
<View style={styles.container(layout.accessoryOffset)} pointerEvents="box-none">
|
|
283
63
|
{!isLargeTitleHidden && (
|
|
284
64
|
<Portal hostName={SCREEN_HEADER_PORTAL_HOST}>
|
|
285
|
-
<
|
|
65
|
+
<LargeTitleContent
|
|
286
66
|
title={title}
|
|
287
67
|
overline={overline}
|
|
288
68
|
subtitle={subtitle}
|
|
289
|
-
hasAccessoryBelow={
|
|
290
|
-
onHeightChange={
|
|
69
|
+
hasAccessoryBelow={layout.isSticky}
|
|
70
|
+
onHeightChange={layout.setLargeTitleHeight}
|
|
71
|
+
animatedStyle={layout.largeTitleStyle}
|
|
291
72
|
/>
|
|
292
|
-
{
|
|
293
|
-
{isSticky && accessory != null && <HeaderAccessorySpacer height={stickyAccessoryHeight} />}
|
|
73
|
+
{layout.isSticky && <HeaderAccessorySpacer height={layout.reservedAccessoryHeight} />}
|
|
294
74
|
</Portal>
|
|
295
75
|
)}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
>
|
|
317
|
-
{title}
|
|
318
|
-
</AnimatedText>
|
|
319
|
-
) : (
|
|
320
|
-
title
|
|
321
|
-
)}
|
|
322
|
-
</Animated.View>
|
|
323
|
-
{trailing != null && <Toolbar style={styles.trailing}>{trailing}</Toolbar>}
|
|
324
|
-
</View>
|
|
325
|
-
{accessory != null && !isSticky && (
|
|
326
|
-
<HeaderAccessory padding={accessoryPadding} onHeightChange={handleAccessoryHeightChange}>
|
|
327
|
-
{accessory}
|
|
328
|
-
</HeaderAccessory>
|
|
329
|
-
)}
|
|
330
|
-
</View>
|
|
331
|
-
{accessory != null && isSticky && (
|
|
332
|
-
<Animated.View style={[styles.stickyAccessory, stickyAccessoryStyle]}>
|
|
333
|
-
<HeaderSurface
|
|
334
|
-
color={screenBackground ?? theme.color["surface.canvas"]}
|
|
335
|
-
animatedStyle={accessorySurfaceStyle}
|
|
336
|
-
fadeHeight={HEADER_FADE_EXTENT}
|
|
337
|
-
/>
|
|
338
|
-
<HeaderAccessory padding={accessoryPadding} onHeightChange={handleAccessoryHeightChange}>
|
|
339
|
-
{accessory}
|
|
340
|
-
</HeaderAccessory>
|
|
341
|
-
</Animated.View>
|
|
76
|
+
<HeaderBar
|
|
77
|
+
{...dismissProps}
|
|
78
|
+
title={title}
|
|
79
|
+
titleAlignment={titleAlignment}
|
|
80
|
+
color={color}
|
|
81
|
+
surfaceStyle={layout.surfaceStyle}
|
|
82
|
+
titleStyle={layout.compactTitleStyle}
|
|
83
|
+
leading={leadingActions !== undefined ? leadingActions : LeftToolbar}
|
|
84
|
+
trailing={trailingActions !== undefined ? trailingActions : RightToolbar}
|
|
85
|
+
/>
|
|
86
|
+
{accessory != null && (
|
|
87
|
+
<HeaderAccessory
|
|
88
|
+
padding={accessoryPadding}
|
|
89
|
+
color={color}
|
|
90
|
+
surfaceStyle={layout.surfaceStyle}
|
|
91
|
+
style={layout.accessoryStyle}
|
|
92
|
+
onHeightChange={layout.setAccessoryHeight}
|
|
93
|
+
>
|
|
94
|
+
{accessory}
|
|
95
|
+
</HeaderAccessory>
|
|
342
96
|
)}
|
|
343
97
|
</View>
|
|
344
98
|
);
|
|
345
99
|
}
|
|
346
100
|
|
|
347
|
-
const styles = StyleSheet.create((
|
|
348
|
-
|
|
349
|
-
// Include the translated accessory in the parent's touchable bounds.
|
|
101
|
+
const styles = StyleSheet.create(() => ({
|
|
102
|
+
// Keep the translated accessory inside the overlay's touchable bounds.
|
|
350
103
|
container: (accessoryOffset: number) => ({
|
|
351
104
|
position: "absolute",
|
|
352
105
|
top: 0,
|
|
@@ -355,84 +108,4 @@ const styles = StyleSheet.create((theme) => ({
|
|
|
355
108
|
zIndex: 1000,
|
|
356
109
|
paddingBottom: accessoryOffset,
|
|
357
110
|
}),
|
|
358
|
-
// Normal flow places the accessory directly below the bar.
|
|
359
|
-
stickyAccessory: { position: "relative" },
|
|
360
|
-
// Anchors the absolutely positioned surface over the bar and the accessory both.
|
|
361
|
-
barGroup: { position: "relative" },
|
|
362
|
-
bar: {
|
|
363
|
-
height: theme.layout.header.height,
|
|
364
|
-
flexDirection: "row",
|
|
365
|
-
alignItems: "center",
|
|
366
|
-
paddingHorizontal: theme.layout.screen.padding.x,
|
|
367
|
-
gap: theme.spacing["3"],
|
|
368
|
-
justifyContent: "space-between",
|
|
369
|
-
},
|
|
370
|
-
// A centered title is absolutely positioned, so it leaves the flex row. With
|
|
371
|
-
// no leading slot the trailing toolbar would be the only in-flow child and
|
|
372
|
-
// `space-between` would park it on the left — the auto margin pins it right.
|
|
373
|
-
trailing: {
|
|
374
|
-
justifyContent: "flex-end",
|
|
375
|
-
variants: {
|
|
376
|
-
titleAlignment: {
|
|
377
|
-
center: { marginLeft: "auto" },
|
|
378
|
-
left: {},
|
|
379
|
-
},
|
|
380
|
-
},
|
|
381
|
-
},
|
|
382
|
-
portalContent: (hasAccessoryBelow: boolean) => ({
|
|
383
|
-
gap: 2,
|
|
384
|
-
// Include the gap in the measured height used to position the accessory.
|
|
385
|
-
paddingBottom: hasAccessoryBelow ? theme.spacing["2"] : 0,
|
|
386
|
-
paddingHorizontal: theme.layout.screen.padding.x,
|
|
387
|
-
}),
|
|
388
|
-
overline: {
|
|
389
|
-
fontSize: theme.typography.variants["label-sm"].fontSize,
|
|
390
|
-
lineHeight: theme.typography.variants["label-sm"].lineHeight,
|
|
391
|
-
letterSpacing: theme.typography.variants["label-sm"].letterSpacing,
|
|
392
|
-
fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["label-sm"].fontWeight],
|
|
393
|
-
color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
|
|
394
|
-
},
|
|
395
|
-
largeTitle: {
|
|
396
|
-
fontSize: theme.typography.variants["headline-lg"].fontSize,
|
|
397
|
-
lineHeight: theme.typography.variants["headline-lg"].lineHeight,
|
|
398
|
-
letterSpacing: theme.typography.variants["headline-lg"].letterSpacing,
|
|
399
|
-
fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["headline-lg"].fontWeight],
|
|
400
|
-
color: (theme.color as unknown as Record<string, string | undefined>)["text.primary"] ?? "",
|
|
401
|
-
},
|
|
402
|
-
subtitle: {
|
|
403
|
-
fontSize: theme.typography.variants["body-lg"].fontSize,
|
|
404
|
-
lineHeight: theme.typography.variants["body-lg"].lineHeight,
|
|
405
|
-
letterSpacing: theme.typography.variants["body-lg"].letterSpacing,
|
|
406
|
-
color: (theme.color as unknown as Record<string, string | undefined>)["text.tertiary"] ?? "",
|
|
407
|
-
},
|
|
408
|
-
compactTitle: {
|
|
409
|
-
fontSize: theme.typography.variants["title-md"].fontSize,
|
|
410
|
-
lineHeight: theme.typography.variants["title-md"].lineHeight,
|
|
411
|
-
letterSpacing: theme.typography.variants["title-md"].letterSpacing,
|
|
412
|
-
fontWeight: FONT_WEIGHT_VALUE[theme.typography.variants["title-md"].fontWeight],
|
|
413
|
-
color: (theme.color as unknown as Record<string, string | undefined>)["text.primary"] ?? "",
|
|
414
|
-
variants: {
|
|
415
|
-
titleAlignment: {
|
|
416
|
-
center: { textAlign: "center" },
|
|
417
|
-
left: { textAlign: "left" },
|
|
418
|
-
},
|
|
419
|
-
},
|
|
420
|
-
},
|
|
421
|
-
titleContainer: {
|
|
422
|
-
flex: 1,
|
|
423
|
-
variants: {
|
|
424
|
-
titleAlignment: {
|
|
425
|
-
center: {
|
|
426
|
-
position: "absolute",
|
|
427
|
-
left: 0,
|
|
428
|
-
right: 0,
|
|
429
|
-
alignItems: "center",
|
|
430
|
-
},
|
|
431
|
-
left: {
|
|
432
|
-
flex: 1,
|
|
433
|
-
alignItems: "flex-start",
|
|
434
|
-
},
|
|
435
|
-
},
|
|
436
|
-
},
|
|
437
|
-
},
|
|
438
111
|
}));
|