@honeypathkar/react-native-lq-bottom-bar 0.0.0-stage → 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 +341 -2
- package/android/build.gradle +78 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/BackdropBlur.kt +428 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarPackage.kt +34 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarView.kt +503 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarViewManager.kt +119 -0
- package/android/src/main/java/com/honeypathkar/lqbottombar/TabSelectedEvent.kt +30 -0
- package/ios/LQBottomBarView.swift +287 -0
- package/ios/LQBottomBarViewManager.m +21 -0
- package/ios/LQBottomBarViewManager.swift +36 -0
- package/lib/commonjs/NativeBottomBar.d.ts +33 -0
- package/lib/commonjs/NativeBottomBar.d.ts.map +1 -0
- package/lib/commonjs/NativeBottomBar.js +9 -0
- package/lib/commonjs/NativeBottomBar.js.map +1 -0
- package/lib/commonjs/index.d.ts +6 -0
- package/lib/commonjs/index.d.ts.map +1 -0
- package/lib/commonjs/index.js +131 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/rasterise.d.ts +20 -0
- package/lib/commonjs/rasterise.d.ts.map +1 -0
- package/lib/commonjs/rasterise.js +398 -0
- package/lib/commonjs/rasterise.js.map +1 -0
- package/lib/commonjs/types.d.ts +119 -0
- package/lib/commonjs/types.d.ts.map +1 -0
- package/lib/commonjs/types.js +3 -0
- package/lib/commonjs/types.js.map +1 -0
- package/package.json +65 -4
- package/react-native-lq-bottom-bar.podspec +27 -0
- package/src/NativeBottomBar.ts +43 -0
- package/src/index.tsx +139 -0
- package/src/rasterise.tsx +444 -0
- package/src/types.ts +136 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Platform, requireNativeComponent } from 'react-native';
|
|
2
|
+
import type {
|
|
3
|
+
HostComponent,
|
|
4
|
+
StyleProp,
|
|
5
|
+
ViewProps,
|
|
6
|
+
ViewStyle,
|
|
7
|
+
} from 'react-native';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The native view, on whichever platform this is.
|
|
11
|
+
*
|
|
12
|
+
* Both platforms register the same component name, and both are legacy view
|
|
13
|
+
* managers bridged through React Native's interop layer — so there is one
|
|
14
|
+
* `requireNativeComponent` call and no per-platform file.
|
|
15
|
+
*
|
|
16
|
+
* `null` on anything else rather than absent: `requireNativeComponent` throws
|
|
17
|
+
* at REQUIRE time where the view is not registered, so it cannot sit at module
|
|
18
|
+
* scope unguarded on, say, web.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The shape the native side is declared with, not the public API. */
|
|
22
|
+
export interface NativeBottomBarProps {
|
|
23
|
+
items: unknown[];
|
|
24
|
+
selectedIndex: number;
|
|
25
|
+
selectedColor?: string;
|
|
26
|
+
unselectedColor?: string;
|
|
27
|
+
labeled: boolean;
|
|
28
|
+
onTabSelected?: (event: { nativeEvent: { index: number } }) => void;
|
|
29
|
+
style?: StyleProp<ViewStyle>;
|
|
30
|
+
pointerEvents?: ViewProps['pointerEvents'];
|
|
31
|
+
|
|
32
|
+
/** Android only; the iOS bar gets its material from UIKit. */
|
|
33
|
+
iconSize?: number;
|
|
34
|
+
blurAmount?: number;
|
|
35
|
+
scrimColor?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SUPPORTED = Platform.OS === 'ios' || Platform.OS === 'android';
|
|
39
|
+
|
|
40
|
+
export const NativeBottomBar: HostComponent<NativeBottomBarProps> | null =
|
|
41
|
+
SUPPORTED
|
|
42
|
+
? requireNativeComponent<NativeBottomBarProps>('LQBottomBarView')
|
|
43
|
+
: null;
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React, { useMemo } from 'react';
|
|
2
|
+
import { Image, Platform, StyleSheet } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { NativeBottomBar } from './NativeBottomBar';
|
|
5
|
+
import { useRasterisedIcons } from './rasterise';
|
|
6
|
+
import type {
|
|
7
|
+
LQBottomBarItem,
|
|
8
|
+
LQBottomBarProps,
|
|
9
|
+
LQIcon,
|
|
10
|
+
LQIconState,
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
export type { LQBottomBarItem, LQBottomBarProps, LQIcon, LQIconState };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* THE SYSTEM TAB BAR, HOSTED ANYWHERE.
|
|
17
|
+
*
|
|
18
|
+
* A native tab bar normally only exists inside a `UITabBarController`, which
|
|
19
|
+
* only draws its bar for its own children — so a screen pushed above a tab
|
|
20
|
+
* navigator cannot have one. That is true of the CONTROLLER, not the BAR:
|
|
21
|
+
* `UITabBar` is a plain `UIView`, and hosting one directly gets the real
|
|
22
|
+
* thing rather than a drawing of it. Liquid glass on iOS 26, UIKit's own
|
|
23
|
+
* selection animation, and the platform's accessibility, on any screen.
|
|
24
|
+
*
|
|
25
|
+
* Android has no system bar to borrow, so it draws a matching one: same
|
|
26
|
+
* dimensions, same tint crossfade and icon bounce, with a blurred backdrop
|
|
27
|
+
* standing in for the iOS material.
|
|
28
|
+
*
|
|
29
|
+
* BOTH PLATFORMS DRAW THEIR OWN ITEMS, which is why `src/rasterise.tsx`
|
|
30
|
+
* exists: a view that renders its items natively cannot be handed a React
|
|
31
|
+
* element, so a lucide or SVG icon is captured to a PNG once and the capture
|
|
32
|
+
* is what crosses the bridge. See that file for the trade this makes and what
|
|
33
|
+
* it costs.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ASSETS ARE RESOLVED HERE, and they have to be.
|
|
38
|
+
*
|
|
39
|
+
* `require('./home.png')` evaluates to a NUMBER — an id into React Native's
|
|
40
|
+
* asset registry. The bridge converts that number into `{ uri, scale, ... }`
|
|
41
|
+
* only for props it knows are image sources, which it learns from the prop's
|
|
42
|
+
* declared type. `items` is declared as an array, so the bridge has no reason
|
|
43
|
+
* to look inside it: the number crosses to native as a number, the native
|
|
44
|
+
* loader finds nothing it recognises, and the bar draws labels with no
|
|
45
|
+
* glyphs. `Image.resolveAssetSource` does the same conversion by hand, which
|
|
46
|
+
* is what lets a caller keep writing `require(...)`.
|
|
47
|
+
*/
|
|
48
|
+
const resolveIcon = (icon: LQIcon): unknown => {
|
|
49
|
+
if (typeof icon === 'number') return Image.resolveAssetSource(icon);
|
|
50
|
+
return icon;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `overflow: 'hidden'` on Android, always.
|
|
55
|
+
*
|
|
56
|
+
* The blurred backdrop is a real child view, so without clipping it squares
|
|
57
|
+
* off whatever `borderRadius` the caller gave the bar — the corners stay
|
|
58
|
+
* rounded on the bar's own background while the blur fills them in. iOS needs
|
|
59
|
+
* none of this: `UITabBar` shapes its own material.
|
|
60
|
+
*/
|
|
61
|
+
const androidClip = StyleSheet.create({
|
|
62
|
+
bar: { overflow: 'hidden' },
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const LQBottomBar = ({
|
|
66
|
+
items,
|
|
67
|
+
selectedIndex,
|
|
68
|
+
selectedColor,
|
|
69
|
+
unselectedColor,
|
|
70
|
+
labeled = true,
|
|
71
|
+
iconSize = 24,
|
|
72
|
+
onTabSelected,
|
|
73
|
+
style,
|
|
74
|
+
pointerEvents,
|
|
75
|
+
blurAmount = 20,
|
|
76
|
+
androidBackgroundColor = 'rgba(20, 20, 20, 0.45)',
|
|
77
|
+
}: LQBottomBarProps) => {
|
|
78
|
+
const { sources, host } = useRasterisedIcons(items, iconSize);
|
|
79
|
+
|
|
80
|
+
const nativeItems = useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
items.map((item, index) => {
|
|
83
|
+
const rasterised = sources[index];
|
|
84
|
+
return {
|
|
85
|
+
title: item.title,
|
|
86
|
+
/*
|
|
87
|
+
A captured glyph wins; otherwise the icon passes through as the
|
|
88
|
+
image or symbol descriptor it already was — which only iOS can
|
|
89
|
+
resolve. Until a capture lands this is `undefined`, which draws the
|
|
90
|
+
item with its title and no glyph for a frame; see the cost note in
|
|
91
|
+
`rasterise.tsx`.
|
|
92
|
+
*/
|
|
93
|
+
icon: rasterised ? { uri: rasterised } : resolveIcon(item.icon),
|
|
94
|
+
// Default true: one artwork, tinted by the platform for both states.
|
|
95
|
+
tintable: item.tintable !== false,
|
|
96
|
+
accessibilityLabel: item.accessibilityLabel ?? item.title,
|
|
97
|
+
};
|
|
98
|
+
}),
|
|
99
|
+
[items, sources],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (!NativeBottomBar) return null;
|
|
103
|
+
|
|
104
|
+
const platformProps =
|
|
105
|
+
Platform.OS === 'android'
|
|
106
|
+
? {
|
|
107
|
+
iconSize,
|
|
108
|
+
blurAmount,
|
|
109
|
+
scrimColor: androidBackgroundColor,
|
|
110
|
+
}
|
|
111
|
+
: // Passing Android-only props to the iOS manager would log an unknown
|
|
112
|
+
// prop warning for each one on every render.
|
|
113
|
+
null;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
{host}
|
|
118
|
+
<NativeBottomBar
|
|
119
|
+
style={Platform.OS === 'android' ? [style, androidClip.bar] : style}
|
|
120
|
+
pointerEvents={pointerEvents}
|
|
121
|
+
items={nativeItems}
|
|
122
|
+
selectedIndex={selectedIndex}
|
|
123
|
+
selectedColor={selectedColor}
|
|
124
|
+
unselectedColor={unselectedColor}
|
|
125
|
+
labeled={labeled !== false}
|
|
126
|
+
{...platformProps}
|
|
127
|
+
/*
|
|
128
|
+
A REPORT, NOT A COMMIT. Both bars put their highlight back where
|
|
129
|
+
`selectedIndex` says it is, so a tap the caller declines to act on —
|
|
130
|
+
one that opens a detail screen instead of switching tabs — cannot
|
|
131
|
+
leave the bar showing the wrong item.
|
|
132
|
+
*/
|
|
133
|
+
onTabSelected={event => onTabSelected?.(event.nativeEvent.index)}
|
|
134
|
+
/>
|
|
135
|
+
</>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export default LQBottomBar;
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
cloneElement,
|
|
3
|
+
isValidElement,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { PixelRatio, Platform, StyleSheet, View } from 'react-native';
|
|
11
|
+
import type { ReactElement } from 'react';
|
|
12
|
+
|
|
13
|
+
import type { LQBottomBarItem, LQIcon } from './types';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TURNING A REACT ICON INTO A UIImage.
|
|
17
|
+
*
|
|
18
|
+
* `UITabBar` renders its items natively and accepts only `UIImage`. That is
|
|
19
|
+
* not a limitation to argue with — it is the reason the bar looks and behaves
|
|
20
|
+
* like the system's own, since UIKit is drawing the items rather than
|
|
21
|
+
* compositing someone else's views over them.
|
|
22
|
+
*
|
|
23
|
+
* So the icon has to become an image BEFORE it reaches the bar. The way out
|
|
24
|
+
* is react-native-svg's `toDataURL()`: mount the icon off-screen once, ask the
|
|
25
|
+
* native SVG view to render itself into a PNG, hand the base64 to the bar,
|
|
26
|
+
* and remember the result. A lucide icon is a react-native-svg `<Svg>` with a
|
|
27
|
+
* forwarded ref, so one path covers lucide and hand-written SVG alike.
|
|
28
|
+
*
|
|
29
|
+
* THE ALTERNATIVE, for the record: overlay JS icons on top of the native bar.
|
|
30
|
+
* That accepts any renderer and needs none of this file — but it forfeits
|
|
31
|
+
* UIKit's selection animation, which is the thing the native bar is for. A
|
|
32
|
+
* one-off rasterisation is the cheaper trade.
|
|
33
|
+
*
|
|
34
|
+
* WHAT THIS COSTS. The first frame for an uncached icon has a title and no
|
|
35
|
+
* glyph. The cache is module-level, so it is one frame per distinct icon per
|
|
36
|
+
* app launch, not per mount — and a static `items` array, which is the normal
|
|
37
|
+
* case, pays it once at startup.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Captured PNGs, keyed by icon identity. Module scope: see above.
|
|
42
|
+
*
|
|
43
|
+
* SUCCESSES ONLY. An earlier version also stored failures here, as an empty
|
|
44
|
+
* string, to stop an uncapturable icon being retried on every render. That
|
|
45
|
+
* was a trap: this map outlives every mount AND survives Fast Refresh, so a
|
|
46
|
+
* failure recorded once was permanent for the life of the JS context. Fixing
|
|
47
|
+
* the cause — an icon component that did not forward its ref — then changed
|
|
48
|
+
* nothing, because the poisoned entry was still there and the icon was never
|
|
49
|
+
* attempted again. Failures are tracked per mount instead; see `failed`.
|
|
50
|
+
*/
|
|
51
|
+
const cache = new Map<string, string>();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stable ids for component types, so a cache key can name "this icon
|
|
55
|
+
* component" without stringifying a function.
|
|
56
|
+
*
|
|
57
|
+
* Weak, because the key must not be the thing that keeps a lazily-imported
|
|
58
|
+
* icon module alive.
|
|
59
|
+
*/
|
|
60
|
+
const typeIds = new WeakMap<object, string>();
|
|
61
|
+
let nextTypeId = 0;
|
|
62
|
+
|
|
63
|
+
const typeId = (type: unknown): string => {
|
|
64
|
+
if (typeof type === 'string') return type;
|
|
65
|
+
if (typeof type !== 'function' && typeof type !== 'object') return 'anon';
|
|
66
|
+
if (type === null) return 'anon';
|
|
67
|
+
const existing = typeIds.get(type as object);
|
|
68
|
+
if (existing) return existing;
|
|
69
|
+
const id = `t${nextTypeId++}`;
|
|
70
|
+
typeIds.set(type as object, id);
|
|
71
|
+
return id;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* OPAQUE BLACK, and the colour genuinely does not matter.
|
|
76
|
+
*
|
|
77
|
+
* The capture is handed to UIKit as a TEMPLATE image: the alpha channel is
|
|
78
|
+
* the shape and the system chooses the colour. What does matter is that the
|
|
79
|
+
* colour is RESOLVABLE — lucide defaults `color` to the literal string
|
|
80
|
+
* `"currentColor"`, which resolves against the `<Svg>`'s own `color` prop, and
|
|
81
|
+
* with nothing to resolve against the glyph rasterises empty. Setting `color`
|
|
82
|
+
* on the root is what gives `currentColor` a value.
|
|
83
|
+
*/
|
|
84
|
+
const RASTER_COLOR = '#000000';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The dimension to ask `toDataURL` for, in whichever unit this platform means
|
|
88
|
+
* by it. See the long note at the call site for why they disagree.
|
|
89
|
+
*/
|
|
90
|
+
const captureSize = (size: number): number =>
|
|
91
|
+
Platform.OS === 'android' ? Math.round(PixelRatio.get() * size) : size;
|
|
92
|
+
|
|
93
|
+
/** Props only ever serialised for the cache key, never compared deeply. */
|
|
94
|
+
const stableStringify = (value: unknown): string => {
|
|
95
|
+
if (value === null || typeof value !== 'object') {
|
|
96
|
+
// A function in props cannot be serialised, and two renders produce two
|
|
97
|
+
// identities. Collapse it to its type: a caller whose icon varies by a
|
|
98
|
+
// callback should pass an explicit `key`.
|
|
99
|
+
return typeof value === 'function' ? 'fn' : String(value);
|
|
100
|
+
}
|
|
101
|
+
if (Array.isArray(value)) {
|
|
102
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
103
|
+
}
|
|
104
|
+
const record = value as Record<string, unknown>;
|
|
105
|
+
const keys = Object.keys(record).sort();
|
|
106
|
+
return `{${keys
|
|
107
|
+
.map(k => `${k}:${stableStringify(record[k])}`)
|
|
108
|
+
.join(',')}}`;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** True for the two icon forms that have to be drawn before iOS can use them. */
|
|
112
|
+
const needsRaster = (
|
|
113
|
+
icon: LQIcon,
|
|
114
|
+
): icon is ReactElement | ((state: never) => ReactElement) =>
|
|
115
|
+
typeof icon === 'function' || isValidElement(icon);
|
|
116
|
+
|
|
117
|
+
const cacheKey = (item: LQBottomBarItem, size: number): string | null => {
|
|
118
|
+
const { icon } = item;
|
|
119
|
+
if (!needsRaster(icon)) return null;
|
|
120
|
+
|
|
121
|
+
const base = item.key
|
|
122
|
+
? `k:${item.key}`
|
|
123
|
+
: typeof icon === 'function'
|
|
124
|
+
? `f:${typeId(icon)}`
|
|
125
|
+
: `e:${typeId((icon as ReactElement).type)}:${stableStringify(
|
|
126
|
+
(icon as ReactElement).props,
|
|
127
|
+
)}`;
|
|
128
|
+
|
|
129
|
+
return `${base}|${size}`;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve an icon to the element that should actually be drawn, sized and
|
|
134
|
+
* coloured for capture.
|
|
135
|
+
*/
|
|
136
|
+
const rasterElement = (icon: LQIcon, size: number): ReactElement | null => {
|
|
137
|
+
if (typeof icon === 'function') {
|
|
138
|
+
/*
|
|
139
|
+
ALWAYS THE UNSELECTED FACE. UIKit owns the selected appearance: it tints
|
|
140
|
+
the one template image it was given. Capturing the selected face too
|
|
141
|
+
would produce a second artwork fighting the system's own treatment —
|
|
142
|
+
which is also why `tintable` exists for icons that should opt out.
|
|
143
|
+
*/
|
|
144
|
+
return icon({ selected: false, color: RASTER_COLOR, size });
|
|
145
|
+
}
|
|
146
|
+
if (!isValidElement(icon)) return null;
|
|
147
|
+
|
|
148
|
+
const props = (icon.props ?? {}) as Record<string, unknown>;
|
|
149
|
+
return cloneElement(icon as ReactElement<Record<string, unknown>>, {
|
|
150
|
+
// Only filled in where the caller left them out, so an icon that sets its
|
|
151
|
+
// own size or colours keeps them.
|
|
152
|
+
size: props.size ?? size,
|
|
153
|
+
width: props.width ?? size,
|
|
154
|
+
height: props.height ?? size,
|
|
155
|
+
color: props.color ?? RASTER_COLOR,
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* How long to wait for a capture before re-issuing it, and how many times.
|
|
161
|
+
*
|
|
162
|
+
* Eight tries at 100ms is 800ms of patience before an icon is written off,
|
|
163
|
+
* which is generous for something that normally lands on the first frame and
|
|
164
|
+
* still short enough that a genuinely uncapturable icon does not hold the
|
|
165
|
+
* off-screen host mounted for the session.
|
|
166
|
+
*/
|
|
167
|
+
const RETRY_MS = 100;
|
|
168
|
+
const MAX_TRIES = 8;
|
|
169
|
+
|
|
170
|
+
interface CaptureProps {
|
|
171
|
+
cacheKey: string;
|
|
172
|
+
node: ReactElement;
|
|
173
|
+
size: number;
|
|
174
|
+
onCaptured: (key: string, dataUri: string | null) => void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* One off-screen icon, captured once.
|
|
179
|
+
*
|
|
180
|
+
* `toDataURL` needs the NATIVE view to exist and, on Android, to have been
|
|
181
|
+
* drawn at least once — neither of which is true when the effect first runs:
|
|
182
|
+
* React has committed the tree, but the native view is mounted and laid out a
|
|
183
|
+
* frame later. Hence the frame of delay.
|
|
184
|
+
*
|
|
185
|
+
* THE WATCHDOG IS NOT BELT-AND-BRACES, it is the only thing covering the worst
|
|
186
|
+
* failure mode. On Android `SvgViewModule.toDataURL` checks `notRendered()`
|
|
187
|
+
* and, if the view has not drawn yet, parks the request as a task to be run
|
|
188
|
+
* from the next `onDraw` — and returns WITHOUT invoking the callback. So a
|
|
189
|
+
* retry driven from inside the callback cannot fire: nothing ever calls it.
|
|
190
|
+
* Re-issuing the whole request on a timer is what breaks that deadlock, and
|
|
191
|
+
* the attempt cap is what stops it running forever if the icon simply cannot
|
|
192
|
+
* be drawn.
|
|
193
|
+
*/
|
|
194
|
+
const Capture = ({ cacheKey: key, node, size, onCaptured }: CaptureProps) => {
|
|
195
|
+
const ref = useRef<{ toDataURL?: Function } | null>(null);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Survives effect re-runs on purpose.
|
|
199
|
+
*
|
|
200
|
+
* `cancelled` below is per-run, so a re-run restarts a capture that has not
|
|
201
|
+
* landed. This one is per-COMPONENT, so a capture that already reported is
|
|
202
|
+
* never reported twice — which it would be if both flags were the same ref,
|
|
203
|
+
* and which the earlier version of this file got wrong in the other
|
|
204
|
+
* direction: a cleanup that set the shared flag killed every subsequent
|
|
205
|
+
* attempt, so any re-render before the first capture landed meant no glyph
|
|
206
|
+
* ever appeared.
|
|
207
|
+
*/
|
|
208
|
+
const reported = useRef(false);
|
|
209
|
+
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
let cancelled = false;
|
|
212
|
+
let tries = 0;
|
|
213
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
214
|
+
let frame = 0;
|
|
215
|
+
|
|
216
|
+
const report = (dataUri: string | null) => {
|
|
217
|
+
if (cancelled || reported.current) return;
|
|
218
|
+
reported.current = true;
|
|
219
|
+
if (watchdog) clearTimeout(watchdog);
|
|
220
|
+
onCaptured(key, dataUri);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const issue = () => {
|
|
224
|
+
if (cancelled || reported.current) return;
|
|
225
|
+
|
|
226
|
+
if (tries >= MAX_TRIES) {
|
|
227
|
+
report(null);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
tries += 1;
|
|
231
|
+
|
|
232
|
+
const svg = ref.current;
|
|
233
|
+
if (!svg || typeof svg.toDataURL !== 'function') {
|
|
234
|
+
/*
|
|
235
|
+
Either the native view is not up yet, or this is not an <Svg> root at
|
|
236
|
+
all — the usual cause of the latter being an icon wrapped in a <View>,
|
|
237
|
+
where the ref lands on the wrapper and the SVG underneath is
|
|
238
|
+
unreachable. Both are retried, because the first resolves itself and
|
|
239
|
+
the second costs only the attempt budget before falling through to a
|
|
240
|
+
reported miss.
|
|
241
|
+
*/
|
|
242
|
+
watchdog = setTimeout(issue, RETRY_MS);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
watchdog = setTimeout(issue, RETRY_MS);
|
|
247
|
+
|
|
248
|
+
svg.toDataURL(
|
|
249
|
+
(base64: string) => {
|
|
250
|
+
if (base64) {
|
|
251
|
+
/*
|
|
252
|
+
`toDataURL` yields BARE base64, not a data URI — the name is a
|
|
253
|
+
long-standing misnomer in react-native-svg. The prefix is added
|
|
254
|
+
here so the native side receives something it recognises as an
|
|
255
|
+
image source.
|
|
256
|
+
*/
|
|
257
|
+
report(`data:image/png;base64,${base64}`);
|
|
258
|
+
}
|
|
259
|
+
// An empty result is left to the watchdog rather than retried here,
|
|
260
|
+
// so there is one retry path instead of two that can race.
|
|
261
|
+
},
|
|
262
|
+
/*
|
|
263
|
+
THE UNITS DIFFER BY PLATFORM, and getting it wrong fails quietly in
|
|
264
|
+
two different ways. Hence `captureSize`.
|
|
265
|
+
|
|
266
|
+
iOS takes POINTS. The renderer is a `UIGraphicsImageRenderer` at the
|
|
267
|
+
screen's scale, so a 24-point request yields a 24x24pt / 72x72px PNG
|
|
268
|
+
on a 3x device; PNG carries no scale, so the native side decodes it
|
|
269
|
+
with `UIScreen.main.scale` and the two cancel out. Asking for
|
|
270
|
+
`size * pixelRatio` here would double-apply the scale and draw the
|
|
271
|
+
glyph three times too large.
|
|
272
|
+
|
|
273
|
+
Android takes PIXELS. `SvgView.toDataURL(w, h)` allocates a bitmap of
|
|
274
|
+
exactly w by h pixels and then draws the SVG in the VIEW's own
|
|
275
|
+
coordinate space, which is already in pixels — so a 24-pixel bitmap
|
|
276
|
+
on a 3x device captures the top-left ninth of a 72-pixel glyph and
|
|
277
|
+
discards the rest. The bitmap has to be the full pixel size, and the
|
|
278
|
+
native side scales it back down to `size` dp when it draws.
|
|
279
|
+
*/
|
|
280
|
+
{ width: captureSize(size), height: captureSize(size) },
|
|
281
|
+
);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
frame = requestAnimationFrame(issue);
|
|
285
|
+
return () => {
|
|
286
|
+
cancelled = true;
|
|
287
|
+
if (watchdog) clearTimeout(watchdog);
|
|
288
|
+
cancelAnimationFrame(frame);
|
|
289
|
+
};
|
|
290
|
+
}, [key, size, onCaptured]);
|
|
291
|
+
|
|
292
|
+
return cloneElement(node as ReactElement<{ ref?: unknown }>, { ref });
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const styles = StyleSheet.create({
|
|
296
|
+
/**
|
|
297
|
+
* OFF-SCREEN, not invisible, and NOT CLIPPED.
|
|
298
|
+
*
|
|
299
|
+
* `opacity: 0` and `display: 'none'` are both tempting and both wrong: the
|
|
300
|
+
* first relies on the renderer still drawing a fully transparent subtree,
|
|
301
|
+
* and the second detaches the native view so there is nothing left to
|
|
302
|
+
* capture. Moving it out of the viewport keeps a real, laid-out native view
|
|
303
|
+
* that the user cannot see.
|
|
304
|
+
*
|
|
305
|
+
* No size and no `overflow: 'hidden'` either, which an earlier version had.
|
|
306
|
+
* Pinning the host to 1x1 and clipping is the obvious way to keep it out of
|
|
307
|
+
* the way, but on Android a capture is only possible once the SVG has been
|
|
308
|
+
* drawn at least once — so clipping the icons out of the host is clipping
|
|
309
|
+
* away the very thing being waited on. Without a size the host sizes itself
|
|
310
|
+
* to its content, and being off-screen is enough on its own.
|
|
311
|
+
*/
|
|
312
|
+
host: {
|
|
313
|
+
position: 'absolute',
|
|
314
|
+
left: -10000,
|
|
315
|
+
top: -10000,
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
export interface RasterisedIcons {
|
|
320
|
+
/**
|
|
321
|
+
* Per item: the captured data URI, or `null` when the icon needs no
|
|
322
|
+
* rasterising (an image or SF Symbol) or has not been captured yet.
|
|
323
|
+
*/
|
|
324
|
+
sources: (string | null)[];
|
|
325
|
+
/** Mount this somewhere in the tree. Renders nothing visible. */
|
|
326
|
+
host: ReactElement | null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Capture every React-element icon in `items`, memoised by icon identity.
|
|
331
|
+
*
|
|
332
|
+
* Returns the captured sources plus the off-screen host that does the
|
|
333
|
+
* capturing — the caller has to render the host, since a hook cannot mount
|
|
334
|
+
* views on its own.
|
|
335
|
+
*/
|
|
336
|
+
export const useRasterisedIcons = (
|
|
337
|
+
items: LQBottomBarItem[],
|
|
338
|
+
size: number,
|
|
339
|
+
): RasterisedIcons => {
|
|
340
|
+
const keys = useMemo(
|
|
341
|
+
() => items.map(item => cacheKey(item, size)),
|
|
342
|
+
[items, size],
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Captures that landed during THIS mount.
|
|
347
|
+
*
|
|
348
|
+
* The module cache is the source of truth and survives unmounting, but a
|
|
349
|
+
* write to it is invisible to the reconciler. This mirror is what actually
|
|
350
|
+
* triggers the re-render; it is only ever added to, and it is seeded from
|
|
351
|
+
* nothing because a warm cache needs no re-render — `sources` reads it
|
|
352
|
+
* directly on the first pass.
|
|
353
|
+
*/
|
|
354
|
+
const [captured, setCaptured] = useState<Record<string, string>>({});
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Icons that could not be captured DURING THIS MOUNT.
|
|
358
|
+
*
|
|
359
|
+
* Held in a ref rather than in the module cache so the scope is right in
|
|
360
|
+
* both directions: within a mount an icon is attempted once, so a glyph that
|
|
361
|
+
* genuinely cannot be drawn does not spin; across mounts the slate is clean,
|
|
362
|
+
* so remounting the bar — or a Fast Refresh that fixed the icon — gets
|
|
363
|
+
* another go. A permanent record of failure is what made a fixed icon stay
|
|
364
|
+
* broken until the app was killed.
|
|
365
|
+
*/
|
|
366
|
+
const failed = useRef<Set<string>>(new Set());
|
|
367
|
+
|
|
368
|
+
const onCaptured = useCallback((key: string, dataUri: string | null) => {
|
|
369
|
+
if (dataUri) {
|
|
370
|
+
cache.set(key, dataUri);
|
|
371
|
+
} else {
|
|
372
|
+
failed.current.add(key);
|
|
373
|
+
if (__DEV__) {
|
|
374
|
+
/*
|
|
375
|
+
LOUD, because the failure is otherwise invisible: the item renders
|
|
376
|
+
with its title and no glyph, which reads as a styling problem rather
|
|
377
|
+
than a capture that never happened.
|
|
378
|
+
*/
|
|
379
|
+
console.warn(
|
|
380
|
+
`[lq-bottom-bar] Could not rasterise the icon for "${key}". ` +
|
|
381
|
+
'The icon must be a react-native-svg <Svg> element, or a component ' +
|
|
382
|
+
'that forwards its ref to one — a plain function component drops ' +
|
|
383
|
+
'the ref and cannot be captured. Wrap it in React.forwardRef.',
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
setCaptured(prev => ({ ...prev, [key]: dataUri ?? '' }));
|
|
388
|
+
}, []);
|
|
389
|
+
|
|
390
|
+
const sources = useMemo(
|
|
391
|
+
() =>
|
|
392
|
+
keys.map(key => {
|
|
393
|
+
if (!key) return null;
|
|
394
|
+
return cache.get(key) ?? null;
|
|
395
|
+
}),
|
|
396
|
+
// `captured` is what makes this re-run; the cache it reads is module state.
|
|
397
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
398
|
+
[keys, captured],
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
const pending = useMemo(
|
|
402
|
+
() =>
|
|
403
|
+
keys
|
|
404
|
+
.map((key, index) => ({ key, index }))
|
|
405
|
+
.filter(
|
|
406
|
+
(entry): entry is { key: string; index: number } =>
|
|
407
|
+
entry.key !== null &&
|
|
408
|
+
!cache.has(entry.key) &&
|
|
409
|
+
!failed.current.has(entry.key),
|
|
410
|
+
),
|
|
411
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
412
|
+
[keys, captured],
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const host =
|
|
416
|
+
pending.length === 0 ? null : (
|
|
417
|
+
<View style={styles.host} pointerEvents="none" collapsable={false}>
|
|
418
|
+
{pending.map(({ key, index }) => {
|
|
419
|
+
const node = rasterElement(items[index].icon, size);
|
|
420
|
+
if (!node) return null;
|
|
421
|
+
return (
|
|
422
|
+
/*
|
|
423
|
+
`onCaptured` IS PASSED AS-IS, not wrapped in an arrow.
|
|
424
|
+
|
|
425
|
+
An inline `dataUri => onCaptured(key, dataUri)` is a new function
|
|
426
|
+
identity on every render, which lands in `Capture`'s effect deps
|
|
427
|
+
and re-runs the effect — tearing down an in-flight capture on
|
|
428
|
+
every parent render. The key travels as a prop instead so the
|
|
429
|
+
callback can stay stable.
|
|
430
|
+
*/
|
|
431
|
+
<Capture
|
|
432
|
+
key={key}
|
|
433
|
+
cacheKey={key}
|
|
434
|
+
node={node}
|
|
435
|
+
size={size}
|
|
436
|
+
onCaptured={onCaptured}
|
|
437
|
+
/>
|
|
438
|
+
);
|
|
439
|
+
})}
|
|
440
|
+
</View>
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
return { sources, host };
|
|
444
|
+
};
|