@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,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The floating tab bar, Instagram style.
|
|
3
|
+
*
|
|
4
|
+
* A round floating bar, sober destinations — an icon and a short label — and
|
|
5
|
+
* an optional centre tab, raised, for the app's signature action. On iOS the
|
|
6
|
+
* bar is the Dock's glass; Android keeps a light card (its system has no such
|
|
7
|
+
* material, and its imitations came out grey).
|
|
8
|
+
*
|
|
9
|
+
* Four behaviours, each asked for after seeing the previous version on a
|
|
10
|
+
* phone:
|
|
11
|
+
* - the ACTIVE TAB is full ink on a grey pill, not a blue accent;
|
|
12
|
+
* - the pill SLIDES to the tapped tab, and the page opens once it has almost
|
|
13
|
+
* arrived — opening in the same frame covered the bar, the slide was never
|
|
14
|
+
* seen;
|
|
15
|
+
* - the CENTRE TAB never takes the pill: it opens, the pill stays where it
|
|
16
|
+
* was (it is an action more than a place);
|
|
17
|
+
* - the bar FOLDS on scroll (`collapse`, from `useCollapsingBar`) and GROWS a
|
|
18
|
+
* little on large screens (`barScale`) — never smaller.
|
|
19
|
+
*
|
|
20
|
+
* The geometry is in logic/tabBar.js and logic/collapse.js, tested dry.
|
|
21
|
+
*/
|
|
22
|
+
const { React, h, RN, Reanimated, AnimatedView, AnimatedText, GlassEffect, getGlassMode, useScheme, useReducedMotion } =
|
|
23
|
+
require('./runtime');
|
|
24
|
+
const { GlassSurface } = require('./GlassSurface');
|
|
25
|
+
const { useCollapseTransform } = require('./useCollapsingBar');
|
|
26
|
+
const { barScale } = require('../logic/collapse');
|
|
27
|
+
const {
|
|
28
|
+
TAB_BAR_HEIGHT,
|
|
29
|
+
TAB_ROW_MARGIN,
|
|
30
|
+
TAB_PILL_HEIGHT,
|
|
31
|
+
TAB_CENTER_SIZE,
|
|
32
|
+
TAB_OPEN_DELAY,
|
|
33
|
+
arrangeTabs,
|
|
34
|
+
tabCellWidth,
|
|
35
|
+
pillOffset,
|
|
36
|
+
tabBarWidth,
|
|
37
|
+
badgeText,
|
|
38
|
+
tabAccessibilityLabel
|
|
39
|
+
} = require('../logic/tabBar');
|
|
40
|
+
const { floatingBottomOffset } = require('../logic/collapse');
|
|
41
|
+
|
|
42
|
+
const { interpolate, useAnimatedStyle, useSharedValue, withSpring } = Reanimated;
|
|
43
|
+
|
|
44
|
+
/* On glass the background is never guaranteed: the idle ink is frank on both
|
|
45
|
+
sides of the theme, not a mid grey that gets lost in both. */
|
|
46
|
+
const DEFAULT_COLORS = {
|
|
47
|
+
light: { active: '#0d1235', idle: '#343a52', pill: 'rgba(13,18,53,0.08)', badge: '#e5484d', badgeRing: '#fdfdff' },
|
|
48
|
+
dark: { active: '#ffffff', idle: 'rgba(255,255,255,0.9)', pill: 'rgba(255,255,255,0.16)', badge: '#e5484d', badgeRing: '#1c1c1e' }
|
|
49
|
+
};
|
|
50
|
+
/* A quick spring, without a marked bounce. */
|
|
51
|
+
const SLIDE = { damping: 20, stiffness: 260, mass: 0.7 };
|
|
52
|
+
|
|
53
|
+
const styles = {
|
|
54
|
+
wrapper: { position: 'absolute', left: 0, right: 0, alignItems: 'center' },
|
|
55
|
+
/* A card, not glass: frank light fill, a hairline, a soft shadow. The bar
|
|
56
|
+
carries the content; it does not make a show. */
|
|
57
|
+
card: {
|
|
58
|
+
backgroundColor: '#fdfdff',
|
|
59
|
+
borderWidth: RN.StyleSheet.hairlineWidth,
|
|
60
|
+
borderColor: 'rgba(13,18,53,0.06)',
|
|
61
|
+
shadowColor: '#18234f',
|
|
62
|
+
shadowOpacity: 0.14,
|
|
63
|
+
shadowRadius: 22,
|
|
64
|
+
shadowOffset: { width: 0, height: 10 },
|
|
65
|
+
elevation: 10
|
|
66
|
+
},
|
|
67
|
+
darkCard: { backgroundColor: '#1c1c1e', borderColor: 'rgba(255,255,255,0.08)' },
|
|
68
|
+
/* The Dock's glass: no fill, no stroke, no shadow — each gave back the
|
|
69
|
+
white slab the glass replaced. The clipping keeps the rounding. */
|
|
70
|
+
glass: { overflow: 'hidden' },
|
|
71
|
+
row: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingHorizontal: TAB_ROW_MARGIN },
|
|
72
|
+
tab: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 4 },
|
|
73
|
+
iconStack: { minWidth: 30, height: 24, alignItems: 'center', justifyContent: 'center' },
|
|
74
|
+
badge: {
|
|
75
|
+
position: 'absolute',
|
|
76
|
+
top: -5,
|
|
77
|
+
right: -9,
|
|
78
|
+
minWidth: 16,
|
|
79
|
+
height: 16,
|
|
80
|
+
paddingHorizontal: 4,
|
|
81
|
+
borderRadius: 8,
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
justifyContent: 'center',
|
|
84
|
+
borderWidth: 1.5
|
|
85
|
+
},
|
|
86
|
+
badgeText: { color: '#ffffff', fontWeight: '700', fontSize: 9 },
|
|
87
|
+
label: { fontWeight: '500', letterSpacing: 0.1 },
|
|
88
|
+
labelActive: { fontWeight: '600' },
|
|
89
|
+
centerSlot: { flex: 1 },
|
|
90
|
+
centerWrap: { position: 'absolute', alignItems: 'center', gap: 3 }
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Without a navigation focus hook, the bar re-syncs whenever the callback changes. */
|
|
94
|
+
function useEffectOnChange(callback) {
|
|
95
|
+
React.useEffect(() => callback(), [callback]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function TabBar({
|
|
99
|
+
tabs,
|
|
100
|
+
activeKey,
|
|
101
|
+
onSelect,
|
|
102
|
+
centerKey,
|
|
103
|
+
renderCenter,
|
|
104
|
+
collapse,
|
|
105
|
+
bottomInset = 0,
|
|
106
|
+
onHaptic,
|
|
107
|
+
openDelay = TAB_OPEN_DELAY,
|
|
108
|
+
useFocusEffect = useEffectOnChange,
|
|
109
|
+
colors,
|
|
110
|
+
scheme: schemeOverride,
|
|
111
|
+
testID
|
|
112
|
+
}) {
|
|
113
|
+
const { width: screenWidth } = RN.useWindowDimensions();
|
|
114
|
+
const scheme = useScheme(schemeOverride);
|
|
115
|
+
const palette = { ...DEFAULT_COLORS[scheme], ...(colors || {}) };
|
|
116
|
+
const scale = barScale(screenWidth);
|
|
117
|
+
const height = TAB_BAR_HEIGHT * scale;
|
|
118
|
+
const pillHeight = TAB_PILL_HEIGHT * scale;
|
|
119
|
+
const { ordered, centerIndex } = arrangeTabs(tabs, centerKey);
|
|
120
|
+
const progress = useSharedValue(0);
|
|
121
|
+
const fold = collapse || progress;
|
|
122
|
+
const foldStyle = useCollapseTransform(collapse);
|
|
123
|
+
const reduceMotion = useReducedMotion();
|
|
124
|
+
const glass = getGlassMode() === 'native';
|
|
125
|
+
|
|
126
|
+
/* The chosen tab lives here: a tap moves it at once, the pill slides, then
|
|
127
|
+
the page opens. Back on the screen, it becomes the screen's tab again
|
|
128
|
+
(activeKey) and the pill returns there. */
|
|
129
|
+
const [chosen, setChosen] = React.useState(activeKey);
|
|
130
|
+
const [rowWidth, setRowWidth] = React.useState(0);
|
|
131
|
+
const cellWidth = tabCellWidth(rowWidth, ordered.length);
|
|
132
|
+
const keys = ordered.map((tab) => tab.key).join('|');
|
|
133
|
+
const x = useSharedValue(0);
|
|
134
|
+
const place = React.useCallback(
|
|
135
|
+
(key, animated) => {
|
|
136
|
+
const target = pillOffset(keys.split('|'), key, cellWidth);
|
|
137
|
+
x.value = animated && !reduceMotion ? withSpring(target, SLIDE) : target;
|
|
138
|
+
},
|
|
139
|
+
[keys, cellWidth, reduceMotion, x]
|
|
140
|
+
);
|
|
141
|
+
/* First measure (or a rotation): the pill lands without sliding. Only a
|
|
142
|
+
width change re-places it; `chosen` slides through the tap. */
|
|
143
|
+
React.useEffect(() => {
|
|
144
|
+
place(chosen, false);
|
|
145
|
+
}, [cellWidth]); // deliberately not `chosen`: a tap slides the pill itself
|
|
146
|
+
useFocusEffect(
|
|
147
|
+
React.useCallback(() => {
|
|
148
|
+
if (activeKey === centerKey) return;
|
|
149
|
+
setChosen(activeKey);
|
|
150
|
+
place(activeKey, true);
|
|
151
|
+
}, [activeKey, centerKey, place])
|
|
152
|
+
);
|
|
153
|
+
const pillStyle = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }));
|
|
154
|
+
|
|
155
|
+
const haptic = () => {
|
|
156
|
+
if (onHaptic) onHaptic();
|
|
157
|
+
};
|
|
158
|
+
const select = (tab, active) => {
|
|
159
|
+
if (active) {
|
|
160
|
+
onSelect(tab);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
haptic();
|
|
164
|
+
/* The centre tab never takes the pill: it opens at once, the pill stays. */
|
|
165
|
+
if (tab.key === centerKey) {
|
|
166
|
+
onSelect(tab);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
setChosen(tab.key);
|
|
170
|
+
place(tab.key, true);
|
|
171
|
+
if (reduceMotion || !(openDelay > 0)) onSelect(tab);
|
|
172
|
+
else setTimeout(() => onSelect(tab), openDelay);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return h(
|
|
176
|
+
AnimatedView,
|
|
177
|
+
{
|
|
178
|
+
testID,
|
|
179
|
+
pointerEvents: 'box-none',
|
|
180
|
+
style: [styles.wrapper, { bottom: floatingBottomOffset(bottomInset), transformOrigin: 'bottom' }, foldStyle]
|
|
181
|
+
},
|
|
182
|
+
h(
|
|
183
|
+
RN.View,
|
|
184
|
+
{
|
|
185
|
+
style: [
|
|
186
|
+
glass ? styles.glass : [styles.card, scheme === 'dark' ? styles.darkCard : null],
|
|
187
|
+
{ width: tabBarWidth(screenWidth, scale), height, borderRadius: height / 2 }
|
|
188
|
+
]
|
|
189
|
+
},
|
|
190
|
+
glass
|
|
191
|
+
? h(GlassEffect.GlassView, {
|
|
192
|
+
glassEffectStyle: 'regular',
|
|
193
|
+
/* A tint in the glass, or the page's coloured cards show through
|
|
194
|
+
the bar and the labels vanish — glaring in dark mode. `clear`
|
|
195
|
+
glass let them scramble the labels; `regular` blurs them
|
|
196
|
+
enough to read, without becoming a card again. */
|
|
197
|
+
tintColor: scheme === 'dark' ? 'rgba(0,0,0,0.62)' : 'rgba(255,255,255,0.42)',
|
|
198
|
+
/* The APP's theme, not the phone's. */
|
|
199
|
+
colorScheme: scheme,
|
|
200
|
+
style: RN.StyleSheet.absoluteFill
|
|
201
|
+
})
|
|
202
|
+
: null,
|
|
203
|
+
h(
|
|
204
|
+
RN.View,
|
|
205
|
+
{ style: styles.row, onLayout: (event) => setRowWidth(event.nativeEvent.layout.width) },
|
|
206
|
+
cellWidth > 0
|
|
207
|
+
? h(AnimatedView, {
|
|
208
|
+
pointerEvents: 'none',
|
|
209
|
+
style: [
|
|
210
|
+
{
|
|
211
|
+
position: 'absolute',
|
|
212
|
+
left: 0,
|
|
213
|
+
width: cellWidth,
|
|
214
|
+
top: (height - pillHeight) / 2,
|
|
215
|
+
height: pillHeight,
|
|
216
|
+
borderRadius: pillHeight / 2,
|
|
217
|
+
backgroundColor: palette.pill
|
|
218
|
+
},
|
|
219
|
+
pillStyle
|
|
220
|
+
]
|
|
221
|
+
})
|
|
222
|
+
: null,
|
|
223
|
+
ordered.map((tab, index) =>
|
|
224
|
+
index === centerIndex
|
|
225
|
+
? h(RN.View, { key: tab.key, style: styles.centerSlot })
|
|
226
|
+
: h(TabItem, {
|
|
227
|
+
key: tab.key,
|
|
228
|
+
tab,
|
|
229
|
+
active: tab.key === chosen,
|
|
230
|
+
onSelect: select,
|
|
231
|
+
scale,
|
|
232
|
+
fold,
|
|
233
|
+
palette,
|
|
234
|
+
testID: testID ? `${testID}-${tab.key}` : undefined
|
|
235
|
+
})
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
),
|
|
239
|
+
centerIndex >= 0
|
|
240
|
+
? h(CenterTab, {
|
|
241
|
+
tab: ordered[centerIndex],
|
|
242
|
+
onSelect: select,
|
|
243
|
+
fold,
|
|
244
|
+
scale,
|
|
245
|
+
palette,
|
|
246
|
+
renderCenter,
|
|
247
|
+
testID: testID ? `${testID}-${ordered[centerIndex].key}` : undefined
|
|
248
|
+
})
|
|
249
|
+
: null
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The press feedback of a tab: it sinks a little, on a spring. */
|
|
254
|
+
function usePressScale(to) {
|
|
255
|
+
const press = React.useRef(new RN.Animated.Value(0)).current;
|
|
256
|
+
const animate = (toValue) =>
|
|
257
|
+
RN.Animated.spring(press, { toValue, friction: 7, tension: 115, useNativeDriver: true }).start();
|
|
258
|
+
const style = { transform: [{ scale: press.interpolate({ inputRange: [0, 1], outputRange: [1, to] }) }] };
|
|
259
|
+
return { style, pressIn: () => animate(1), pressOut: () => animate(0) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function TabItem({ tab, active, onSelect, scale, fold, palette, testID }) {
|
|
263
|
+
const press = usePressScale(0.94);
|
|
264
|
+
const color = active ? palette.active : palette.idle;
|
|
265
|
+
/* Folded: the label fades and the icon drops into its place. */
|
|
266
|
+
const labelStyle = useAnimatedStyle(() => ({ opacity: interpolate(fold.value, [0, 0.6], [1, 0], 'clamp') }));
|
|
267
|
+
const iconStyle = useAnimatedStyle(() => ({ transform: [{ translateY: interpolate(fold.value, [0, 1], [0, 7]) }] }));
|
|
268
|
+
const size = 20 * scale;
|
|
269
|
+
|
|
270
|
+
return h(
|
|
271
|
+
RN.Animated.View,
|
|
272
|
+
{ style: [{ flex: 1 }, press.style] },
|
|
273
|
+
h(
|
|
274
|
+
RN.Pressable,
|
|
275
|
+
{
|
|
276
|
+
testID,
|
|
277
|
+
accessibilityRole: 'tab',
|
|
278
|
+
accessibilityState: { selected: active },
|
|
279
|
+
accessibilityLabel: tabAccessibilityLabel(tab),
|
|
280
|
+
onPress: () => onSelect(tab, active),
|
|
281
|
+
onPressIn: press.pressIn,
|
|
282
|
+
onPressOut: press.pressOut,
|
|
283
|
+
style: [styles.tab, { minHeight: (TAB_BAR_HEIGHT - 8) * scale }]
|
|
284
|
+
},
|
|
285
|
+
h(
|
|
286
|
+
AnimatedView,
|
|
287
|
+
{ style: [styles.iconStack, iconStyle] },
|
|
288
|
+
typeof tab.icon === 'function' ? tab.icon({ color, size, strokeWidth: active ? 2.4 : 2.1, active }) : null,
|
|
289
|
+
tab.badge
|
|
290
|
+
? h(
|
|
291
|
+
RN.View,
|
|
292
|
+
{ style: [styles.badge, { backgroundColor: palette.badge, borderColor: palette.badgeRing }] },
|
|
293
|
+
h(RN.Text, { style: styles.badgeText }, badgeText(tab.badge))
|
|
294
|
+
)
|
|
295
|
+
: null
|
|
296
|
+
),
|
|
297
|
+
h(
|
|
298
|
+
AnimatedText,
|
|
299
|
+
{ numberOfLines: 1, style: [styles.label, { color, fontSize: 10 * scale }, active ? styles.labelActive : null, labelStyle] },
|
|
300
|
+
tab.label
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The centre tab: round, raised above the bar; it never takes the pill, it opens. */
|
|
307
|
+
function CenterTab({ tab, onSelect, fold, scale, palette, renderCenter, testID }) {
|
|
308
|
+
const size = TAB_CENTER_SIZE * scale;
|
|
309
|
+
const press = usePressScale(0.92);
|
|
310
|
+
const labelStyle = useAnimatedStyle(() => ({ opacity: interpolate(fold.value, [0, 0.6], [1, 0], 'clamp') }));
|
|
311
|
+
const content = renderCenter
|
|
312
|
+
? renderCenter({ tab, size })
|
|
313
|
+
: h(
|
|
314
|
+
GlassSurface,
|
|
315
|
+
{
|
|
316
|
+
interactive: true,
|
|
317
|
+
style: { width: size, height: size, borderRadius: size / 2, alignItems: 'center', justifyContent: 'center' }
|
|
318
|
+
},
|
|
319
|
+
typeof tab.icon === 'function' ? tab.icon({ color: palette.active, size: size * 0.44, strokeWidth: 2.2, active: false }) : null
|
|
320
|
+
);
|
|
321
|
+
return h(
|
|
322
|
+
RN.Animated.View,
|
|
323
|
+
{ pointerEvents: 'box-none', style: [styles.centerWrap, { top: -size / 2 + 6 }, press.style] },
|
|
324
|
+
h(
|
|
325
|
+
RN.Pressable,
|
|
326
|
+
{
|
|
327
|
+
testID,
|
|
328
|
+
accessibilityRole: 'tab',
|
|
329
|
+
accessibilityState: { selected: false },
|
|
330
|
+
accessibilityLabel: tabAccessibilityLabel(tab),
|
|
331
|
+
onPress: () => onSelect(tab, false),
|
|
332
|
+
onPressIn: press.pressIn,
|
|
333
|
+
onPressOut: press.pressOut,
|
|
334
|
+
style: { padding: 4, borderRadius: (size + 8) / 2 }
|
|
335
|
+
},
|
|
336
|
+
content
|
|
337
|
+
),
|
|
338
|
+
h(
|
|
339
|
+
AnimatedText,
|
|
340
|
+
{ numberOfLines: 1, style: [styles.label, { color: palette.idle, fontSize: 10 * scale, marginTop: 1 }, labelStyle] },
|
|
341
|
+
tab.label
|
|
342
|
+
)
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
module.exports = { TabBar };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The peer modules, loaded once, and the device facts the kit reads from them.
|
|
3
|
+
*
|
|
4
|
+
* Every component goes through here instead of requiring react-native itself:
|
|
5
|
+
* one place knows how a default export arrives (Metro's interop wraps an ES
|
|
6
|
+
* module's default in `.default`, Node's require does not), and one place
|
|
7
|
+
* decides the glass mode.
|
|
8
|
+
*/
|
|
9
|
+
const React = require('react');
|
|
10
|
+
const RN = require('react-native');
|
|
11
|
+
const { resolveGlassMode } = require('@astratra/native');
|
|
12
|
+
|
|
13
|
+
const h = React.createElement;
|
|
14
|
+
|
|
15
|
+
function interop(mod) {
|
|
16
|
+
return mod && mod.default ? mod.default : mod;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const Reanimated = require('react-native-reanimated');
|
|
20
|
+
const AnimatedLib = interop(Reanimated);
|
|
21
|
+
const GlassEffect = require('expo-glass-effect');
|
|
22
|
+
const { LinearGradient } = require('expo-linear-gradient');
|
|
23
|
+
const { BlurView } = require('expo-blur');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Probing the glass API THROWS off iOS on some versions of expo-glass-effect,
|
|
27
|
+
* instead of answering false. A probe that throws is answered "no".
|
|
28
|
+
*/
|
|
29
|
+
function probe(fn) {
|
|
30
|
+
try {
|
|
31
|
+
return typeof fn === 'function' ? Boolean(fn()) : false;
|
|
32
|
+
} catch (_error) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let cachedMode = null;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The glass this device renders, decided once for the whole app: a header
|
|
41
|
+
* that is glass above a tab bar that is not reads worse than neither.
|
|
42
|
+
*
|
|
43
|
+
* Only `'native'` matters to this kit (see logic/glass.js for why Android's
|
|
44
|
+
* `'blur'` is not used for surfaces). `isLiquidGlassAvailable` is the iOS 26
|
|
45
|
+
* material itself; `isGlassEffectAPIAvailable` only says the module is there.
|
|
46
|
+
*/
|
|
47
|
+
function getGlassMode() {
|
|
48
|
+
if (cachedMode) return cachedMode;
|
|
49
|
+
const ios = RN.Platform.OS === 'ios';
|
|
50
|
+
cachedMode = resolveGlassMode({
|
|
51
|
+
platform: RN.Platform.OS,
|
|
52
|
+
apiAvailable: ios && probe(GlassEffect.isGlassEffectAPIAvailable),
|
|
53
|
+
effectAvailable: ios && probe(GlassEffect.isLiquidGlassAvailable)
|
|
54
|
+
});
|
|
55
|
+
return cachedMode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The colour scheme a component should draw for: the caller's (the APP's
|
|
60
|
+
* theme — an app with its own dark mode) when given, the phone's otherwise.
|
|
61
|
+
* Glass left in light mode on a dark page makes its labels unreadable.
|
|
62
|
+
*/
|
|
63
|
+
function useScheme(override) {
|
|
64
|
+
const system = RN.useColorScheme();
|
|
65
|
+
if (override === 'dark' || override === 'light') return override;
|
|
66
|
+
return system === 'dark' ? 'dark' : 'light';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reanimated's own "Reduce motion" reading; absent on old versions. */
|
|
70
|
+
function useReducedMotion() {
|
|
71
|
+
const hook = Reanimated.useReducedMotion;
|
|
72
|
+
return typeof hook === 'function' ? Boolean(hook()) : false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
React,
|
|
77
|
+
RN,
|
|
78
|
+
h,
|
|
79
|
+
Reanimated,
|
|
80
|
+
AnimatedView: AnimatedLib.View,
|
|
81
|
+
AnimatedText: AnimatedLib.Text,
|
|
82
|
+
GlassEffect,
|
|
83
|
+
LinearGradient,
|
|
84
|
+
BlurView,
|
|
85
|
+
getGlassMode,
|
|
86
|
+
useScheme,
|
|
87
|
+
useReducedMotion
|
|
88
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires a screen's scroll to the folding of its floating bars.
|
|
3
|
+
*
|
|
4
|
+
* The screen passes `onScroll` to its list (with `scrollEventThrottle={16}`)
|
|
5
|
+
* and `collapse` to `TabBar` / `FloatingPagination`. `collapse` goes from 0
|
|
6
|
+
* (full) to 1 (folded); it glides on a spring, on the UI thread, without
|
|
7
|
+
* re-rendering the screen. With "Reduce motion" on, the bar does not move at
|
|
8
|
+
* all. The decision itself is `followScroll`, tested dry.
|
|
9
|
+
*/
|
|
10
|
+
const { React, Reanimated, useReducedMotion } = require('./runtime');
|
|
11
|
+
const { followScroll, INITIAL_COLLAPSE, COLLAPSED_SCALE, COLLAPSED_DROP } = require('../logic/collapse');
|
|
12
|
+
|
|
13
|
+
/* Destructured once: a worklet must capture `interpolate` itself (a worklet),
|
|
14
|
+
never the whole module object. */
|
|
15
|
+
const { interpolate, useAnimatedStyle, useSharedValue, withSpring } = Reanimated;
|
|
16
|
+
|
|
17
|
+
const SPRING = { damping: 22, stiffness: 240, mass: 0.8 };
|
|
18
|
+
|
|
19
|
+
function useCollapsingBar() {
|
|
20
|
+
const collapse = useSharedValue(0);
|
|
21
|
+
const state = React.useRef(INITIAL_COLLAPSE);
|
|
22
|
+
const reduceMotion = useReducedMotion();
|
|
23
|
+
|
|
24
|
+
const onScroll = React.useCallback(
|
|
25
|
+
(event) => {
|
|
26
|
+
if (reduceMotion) return;
|
|
27
|
+
const next = followScroll(state.current, event.nativeEvent.contentOffset.y);
|
|
28
|
+
if (next.collapsed !== state.current.collapsed) {
|
|
29
|
+
collapse.value = withSpring(next.collapsed ? 1 : 0, SPRING);
|
|
30
|
+
}
|
|
31
|
+
state.current = next;
|
|
32
|
+
},
|
|
33
|
+
[reduceMotion, collapse]
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return { collapse, onScroll };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The fold itself, shared by every floating bar so they fold alike: toward
|
|
41
|
+
* the bottom, to COLLAPSED_SCALE. Without a `collapse` value wired, the bar
|
|
42
|
+
* simply stays full. Pair it with `transformOrigin: 'bottom'`.
|
|
43
|
+
*/
|
|
44
|
+
function useCollapseTransform(collapse) {
|
|
45
|
+
const resting = useSharedValue(0);
|
|
46
|
+
const progress = collapse || resting;
|
|
47
|
+
return useAnimatedStyle(() => ({
|
|
48
|
+
transform: [
|
|
49
|
+
{ translateY: interpolate(progress.value, [0, 1], [0, COLLAPSED_DROP]) },
|
|
50
|
+
{ scale: interpolate(progress.value, [0, 1], [1, COLLAPSED_SCALE]) }
|
|
51
|
+
]
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { useCollapsingBar, useCollapseTransform };
|