@astratra/native-ui 0.1.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/LICENSE +21 -0
- package/README.md +370 -0
- package/package.json +64 -0
- package/src/components/Chevron.js +31 -0
- package/src/components/CollapsibleHeader.js +253 -0
- package/src/components/FloatingPagination.js +135 -0
- package/src/components/GlassButton.js +117 -0
- package/src/components/GlassSurface.js +61 -0
- package/src/components/MarkdownView.js +322 -0
- package/src/components/PaleCard.js +83 -0
- package/src/components/TabBar.js +346 -0
- package/src/components/runtime.js +88 -0
- package/src/components/useCollapsingBar.js +55 -0
- package/src/index.d.ts +229 -0
- package/src/index.js +20 -0
- package/src/logic/anchorQuestion.js +41 -0
- package/src/logic/collapse.js +77 -0
- package/src/logic/collapsibleHeader.js +103 -0
- package/src/logic/color.js +68 -0
- package/src/logic/glass.js +151 -0
- package/src/logic/index.d.ts +203 -0
- package/src/logic/index.js +16 -0
- package/src/logic/markdown.js +229 -0
- package/src/logic/paleCard.js +66 -0
- package/src/logic/tabBar.js +104 -0
- package/src/logic/tableColumns.js +149 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page header that folds on scroll, and the screen that wires it.
|
|
3
|
+
*
|
|
4
|
+
* The rule — fixed back button and actions, large title scrolling away, a
|
|
5
|
+
* small blurred bar carrying the page name once the title has gone, a fade
|
|
6
|
+
* under it rather than a line — and its numbers are in
|
|
7
|
+
* logic/collapsibleHeader.js.
|
|
8
|
+
*
|
|
9
|
+
* On iOS the bar is the system's blur (the material of Apple's bars), masked
|
|
10
|
+
* by a gradient so it dies out in the overflow. The mask needs a MaskedView,
|
|
11
|
+
* which is injected (`MaskedView` prop, e.g. `@react-native-masked-view/
|
|
12
|
+
* masked-view`) rather than required: without it iOS gets the blur down to
|
|
13
|
+
* the bar's edge and the page-colour fade below it. On Android, a plain
|
|
14
|
+
* gradient of the page colour: its blur came out "dirty".
|
|
15
|
+
*/
|
|
16
|
+
const { React, h, RN, Reanimated, AnimatedView, BlurView, LinearGradient, useScheme } = require('./runtime');
|
|
17
|
+
const {
|
|
18
|
+
HEADER_BAR_HEIGHT,
|
|
19
|
+
HEADER_DEFAULT_THRESHOLD,
|
|
20
|
+
headerThreshold,
|
|
21
|
+
headerBarFade,
|
|
22
|
+
headerTitleFade,
|
|
23
|
+
headerBottomFade,
|
|
24
|
+
headerGradientStops,
|
|
25
|
+
androidHeaderColors
|
|
26
|
+
} = require('../logic/collapsibleHeader');
|
|
27
|
+
|
|
28
|
+
const { interpolate, useAnimatedStyle, useSharedValue } = Reanimated;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The scroll of a page and its threshold, for a screen that lays out the
|
|
32
|
+
* header itself.
|
|
33
|
+
*
|
|
34
|
+
* - `onScroll` goes to the page's scroll (with `scrollEventThrottle={16}`);
|
|
35
|
+
* the scrolling content starts with `paddingTop: topInset +
|
|
36
|
+
* HEADER_BAR_HEIGHT` (`topInset` = 0 when a safe-area view already pads it).
|
|
37
|
+
* - `onTitleLayout` goes to the `onLayout` of the large title (or the block
|
|
38
|
+
* holding it), a DIRECT child of the scrolling content.
|
|
39
|
+
* - `onListTitleLayout`: the same block used as a FlatList's
|
|
40
|
+
* `ListHeaderComponent`, where the measure does not include the top padding.
|
|
41
|
+
* - `measureTitle(node)`: for a title whose position is unknown (nested
|
|
42
|
+
* views), measured in the window against `originRef`, a view placed at the
|
|
43
|
+
* top of the scrolling area — both measures in the same frame, whatever the
|
|
44
|
+
* system.
|
|
45
|
+
*/
|
|
46
|
+
function useCollapsibleHeader({ topInset = 0 } = {}) {
|
|
47
|
+
const scrollY = useSharedValue(0);
|
|
48
|
+
const threshold = useSharedValue(HEADER_DEFAULT_THRESHOLD);
|
|
49
|
+
const originRef = React.useRef(null);
|
|
50
|
+
const onScroll = React.useCallback(
|
|
51
|
+
(event) => {
|
|
52
|
+
scrollY.value = event.nativeEvent.contentOffset.y;
|
|
53
|
+
},
|
|
54
|
+
[scrollY]
|
|
55
|
+
);
|
|
56
|
+
const placeThreshold = React.useCallback(
|
|
57
|
+
(titleBottom) => {
|
|
58
|
+
threshold.value = headerThreshold(titleBottom, topInset);
|
|
59
|
+
},
|
|
60
|
+
[threshold, topInset]
|
|
61
|
+
);
|
|
62
|
+
const onTitleLayout = React.useCallback(
|
|
63
|
+
(event) => placeThreshold(event.nativeEvent.layout.y + event.nativeEvent.layout.height),
|
|
64
|
+
[placeThreshold]
|
|
65
|
+
);
|
|
66
|
+
const onListTitleLayout = React.useCallback(
|
|
67
|
+
(event) =>
|
|
68
|
+
placeThreshold(topInset + HEADER_BAR_HEIGHT + event.nativeEvent.layout.y + event.nativeEvent.layout.height),
|
|
69
|
+
[placeThreshold, topInset]
|
|
70
|
+
);
|
|
71
|
+
const measureTitle = React.useCallback(
|
|
72
|
+
(node) => {
|
|
73
|
+
const origin = originRef.current;
|
|
74
|
+
if (!node || !origin) return;
|
|
75
|
+
origin.measureInWindow((_x, areaTop) =>
|
|
76
|
+
node.measureInWindow((_x2, y, _w, height) => placeThreshold(y - areaTop + height + scrollY.value))
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
[scrollY, placeThreshold]
|
|
80
|
+
);
|
|
81
|
+
return { scrollY, threshold, onScroll, onTitleLayout, onListTitleLayout, measureTitle, originRef };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const styles = {
|
|
85
|
+
zone: { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 },
|
|
86
|
+
/* Taller than the zone: it overflows below for the fade. */
|
|
87
|
+
background: { position: 'absolute', top: 0, left: 0, right: 0 },
|
|
88
|
+
row: { flexDirection: 'row', alignItems: 'center' },
|
|
89
|
+
/* Both sides share a minimum width: the title stays centred even with a
|
|
90
|
+
single action on the right. */
|
|
91
|
+
side: { minWidth: 44, flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
92
|
+
right: { justifyContent: 'flex-end' },
|
|
93
|
+
center: { flex: 1, alignItems: 'center', paddingHorizontal: 10 },
|
|
94
|
+
title: { fontWeight: '700', fontSize: 16, letterSpacing: -0.2 }
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
function CollapsibleHeader({
|
|
98
|
+
title,
|
|
99
|
+
scrollY,
|
|
100
|
+
threshold,
|
|
101
|
+
leading,
|
|
102
|
+
actions,
|
|
103
|
+
topInset = 0,
|
|
104
|
+
gutter = 16,
|
|
105
|
+
pageBackground,
|
|
106
|
+
titleColor,
|
|
107
|
+
MaskedView,
|
|
108
|
+
scheme: schemeOverride,
|
|
109
|
+
testID
|
|
110
|
+
}) {
|
|
111
|
+
const scheme = useScheme(schemeOverride);
|
|
112
|
+
const background = pageBackground || (scheme === 'dark' ? '#000000' : '#ffffff');
|
|
113
|
+
const barHeight = topInset + HEADER_BAR_HEIGHT;
|
|
114
|
+
const fade = headerBottomFade(RN.Platform.OS);
|
|
115
|
+
const stops = headerGradientStops(barHeight, fade);
|
|
116
|
+
const iosBlur = RN.Platform.OS === 'ios';
|
|
117
|
+
|
|
118
|
+
const barStyle = useAnimatedStyle(() => {
|
|
119
|
+
const range = headerBarFade(threshold.value);
|
|
120
|
+
return { opacity: interpolate(scrollY.value, range.input, range.output, 'clamp') };
|
|
121
|
+
});
|
|
122
|
+
const titleStyle = useAnimatedStyle(() => {
|
|
123
|
+
const ranges = headerTitleFade(threshold.value);
|
|
124
|
+
return {
|
|
125
|
+
opacity: interpolate(scrollY.value, ranges.opacity.input, ranges.opacity.output, 'clamp'),
|
|
126
|
+
transform: [{ translateY: interpolate(scrollY.value, ranges.translateY.input, ranges.translateY.output, 'clamp') }]
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const blur = h(BlurView, {
|
|
131
|
+
intensity: 80,
|
|
132
|
+
tint: scheme === 'dark' ? 'systemChromeMaterialDark' : 'systemChromeMaterialLight',
|
|
133
|
+
style: MaskedView ? RN.StyleSheet.absoluteFill : { height: barHeight }
|
|
134
|
+
});
|
|
135
|
+
const pageFade = (style) =>
|
|
136
|
+
h(LinearGradient, { colors: androidHeaderColors(background), locations: stops, style });
|
|
137
|
+
|
|
138
|
+
let backdrop;
|
|
139
|
+
if (iosBlur && MaskedView) {
|
|
140
|
+
backdrop = h(
|
|
141
|
+
MaskedView,
|
|
142
|
+
{
|
|
143
|
+
style: RN.StyleSheet.absoluteFill,
|
|
144
|
+
maskElement: h(LinearGradient, {
|
|
145
|
+
colors: ['#000000', '#000000', 'rgba(0,0,0,0)'],
|
|
146
|
+
locations: stops,
|
|
147
|
+
style: RN.StyleSheet.absoluteFill
|
|
148
|
+
})
|
|
149
|
+
},
|
|
150
|
+
blur
|
|
151
|
+
);
|
|
152
|
+
} else if (iosBlur) {
|
|
153
|
+
/* No mask available: the blur stops at the bar's edge, and the page
|
|
154
|
+
colour's own fade carries the overflow. */
|
|
155
|
+
backdrop = h(
|
|
156
|
+
RN.View,
|
|
157
|
+
{ style: RN.StyleSheet.absoluteFill },
|
|
158
|
+
pageFade(RN.StyleSheet.absoluteFill),
|
|
159
|
+
blur
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
backdrop = pageFade(RN.StyleSheet.absoluteFill);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return h(
|
|
166
|
+
RN.View,
|
|
167
|
+
{ testID, pointerEvents: 'box-none', style: [styles.zone, { height: barHeight }] },
|
|
168
|
+
h(AnimatedView, { pointerEvents: 'none', style: [styles.background, { height: barHeight + fade }, barStyle] }, backdrop),
|
|
169
|
+
h(
|
|
170
|
+
RN.View,
|
|
171
|
+
{
|
|
172
|
+
pointerEvents: 'box-none',
|
|
173
|
+
style: [styles.row, { marginTop: topInset, height: HEADER_BAR_HEIGHT, paddingHorizontal: gutter }]
|
|
174
|
+
},
|
|
175
|
+
h(RN.View, { style: styles.side }, leading || null),
|
|
176
|
+
h(
|
|
177
|
+
AnimatedView,
|
|
178
|
+
{ pointerEvents: 'none', style: [styles.center, titleStyle] },
|
|
179
|
+
h(
|
|
180
|
+
RN.Text,
|
|
181
|
+
{
|
|
182
|
+
numberOfLines: 1,
|
|
183
|
+
accessibilityRole: 'header',
|
|
184
|
+
style: [styles.title, { color: titleColor || (scheme === 'dark' ? '#ffffff' : '#0d1235') }]
|
|
185
|
+
},
|
|
186
|
+
title
|
|
187
|
+
)
|
|
188
|
+
),
|
|
189
|
+
h(RN.View, { style: [styles.side, styles.right] }, actions || null)
|
|
190
|
+
)
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A scrolling screen with the collapsible header already wired: the large
|
|
196
|
+
* title (`largeTitle`) scrolls, sets the threshold by its own layout, and the
|
|
197
|
+
* header floats above everything.
|
|
198
|
+
*/
|
|
199
|
+
function CollapsibleScreen({
|
|
200
|
+
title,
|
|
201
|
+
largeTitle,
|
|
202
|
+
children,
|
|
203
|
+
leading,
|
|
204
|
+
actions,
|
|
205
|
+
topInset = 0,
|
|
206
|
+
gutter = 16,
|
|
207
|
+
pageBackground,
|
|
208
|
+
titleColor,
|
|
209
|
+
MaskedView,
|
|
210
|
+
scheme,
|
|
211
|
+
contentContainerStyle,
|
|
212
|
+
scrollProps,
|
|
213
|
+
testID
|
|
214
|
+
}) {
|
|
215
|
+
const header = useCollapsibleHeader({ topInset });
|
|
216
|
+
const onScroll = (event) => {
|
|
217
|
+
header.onScroll(event);
|
|
218
|
+
if (scrollProps && scrollProps.onScroll) scrollProps.onScroll(event);
|
|
219
|
+
};
|
|
220
|
+
return h(
|
|
221
|
+
RN.View,
|
|
222
|
+
{ testID, style: { flex: 1, backgroundColor: pageBackground } },
|
|
223
|
+
h(RN.View, { ref: header.originRef, collapsable: false }),
|
|
224
|
+
h(
|
|
225
|
+
RN.ScrollView,
|
|
226
|
+
{
|
|
227
|
+
showsVerticalScrollIndicator: false,
|
|
228
|
+
...scrollProps,
|
|
229
|
+
onScroll,
|
|
230
|
+
scrollEventThrottle: 16,
|
|
231
|
+
/* Under the fixed header: the large title starts below the back button's row. */
|
|
232
|
+
contentContainerStyle: [{ flexGrow: 1, paddingTop: topInset + HEADER_BAR_HEIGHT }, contentContainerStyle]
|
|
233
|
+
},
|
|
234
|
+
largeTitle ? h(RN.View, { style: { paddingHorizontal: gutter }, onLayout: header.onTitleLayout }, largeTitle) : null,
|
|
235
|
+
children
|
|
236
|
+
),
|
|
237
|
+
h(CollapsibleHeader, {
|
|
238
|
+
title,
|
|
239
|
+
scrollY: header.scrollY,
|
|
240
|
+
threshold: header.threshold,
|
|
241
|
+
leading,
|
|
242
|
+
actions,
|
|
243
|
+
topInset,
|
|
244
|
+
gutter,
|
|
245
|
+
pageBackground,
|
|
246
|
+
titleColor,
|
|
247
|
+
MaskedView,
|
|
248
|
+
scheme
|
|
249
|
+
})
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = { useCollapsibleHeader, CollapsibleHeader, CollapsibleScreen };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List pagination as a pill that floats at the bottom, like the tab bar.
|
|
3
|
+
*
|
|
4
|
+
* It used to be a footer bar that ate the bottom of the screen, then a
|
|
5
|
+
* selector at the end of the list that had to be scrolled to. Now: a round
|
|
6
|
+
* pill, glass on iOS (the same as the tab bar), a light card on Android. It
|
|
7
|
+
* stays put while the list passes under it, and behaves like the tab bar: it
|
|
8
|
+
* shrinks when scrolling down, returns when scrolling up (`collapse`, from
|
|
9
|
+
* `useCollapsingBar`). Keep `paginationReserve(bottomInset)` of room under the
|
|
10
|
+
* list so its last row can scroll above the pill.
|
|
11
|
+
*/
|
|
12
|
+
const { h, RN, AnimatedView, GlassEffect, getGlassMode, useScheme } = require('./runtime');
|
|
13
|
+
const { useCollapseTransform } = require('./useCollapsingBar');
|
|
14
|
+
const { Chevron } = require('./Chevron');
|
|
15
|
+
const { PAGINATION_HEIGHT } = require('../logic/tabBar');
|
|
16
|
+
const { floatingBottomOffset } = require('../logic/collapse');
|
|
17
|
+
|
|
18
|
+
/* Properly round, like the tab bar: the ends are half circles. */
|
|
19
|
+
const glassShape = { height: PAGINATION_HEIGHT, borderRadius: PAGINATION_HEIGHT / 2, overflow: 'hidden' };
|
|
20
|
+
const cardShape = {
|
|
21
|
+
height: PAGINATION_HEIGHT,
|
|
22
|
+
borderRadius: PAGINATION_HEIGHT / 2,
|
|
23
|
+
backgroundColor: '#fdfdff',
|
|
24
|
+
borderWidth: RN.StyleSheet.hairlineWidth,
|
|
25
|
+
borderColor: 'rgba(13,18,53,0.06)',
|
|
26
|
+
shadowColor: '#18234f',
|
|
27
|
+
shadowOpacity: 0.14,
|
|
28
|
+
shadowRadius: 22,
|
|
29
|
+
shadowOffset: { width: 0, height: 10 },
|
|
30
|
+
elevation: 8
|
|
31
|
+
};
|
|
32
|
+
const darkCard = { backgroundColor: '#1c1c1e', borderColor: 'rgba(255,255,255,0.08)' };
|
|
33
|
+
|
|
34
|
+
function FloatingPagination({
|
|
35
|
+
page,
|
|
36
|
+
totalPages,
|
|
37
|
+
canPrevious,
|
|
38
|
+
canNext,
|
|
39
|
+
onPrevious,
|
|
40
|
+
onNext,
|
|
41
|
+
previousLabel,
|
|
42
|
+
nextLabel,
|
|
43
|
+
collapse,
|
|
44
|
+
bottom,
|
|
45
|
+
bottomInset = 0,
|
|
46
|
+
onHaptic,
|
|
47
|
+
previousIcon,
|
|
48
|
+
nextIcon,
|
|
49
|
+
scheme: schemeOverride,
|
|
50
|
+
testID
|
|
51
|
+
}) {
|
|
52
|
+
const scheme = useScheme(schemeOverride);
|
|
53
|
+
const dark = scheme === 'dark';
|
|
54
|
+
const ink = dark ? '#ffffff' : '#0d1235';
|
|
55
|
+
const foldStyle = useCollapseTransform(collapse);
|
|
56
|
+
const glass = getGlassMode() === 'native';
|
|
57
|
+
|
|
58
|
+
const arrow = (label, icon, enabled, onPress) =>
|
|
59
|
+
h(
|
|
60
|
+
RN.Pressable,
|
|
61
|
+
{
|
|
62
|
+
accessibilityRole: 'button',
|
|
63
|
+
accessibilityLabel: label,
|
|
64
|
+
accessibilityState: { disabled: !enabled },
|
|
65
|
+
disabled: !enabled,
|
|
66
|
+
hitSlop: 6,
|
|
67
|
+
onPress: () => {
|
|
68
|
+
if (onHaptic) onHaptic();
|
|
69
|
+
onPress();
|
|
70
|
+
},
|
|
71
|
+
style: ({ pressed }) => [
|
|
72
|
+
{ width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center' },
|
|
73
|
+
pressed ? { backgroundColor: dark ? 'rgba(255,255,255,0.12)' : 'rgba(13,18,53,0.08)' } : null,
|
|
74
|
+
enabled ? null : { opacity: 0.3 }
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
icon
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return h(
|
|
81
|
+
AnimatedView,
|
|
82
|
+
{
|
|
83
|
+
pointerEvents: 'box-none',
|
|
84
|
+
testID,
|
|
85
|
+
style: [
|
|
86
|
+
{
|
|
87
|
+
position: 'absolute',
|
|
88
|
+
left: 0,
|
|
89
|
+
right: 0,
|
|
90
|
+
alignItems: 'center',
|
|
91
|
+
/* A fixed button already in the page's footer: the pill sits above
|
|
92
|
+
it instead of covering it. Otherwise, the tab bar's place. */
|
|
93
|
+
bottom: bottom ?? floatingBottomOffset(bottomInset),
|
|
94
|
+
transformOrigin: 'bottom'
|
|
95
|
+
},
|
|
96
|
+
foldStyle
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
h(
|
|
100
|
+
RN.View,
|
|
101
|
+
{ style: glass ? glassShape : [cardShape, dark ? darkCard : null] },
|
|
102
|
+
glass
|
|
103
|
+
? h(GlassEffect.GlassView, {
|
|
104
|
+
glassEffectStyle: 'regular',
|
|
105
|
+
tintColor: dark ? 'rgba(0,0,0,0.62)' : 'rgba(255,255,255,0.42)',
|
|
106
|
+
colorScheme: scheme,
|
|
107
|
+
style: RN.StyleSheet.absoluteFill
|
|
108
|
+
})
|
|
109
|
+
: null,
|
|
110
|
+
h(
|
|
111
|
+
RN.View,
|
|
112
|
+
{ style: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 6, gap: 2 } },
|
|
113
|
+
arrow(previousLabel, previousIcon || h(Chevron, { direction: 'left', color: ink }), canPrevious, onPrevious),
|
|
114
|
+
h(
|
|
115
|
+
RN.Text,
|
|
116
|
+
{
|
|
117
|
+
style: {
|
|
118
|
+
minWidth: 70,
|
|
119
|
+
textAlign: 'center',
|
|
120
|
+
fontWeight: '600',
|
|
121
|
+
fontSize: 13,
|
|
122
|
+
fontVariant: ['tabular-nums'],
|
|
123
|
+
color: dark ? 'rgba(255,255,255,0.7)' : '#5c6178'
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
h(RN.Text, { style: { fontWeight: '800', fontSize: 15, color: ink } }, String(page)),
|
|
127
|
+
` / ${totalPages}`
|
|
128
|
+
),
|
|
129
|
+
arrow(nextLabel, nextIcon || h(Chevron, { direction: 'right', color: ink }), canNext, onNext)
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { FloatingPagination };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The glass button — the one in Apple's Photos app: a translucent circle or
|
|
3
|
+
* pill that lets you guess what it covers, a soft shadow, and a solid tint
|
|
4
|
+
* when it carries the main action.
|
|
5
|
+
*
|
|
6
|
+
* A glass with depth: the icon is caught in it like an object in an ice cube
|
|
7
|
+
* and comes out sharp (see `glassButtonMaterial`). Three layers, in this
|
|
8
|
+
* order: the tint, the highlight, then the content. The content goes ABOVE
|
|
9
|
+
* the highlight — a white veil over the icon would dim it.
|
|
10
|
+
*/
|
|
11
|
+
const { h, RN, LinearGradient, getGlassMode, useScheme } = require('./runtime');
|
|
12
|
+
const { GlassSurface } = require('./GlassSurface');
|
|
13
|
+
const { glassButtonMaterial, glassButtonTint } = require('../logic/glass');
|
|
14
|
+
|
|
15
|
+
/* The shadow is iOS's, where the glass carries it discreetly. On Android it
|
|
16
|
+
became a full boxShadow under an almost white fill: a grey halo around
|
|
17
|
+
every round button. Android gets none. */
|
|
18
|
+
const IOS_SHADOW = {
|
|
19
|
+
shadowColor: '#0d1235',
|
|
20
|
+
shadowOpacity: 0.1,
|
|
21
|
+
shadowRadius: 10,
|
|
22
|
+
shadowOffset: { width: 0, height: 4 }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const control = {
|
|
26
|
+
flex: 1,
|
|
27
|
+
flexDirection: 'row',
|
|
28
|
+
alignItems: 'center',
|
|
29
|
+
justifyContent: 'center',
|
|
30
|
+
gap: 6,
|
|
31
|
+
paddingHorizontal: 12
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/* The press is iOS's gesture; on Android the ripple is enough, and both
|
|
35
|
+
together make a button that jerks. */
|
|
36
|
+
const PRESSED = { opacity: 0.75, transform: [{ scale: 0.96 }] };
|
|
37
|
+
|
|
38
|
+
function GlassButton({
|
|
39
|
+
children,
|
|
40
|
+
onPress,
|
|
41
|
+
onLongPress,
|
|
42
|
+
accessibilityLabel,
|
|
43
|
+
size = 44,
|
|
44
|
+
pill = false,
|
|
45
|
+
tint,
|
|
46
|
+
disabled = false,
|
|
47
|
+
selected = false,
|
|
48
|
+
scheme: schemeOverride,
|
|
49
|
+
style,
|
|
50
|
+
testID
|
|
51
|
+
}) {
|
|
52
|
+
const scheme = useScheme(schemeOverride);
|
|
53
|
+
const mode = getGlassMode();
|
|
54
|
+
const material = glassButtonMaterial({ mode, scheme, tinted: Boolean(tint) });
|
|
55
|
+
const android = RN.Platform.OS === 'android';
|
|
56
|
+
return h(
|
|
57
|
+
GlassSurface,
|
|
58
|
+
{
|
|
59
|
+
style: [
|
|
60
|
+
android ? null : IOS_SHADOW,
|
|
61
|
+
{ height: size, borderRadius: size / 2, width: pill ? undefined : size },
|
|
62
|
+
disabled ? { opacity: 0.5 } : null,
|
|
63
|
+
style
|
|
64
|
+
],
|
|
65
|
+
glassStyle: material.glassStyle,
|
|
66
|
+
tintColor: tint || glassButtonTint({ mode, selected }),
|
|
67
|
+
interactive: Boolean(onPress) && !disabled,
|
|
68
|
+
scheme
|
|
69
|
+
},
|
|
70
|
+
/* Apple's glass ignores the fill it is given: a tinted button's colour is
|
|
71
|
+
therefore a layer of its own, under the highlight. */
|
|
72
|
+
tint
|
|
73
|
+
? h(RN.View, {
|
|
74
|
+
pointerEvents: 'none',
|
|
75
|
+
style: [RN.StyleSheet.absoluteFill, { backgroundColor: tint, opacity: 0.8 }]
|
|
76
|
+
})
|
|
77
|
+
: null,
|
|
78
|
+
h(LinearGradient, {
|
|
79
|
+
pointerEvents: 'none',
|
|
80
|
+
colors: material.highlight,
|
|
81
|
+
locations: [0, 0.55, 1],
|
|
82
|
+
start: { x: 0.5, y: 0 },
|
|
83
|
+
end: { x: 0.5, y: 1 },
|
|
84
|
+
style: RN.StyleSheet.absoluteFill
|
|
85
|
+
}),
|
|
86
|
+
h(
|
|
87
|
+
RN.Pressable,
|
|
88
|
+
{
|
|
89
|
+
testID,
|
|
90
|
+
accessibilityRole: 'button',
|
|
91
|
+
accessibilityLabel,
|
|
92
|
+
accessibilityState: { disabled, selected },
|
|
93
|
+
disabled,
|
|
94
|
+
hitSlop: 6,
|
|
95
|
+
onPress,
|
|
96
|
+
onLongPress,
|
|
97
|
+
/* Without it the button had no touch feedback at all on Android. The
|
|
98
|
+
ripple is light on a tinted button, dark on a light one. */
|
|
99
|
+
android_ripple: {
|
|
100
|
+
color: tint ? 'rgba(255,255,255,0.24)' : 'rgba(13,18,53,0.10)',
|
|
101
|
+
borderless: true,
|
|
102
|
+
foreground: true
|
|
103
|
+
},
|
|
104
|
+
style: ({ pressed }) => [
|
|
105
|
+
control,
|
|
106
|
+
/* With no fill of its own, iOS casts this shadow onto the silhouette
|
|
107
|
+
of the icon and text only: the glow that lifts them out. */
|
|
108
|
+
material.glow,
|
|
109
|
+
pressed && !android ? PRESSED : null
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
children
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { GlassButton };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple's glass on iOS, a visible surface everywhere else.
|
|
3
|
+
*
|
|
4
|
+
* On iOS 26+ this is Apple's `GlassView`, as is. Elsewhere it is a plain View
|
|
5
|
+
* whose fill is the caller's tint AT THE WEIGHT APPLE REALLY GIVES IT
|
|
6
|
+
* (`tintAtAppleWeight`), with iOS's shadow rule applied
|
|
7
|
+
* (`surfaceStyleOffApple`). Both rules, and the reason Android gets no blur,
|
|
8
|
+
* are in logic/glass.js.
|
|
9
|
+
*/
|
|
10
|
+
const { h, RN, GlassEffect, getGlassMode, useScheme } = require('./runtime');
|
|
11
|
+
const { tintAtAppleWeight, surfaceStyleOffApple } = require('../logic/glass');
|
|
12
|
+
|
|
13
|
+
const base = { overflow: 'hidden' };
|
|
14
|
+
|
|
15
|
+
function GlassSurface({
|
|
16
|
+
children,
|
|
17
|
+
style,
|
|
18
|
+
tintColor = 'rgba(255,255,255,0.12)',
|
|
19
|
+
glassStyle = 'regular',
|
|
20
|
+
interactive = false,
|
|
21
|
+
scheme: schemeOverride,
|
|
22
|
+
testID
|
|
23
|
+
}) {
|
|
24
|
+
const scheme = useScheme(schemeOverride);
|
|
25
|
+
if (getGlassMode() === 'native') {
|
|
26
|
+
return h(
|
|
27
|
+
GlassEffect.GlassView,
|
|
28
|
+
{
|
|
29
|
+
glassEffectStyle: glassStyle,
|
|
30
|
+
tintColor,
|
|
31
|
+
/* The APP's scheme, not the phone's: otherwise the glass stays light
|
|
32
|
+
on a dark page and its content drowns. */
|
|
33
|
+
colorScheme: scheme,
|
|
34
|
+
isInteractive: interactive,
|
|
35
|
+
style: [base, style],
|
|
36
|
+
testID
|
|
37
|
+
},
|
|
38
|
+
children
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const flat = surfaceStyleOffApple(
|
|
42
|
+
RN.StyleSheet.flatten([base, { backgroundColor: tintAtAppleWeight(tintColor, interactive) }, style]),
|
|
43
|
+
interactive
|
|
44
|
+
);
|
|
45
|
+
return h(RN.View, { style: flat, testID }, children);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A group of glass elements. On iOS, `GlassContainer` lets neighbouring
|
|
50
|
+
* glass shapes merge and carries the spacing. Off iOS a bare View was
|
|
51
|
+
* rendered and the elements ended up glued to each other: the spacing
|
|
52
|
+
* becomes a `gap`.
|
|
53
|
+
*/
|
|
54
|
+
function GlassGroup({ children, spacing = 8, style, ...props }) {
|
|
55
|
+
if (getGlassMode() === 'native') {
|
|
56
|
+
return h(GlassEffect.GlassContainer, { spacing, style, ...props }, children);
|
|
57
|
+
}
|
|
58
|
+
return h(RN.View, { style: [{ gap: spacing }, style], ...props }, children);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { GlassSurface, GlassGroup };
|