@oxyhq/bloom 1.10.1 → 1.12.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/docs/layout.mdx +40 -3
- package/lib/commonjs/fab/Fab.js +55 -4
- package/lib/commonjs/fab/Fab.js.map +1 -1
- package/lib/commonjs/fab/Fab.web.js +29 -1
- package/lib/commonjs/fab/Fab.web.js.map +1 -1
- package/lib/commonjs/layout/bottom-edge.js +93 -26
- package/lib/commonjs/layout/bottom-edge.js.map +1 -1
- package/lib/commonjs/layout/index.js +6 -0
- package/lib/commonjs/layout/index.js.map +1 -1
- package/lib/commonjs/tab-bar/TabBarBase.js +22 -6
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/fab/Fab.js +57 -6
- package/lib/module/fab/Fab.js.map +1 -1
- package/lib/module/fab/Fab.web.js +30 -2
- package/lib/module/fab/Fab.web.js.map +1 -1
- package/lib/module/layout/bottom-edge.js +92 -26
- package/lib/module/layout/bottom-edge.js.map +1 -1
- package/lib/module/layout/index.js +1 -1
- package/lib/module/layout/index.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +24 -8
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.d.ts.map +1 -1
- package/lib/typescript/commonjs/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/layout/bottom-edge.d.ts +58 -10
- package/lib/typescript/commonjs/layout/bottom-edge.d.ts.map +1 -1
- package/lib/typescript/commonjs/layout/index.d.ts +1 -1
- package/lib/typescript/commonjs/layout/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.d.ts.map +1 -1
- package/lib/typescript/module/fab/Fab.web.d.ts.map +1 -1
- package/lib/typescript/module/layout/bottom-edge.d.ts +58 -10
- package/lib/typescript/module/layout/bottom-edge.d.ts.map +1 -1
- package/lib/typescript/module/layout/index.d.ts +1 -1
- package/lib/typescript/module/layout/index.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/fab/Fab.tsx +57 -4
- package/src/fab/Fab.web.tsx +31 -2
- package/src/layout/bottom-edge.tsx +109 -33
- package/src/layout/index.ts +6 -1
- package/src/tab-bar/TabBarBase.tsx +28 -6
|
@@ -27,6 +27,33 @@
|
|
|
27
27
|
* edge, so two surfaces at that edge overlap rather than stack; the tallest is
|
|
28
28
|
* what a new surface has to clear. Summing would strand it at twice the height.
|
|
29
29
|
*
|
|
30
|
+
* ## A size and a state, because they travel differently
|
|
31
|
+
*
|
|
32
|
+
* A surface that collapses — the tab bar minimizes 58 -> 44 on scroll — publishes
|
|
33
|
+
* two things, and only one of them is a measurement:
|
|
34
|
+
*
|
|
35
|
+
* - RESERVED (`useBottomEdgeInset`) never shrinks while the claimant is
|
|
36
|
+
* mounted. It is what must be kept permanently free: a list's bottom
|
|
37
|
+
* padding, a toast stack's offset. Shrinking it on collapse would jitter a
|
|
38
|
+
* list's content size on every scroll and jump a toast 14px mid-display,
|
|
39
|
+
* because the bar re-expands the instant the user scrolls back up.
|
|
40
|
+
* - COLLAPSED (`useBottomEdgeCollapsed`) is a boolean, deliberately, and this
|
|
41
|
+
* is the part that was learned the hard way. It first shipped as a live
|
|
42
|
+
* PIXEL height so a FAB could ride down with the bar — and it felt broken on
|
|
43
|
+
* a device. The bar animates on the UI thread; a React consumer learns about
|
|
44
|
+
* the change through `runOnJS` plus two render passes, so its motion STARTS
|
|
45
|
+
* one to three frames late, worst exactly while scrolling because that is
|
|
46
|
+
* when the JS thread is busiest. No spring config fixes a variable start
|
|
47
|
+
* delay.
|
|
48
|
+
*
|
|
49
|
+
* Two things moving together betray that lag; a state change does not. A reader
|
|
50
|
+
* that FADES on this boolean has no spatial relationship to violate, so the same
|
|
51
|
+
* few frames of latency are imperceptible. That is why the channel is a state and
|
|
52
|
+
* not a geometry: it is the shape of signal that survives the trip to the JS
|
|
53
|
+
* thread. Anything that genuinely must track the collapse per-frame has to read
|
|
54
|
+
* the tab bar's own shared value on the UI thread instead — React state is the
|
|
55
|
+
* wrong transport for it, at any width.
|
|
56
|
+
*
|
|
30
57
|
* ## Why an external store
|
|
31
58
|
*
|
|
32
59
|
* The claim set is mutable state living outside React. Reading it in a memoized
|
|
@@ -45,33 +72,50 @@ import {
|
|
|
45
72
|
type PropsWithChildren,
|
|
46
73
|
} from 'react';
|
|
47
74
|
|
|
75
|
+
interface Claim {
|
|
76
|
+
/** What the surface keeps permanently free, collapsed or not. */
|
|
77
|
+
reserved: number;
|
|
78
|
+
/** Whether it is currently in its collapsed state. */
|
|
79
|
+
collapsed: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
48
82
|
interface BottomEdgeStore {
|
|
49
83
|
subscribe: (onChange: () => void) => () => void;
|
|
50
84
|
/**
|
|
51
|
-
* The cached
|
|
52
|
-
* — `useSyncExternalStore` re-renders forever if the snapshot is
|
|
53
|
-
* per call
|
|
85
|
+
* The cached totals. Each returns the SAME number until a claim actually
|
|
86
|
+
* changes it — `useSyncExternalStore` re-renders forever if the snapshot is
|
|
87
|
+
* recomputed per call, and a reader of one channel must not re-render when
|
|
88
|
+
* only the other moved.
|
|
54
89
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
getReserved: () => number;
|
|
91
|
+
getCollapsed: () => boolean;
|
|
92
|
+
claim: (id: string, reserved: number, collapsed: boolean) => void;
|
|
57
93
|
release: (id: string) => void;
|
|
58
94
|
}
|
|
59
95
|
|
|
60
96
|
function createBottomEdgeStore(): BottomEdgeStore {
|
|
61
|
-
const claims = new Map<string,
|
|
97
|
+
const claims = new Map<string, Claim>();
|
|
62
98
|
const listeners = new Set<() => void>();
|
|
63
|
-
let
|
|
99
|
+
let reserved = 0;
|
|
100
|
+
let collapsed = false;
|
|
64
101
|
|
|
65
102
|
const recompute = () => {
|
|
66
|
-
let
|
|
67
|
-
|
|
68
|
-
|
|
103
|
+
let nextReserved = 0;
|
|
104
|
+
// ANY claimant collapsing collapses the edge. With the one floating bar this
|
|
105
|
+
// is written for the two readings coincide; where they would not, "something
|
|
106
|
+
// at this edge just retracted" is still the signal a reader wants, and it is
|
|
107
|
+
// the safe direction — a FAB that fades when it did not strictly have to
|
|
108
|
+
// beats one that stays put over a bar that left.
|
|
109
|
+
let nextCollapsed = false;
|
|
110
|
+
for (const claim of claims.values()) {
|
|
111
|
+
if (claim.reserved > nextReserved) nextReserved = claim.reserved;
|
|
112
|
+
if (claim.collapsed) nextCollapsed = true;
|
|
69
113
|
}
|
|
70
|
-
// Bail before notifying: a re-registration at an unchanged
|
|
71
|
-
// render of a claimant
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
// Bail before notifying: a re-registration at an unchanged footprint (every
|
|
115
|
+
// render of a claimant that did not move) must not re-render every reader.
|
|
116
|
+
if (nextReserved === reserved && nextCollapsed === collapsed) return;
|
|
117
|
+
reserved = nextReserved;
|
|
118
|
+
collapsed = nextCollapsed;
|
|
75
119
|
for (const listener of listeners) listener();
|
|
76
120
|
};
|
|
77
121
|
|
|
@@ -82,10 +126,12 @@ function createBottomEdgeStore(): BottomEdgeStore {
|
|
|
82
126
|
listeners.delete(onChange);
|
|
83
127
|
};
|
|
84
128
|
},
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
claims.
|
|
129
|
+
getReserved: () => reserved,
|
|
130
|
+
getCollapsed: () => collapsed,
|
|
131
|
+
claim(id, nextReserved, nextCollapsed) {
|
|
132
|
+
const existing = claims.get(id);
|
|
133
|
+
if (existing?.reserved === nextReserved && existing.collapsed === nextCollapsed) return;
|
|
134
|
+
claims.set(id, { reserved: nextReserved, collapsed: nextCollapsed });
|
|
89
135
|
recompute();
|
|
90
136
|
},
|
|
91
137
|
release(id) {
|
|
@@ -114,15 +160,18 @@ BottomEdgeProvider.displayName = 'BottomEdgeProvider';
|
|
|
114
160
|
// would resubscribe `useSyncExternalStore` on every render.
|
|
115
161
|
const NO_SUBSCRIPTION = () => () => {};
|
|
116
162
|
const NO_INSET = () => 0;
|
|
163
|
+
const NO_COLLAPSE = () => false;
|
|
117
164
|
|
|
118
165
|
/**
|
|
119
|
-
* How much of the bottom edge is
|
|
166
|
+
* How much of the bottom edge is permanently RESERVED, in px.
|
|
120
167
|
*
|
|
121
|
-
*
|
|
168
|
+
* Never shrinks while its claimant is mounted, so it is the number for anything
|
|
169
|
+
* that must not move as the user scrolls — a list's bottom padding, a toast
|
|
170
|
+
* stack's offset:
|
|
122
171
|
*
|
|
123
172
|
* ```tsx
|
|
124
|
-
* const
|
|
125
|
-
* <
|
|
173
|
+
* const reserved = useBottomEdgeInset();
|
|
174
|
+
* <FlatList contentContainerStyle={{ paddingBottom: reserved + 12 }} />
|
|
126
175
|
* ```
|
|
127
176
|
*
|
|
128
177
|
* `0` outside a provider, and `0` on the first commit even inside one — a claim
|
|
@@ -134,29 +183,56 @@ export function useBottomEdgeInset(): number {
|
|
|
134
183
|
const store = useContext(BottomEdgeContext);
|
|
135
184
|
return useSyncExternalStore(
|
|
136
185
|
store?.subscribe ?? NO_SUBSCRIPTION,
|
|
137
|
-
store?.
|
|
138
|
-
store?.
|
|
186
|
+
store?.getReserved ?? NO_INSET,
|
|
187
|
+
store?.getReserved ?? NO_INSET,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Whether the surface owning the bottom edge is currently COLLAPSED — the tab
|
|
193
|
+
* bar minimized on scroll.
|
|
194
|
+
*
|
|
195
|
+
* A boolean rather than a live height, on purpose: see the note at the top of
|
|
196
|
+
* this file. A reader should FADE or otherwise change state on it, never try to
|
|
197
|
+
* stay geometrically locked to the collapsing surface — that lock is what a
|
|
198
|
+
* React-state channel cannot deliver, because the surface animates on the UI
|
|
199
|
+
* thread and this arrives one to three frames later.
|
|
200
|
+
*
|
|
201
|
+
* ```tsx
|
|
202
|
+
* const collapsed = useBottomEdgeCollapsed();
|
|
203
|
+
* // fade out; do not chase the bar's new top edge
|
|
204
|
+
* ```
|
|
205
|
+
*
|
|
206
|
+
* `false` outside a provider.
|
|
207
|
+
*/
|
|
208
|
+
export function useBottomEdgeCollapsed(): boolean {
|
|
209
|
+
const store = useContext(BottomEdgeContext);
|
|
210
|
+
return useSyncExternalStore(
|
|
211
|
+
store?.subscribe ?? NO_SUBSCRIPTION,
|
|
212
|
+
store?.getCollapsed ?? NO_COLLAPSE,
|
|
213
|
+
store?.getCollapsed ?? NO_COLLAPSE,
|
|
139
214
|
);
|
|
140
215
|
}
|
|
141
216
|
|
|
142
217
|
/**
|
|
143
|
-
* Claim
|
|
218
|
+
* Claim the bottom edge for as long as the caller is mounted.
|
|
144
219
|
*
|
|
145
220
|
* The claimant owns its own placement — claiming does not move it. It declares
|
|
146
|
-
* the space it occupies so that everything reading
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
221
|
+
* the space it occupies so that everything reading the edge stays off it. Pass
|
|
222
|
+
* the FULL footprint (the surface's height plus the gap it holds off the window
|
|
223
|
+
* edge), which is the same number the surface positions itself with, and keep it
|
|
224
|
+
* at the surface's FULL size even while collapsed: `reserved` is what must stay
|
|
225
|
+
* permanently free, and the collapse is reported separately by `collapsed`.
|
|
150
226
|
*
|
|
151
227
|
* A no-op outside a provider, so a surface stays usable standalone.
|
|
152
228
|
*/
|
|
153
|
-
export function useClaimBottomEdge(
|
|
229
|
+
export function useClaimBottomEdge(reserved: number, collapsed = false): void {
|
|
154
230
|
const store = useContext(BottomEdgeContext);
|
|
155
231
|
const id = useId();
|
|
156
232
|
|
|
157
233
|
useEffect(() => {
|
|
158
234
|
if (!store) return;
|
|
159
|
-
store.claim(id,
|
|
235
|
+
store.claim(id, reserved, collapsed);
|
|
160
236
|
return () => store.release(id);
|
|
161
|
-
}, [store, id,
|
|
237
|
+
}, [store, id, reserved, collapsed]);
|
|
162
238
|
}
|
package/src/layout/index.ts
CHANGED
|
@@ -5,4 +5,9 @@
|
|
|
5
5
|
* parked there". A surface that floats at the bottom needs both.
|
|
6
6
|
*/
|
|
7
7
|
export { EDGE_GAP, windowEdgeGap } from './edge';
|
|
8
|
-
export {
|
|
8
|
+
export {
|
|
9
|
+
BottomEdgeProvider,
|
|
10
|
+
useBottomEdgeInset,
|
|
11
|
+
useBottomEdgeCollapsed,
|
|
12
|
+
useClaimBottomEdge,
|
|
13
|
+
} from './bottom-edge';
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
useContext,
|
|
21
21
|
useEffect,
|
|
22
22
|
useMemo,
|
|
23
|
+
useState,
|
|
23
24
|
type ComponentType,
|
|
24
25
|
} from 'react';
|
|
25
26
|
import { Pressable, StyleSheet, useWindowDimensions, View, type ViewStyle } from 'react-native';
|
|
@@ -29,6 +30,7 @@ import Animated, {
|
|
|
29
30
|
interpolate,
|
|
30
31
|
interpolateColor,
|
|
31
32
|
runOnJS,
|
|
33
|
+
useAnimatedReaction,
|
|
32
34
|
useAnimatedStyle,
|
|
33
35
|
useSharedValue,
|
|
34
36
|
withSpring,
|
|
@@ -422,13 +424,33 @@ function TabBarBody({
|
|
|
422
424
|
// its own layout can never drift from where the bar actually sits.
|
|
423
425
|
const bottomOffset = windowEdgeGap(insets.bottom);
|
|
424
426
|
|
|
427
|
+
// Whether the bar has settled into its minimized size, as REACT state.
|
|
428
|
+
//
|
|
429
|
+
// Reacting on `target` rather than on `progress` is what keeps this cheap:
|
|
430
|
+
// `target` is the binary 0/1 the minimize spring is heading for, so this fires
|
|
431
|
+
// twice per scroll gesture instead of once per frame. Crossing `progress > 0.5`
|
|
432
|
+
// would work too and would cost the same, but it would report the change
|
|
433
|
+
// halfway through the animation rather than when it was decided.
|
|
434
|
+
const [isMinimized, setIsMinimized] = useState(false);
|
|
435
|
+
useAnimatedReaction(
|
|
436
|
+
() => minimized.target.value,
|
|
437
|
+
(target, previous) => {
|
|
438
|
+
if (target !== previous) runOnJS(setIsMinimized)(target === 1);
|
|
439
|
+
},
|
|
440
|
+
[minimized],
|
|
441
|
+
);
|
|
442
|
+
|
|
425
443
|
// Publish what the bar occupies so anything else at this edge stacks above it
|
|
426
|
-
// rather than behind it. Same
|
|
427
|
-
// the same
|
|
428
|
-
// where the bar actually sits.
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
|
|
444
|
+
// rather than behind it. Same numbers `useTabBarFootprint` reports, derived
|
|
445
|
+
// from the same geometry, so a consumer reading either can never disagree with
|
|
446
|
+
// where the bar actually sits.
|
|
447
|
+
//
|
|
448
|
+
// RESERVED stays the EXPANDED height even while minimized: the bar re-expands
|
|
449
|
+
// the moment the user scrolls back up, so anything that permanently reserves
|
|
450
|
+
// space (a list's bottom padding, a toast) must keep room for the full pill or
|
|
451
|
+
// it would jitter on every scroll. The collapse travels as its own STATE — see
|
|
452
|
+
// `layout/bottom-edge` for why it is a boolean and not the live height.
|
|
453
|
+
useClaimBottomEdge(bottomOffset + EXPANDED_HEIGHT, isMinimized);
|
|
432
454
|
|
|
433
455
|
// How centring and the animated inset compose: centring is STATIC and belongs
|
|
434
456
|
// to the wrap, the inset stays ANIMATED on the pill inside it. The wrap is
|