@honeypathkar/react-native-lq-bottom-bar 0.0.0-stage → 0.1.1

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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -2
  3. package/android/build.gradle +78 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/com/honeypathkar/lqbottombar/BackdropBlur.kt +428 -0
  6. package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarPackage.kt +34 -0
  7. package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarView.kt +503 -0
  8. package/android/src/main/java/com/honeypathkar/lqbottombar/LQBottomBarViewManager.kt +119 -0
  9. package/android/src/main/java/com/honeypathkar/lqbottombar/TabSelectedEvent.kt +30 -0
  10. package/ios/LQBottomBarView.swift +328 -0
  11. package/ios/LQBottomBarViewManager.m +21 -0
  12. package/ios/LQBottomBarViewManager.swift +36 -0
  13. package/lib/commonjs/NativeBottomBar.d.ts +33 -0
  14. package/lib/commonjs/NativeBottomBar.d.ts.map +1 -0
  15. package/lib/commonjs/NativeBottomBar.js +9 -0
  16. package/lib/commonjs/NativeBottomBar.js.map +1 -0
  17. package/lib/commonjs/index.d.ts +6 -0
  18. package/lib/commonjs/index.d.ts.map +1 -0
  19. package/lib/commonjs/index.js +131 -0
  20. package/lib/commonjs/index.js.map +1 -0
  21. package/lib/commonjs/rasterise.d.ts +20 -0
  22. package/lib/commonjs/rasterise.d.ts.map +1 -0
  23. package/lib/commonjs/rasterise.js +398 -0
  24. package/lib/commonjs/rasterise.js.map +1 -0
  25. package/lib/commonjs/types.d.ts +119 -0
  26. package/lib/commonjs/types.d.ts.map +1 -0
  27. package/lib/commonjs/types.js +3 -0
  28. package/lib/commonjs/types.js.map +1 -0
  29. package/package.json +65 -4
  30. package/react-native-lq-bottom-bar.podspec +27 -0
  31. package/src/NativeBottomBar.ts +43 -0
  32. package/src/index.tsx +139 -0
  33. package/src/rasterise.tsx +444 -0
  34. package/src/types.ts +136 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 honeypathkar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,350 @@
1
- # Temporary Holding Version
1
+ # react-native-lq-bottom-bar
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The **real** iOS `UITabBar` — liquid glass on iOS 26, UIKit's own selection
4
+ animation, the system's accessibility — on **any** screen. Plus a matching
5
+ Android bar with a blurred backdrop.
6
+
7
+ Takes **React icons**, not only images.
8
+
9
+ ```jsx
10
+ import LQBottomBar from '@honeypathkar/react-native-lq-bottom-bar';
11
+ import { House, Ticket, MessageCircle, User } from 'lucide-react-native';
12
+
13
+ <LQBottomBar
14
+ style={{ height: 49 + insets.bottom }}
15
+ selectedIndex={index}
16
+ onTabSelected={setIndex}
17
+ selectedColor="#4F8EF7"
18
+ items={[
19
+ { key: 'home', title: 'Home', icon: <House /> },
20
+ { key: 'events', title: 'Events', icon: <Ticket /> },
21
+ { key: 'chat', title: 'Chats', icon: <MessageCircle /> },
22
+ { key: 'profile', title: 'Profile', icon: <User /> },
23
+ ]}
24
+ />;
25
+ ```
26
+
27
+ ## Why this exists
28
+
29
+ A native tab bar normally only exists inside a `UITabBarController`, and a
30
+ `UITabBarController` only draws its bar for its own child view controllers. So
31
+ a screen pushed **above** a tab navigator cannot have one, and every app ends
32
+ up hand-drawing a replacement that does not quite match.
33
+
34
+ That constraint is real for the **controller**. It is not real for the **bar**.
35
+ `UITabBar` is a plain `UIView`: it can be added to any hierarchy, given
36
+ `UITabBarItem`s, and told which one is selected. Doing that gets the genuine
37
+ control rather than a copy of it —
38
+
39
+ - On iOS 26 the system applies **liquid glass**, including the scroll-edge
40
+ treatment, because the appearance is resolved by the bar itself and not by
41
+ its controller.
42
+ - The selection animation, the icon bounce and the label crossfade are UIKit's
43
+ own.
44
+ - VoiceOver, Dynamic Type, Reduce Motion and the high-contrast variants all
45
+ come for free, because this **is** the system control.
46
+
47
+ ## Install
48
+
49
+ ```sh
50
+ npm install @honeypathkar/react-native-lq-bottom-bar react-native-svg
51
+ cd ios && pod install
52
+ ```
53
+
54
+ Autolinked on both platforms. `react-native-svg` is a peer dependency, needed
55
+ only for React-element icons — which is the point of the package, so you almost
56
+ certainly want it.
57
+
58
+ ---
59
+
60
+ ## Icons
61
+
62
+ `items[].icon` accepts any of:
63
+
64
+ | Form | Example | Platforms |
65
+ | --- | --- | --- |
66
+ | React element | `<House />`, any `<Svg>` | iOS, Android |
67
+ | Render function | `({ selected, color, size }) => <Icon … />` | iOS, Android |
68
+ | Bundled asset | `require('./home.png')` | iOS |
69
+ | Asset catalogue | `{ name: 'home' }` | iOS |
70
+ | SF Symbol | `{ sfSymbol: 'house.fill' }` | iOS |
71
+
72
+ ### ⚠️ Your icon must forward its ref to an `<Svg>`
73
+
74
+ **This is the single most common reason icons do not appear.** Read it before
75
+ anything else.
76
+
77
+ Both platforms draw their own tab items natively, so a React icon has to become
78
+ a PNG before it reaches the bar. The capture is done through react-native-svg's
79
+ `toDataURL()`, which needs a ref on the `<Svg>` itself. A plain function
80
+ component **silently drops the ref**:
81
+
82
+ ```jsx
83
+ // ✗ Ref goes nowhere. Renders a title with no glyph.
84
+ const HomeIcon = ({ size }) => <Svg width={size} height={size}>…</Svg>;
85
+
86
+ // ✓ Ref reaches the Svg.
87
+ const HomeIcon = forwardRef(({ size }, ref) => (
88
+ <Svg ref={ref} width={size} height={size}>…</Svg>
89
+ ));
90
+ HomeIcon.displayName = 'HomeIcon';
91
+ ```
92
+
93
+ Lucide icons already do this — `createLucideIcon` is built on `forwardRef` and
94
+ passes the ref straight to its `<Svg>` — so `lucide-react-native` works
95
+ unmodified. Your own icon components probably do not, unless you wrote them
96
+ that way.
97
+
98
+ The same applies to an icon wrapped in a `<View>`: the ref lands on the wrapper
99
+ and the SVG underneath is unreachable.
100
+
101
+ In development the package **warns** when a capture fails, naming the item:
102
+
103
+ ```
104
+ [lq-bottom-bar] Could not rasterise the icon for "home". The icon must be a
105
+ react-native-svg <Svg> element, or a component that forwards its ref to one …
106
+ ```
107
+
108
+ If you see a bar with labels and no glyphs, check your logs for that line.
109
+
110
+ ### How a React element becomes a tab-bar icon
111
+
112
+ A `UITabBar` renders its items natively and accepts only `UIImage`. That is not
113
+ a limitation to argue with — it is precisely **why** the bar looks and behaves
114
+ like the system's own, since UIKit draws the items rather than compositing
115
+ someone else's views over them. The Android bar draws its own items too.
116
+
117
+ So the package mounts each icon off-screen once, captures it with `toDataURL()`,
118
+ hands the base64 to the native bar, and memoises the result by icon identity.
119
+
120
+ The alternative — overlaying JS icons on top of the native bar — accepts any
121
+ renderer and needs none of this, but forfeits UIKit's selection animation,
122
+ which is the thing the native bar is for. A one-off rasterisation is the
123
+ cheaper trade.
124
+
125
+ **What it costs.** The first frame for an uncached icon shows the title with no
126
+ glyph. Successful captures are cached at module scope, so that is one frame per
127
+ distinct icon per app launch, not per mount.
128
+
129
+ **Failures are not cached across mounts.** An icon that could not be captured is
130
+ retried next time the bar mounts, rather than being written off for the life of
131
+ the JS context — otherwise fixing a broken icon during a Fast Refresh session
132
+ would appear to do nothing, because the failure would outlive the fix.
133
+
134
+ ### Tinting, and the solid face
135
+
136
+ The capture is handed to the platform as a **template**: its alpha channel is
137
+ the shape and the system picks the colour, selected against unselected. One
138
+ artwork covers both states and follows the platform's own treatment.
139
+
140
+ This means you want the **solid, opaque** face of a two-state icon, not the
141
+ outlined one. An outline variant wrapped in `<G opacity="0.5">` bakes
142
+ half-transparency into the alpha and comes out washed out in **both** states:
143
+
144
+ ```jsx
145
+ // Rasterise the solid face. The system decides the colour from there.
146
+ icon: ({ size }) => <MyIcon isActive size={size} />;
147
+ ```
148
+
149
+ Set `tintable: false` on an item whose own colours **are** the content — an
150
+ avatar, a brand mark. It will then look identical selected and not, which is
151
+ right for those and wrong for a glyph.
152
+
153
+ ### Cache keys
154
+
155
+ Captures are memoised by icon identity. Pass a stable `key` per item whenever
156
+ the list is static, which it nearly always is:
157
+
158
+ ```jsx
159
+ { key: 'home', title: 'Home', icon: <House /> }
160
+ ```
161
+
162
+ Without it the key is derived from the icon's component type and a
163
+ serialisation of its props — correct, but it costs that serialisation on every
164
+ render, and it collapses any function-valued prop into a single bucket.
165
+
166
+ ---
167
+
168
+ ## Props
169
+
170
+ | Prop | Type | Default | Notes |
171
+ | --- | --- | --- | --- |
172
+ | `items` | `LQBottomBarItem[]` | — | `{ title?, icon, key?, tintable?, accessibilityLabel? }` |
173
+ | `selectedIndex` | `number` | — | **You own this**; see below |
174
+ | `onTabSelected` | `(index: number) => void` | — | A report, not a commit |
175
+ | `selectedColor` | `string` | system | Tint for the selected item |
176
+ | `unselectedColor` | `string` | system | Tint for the rest |
177
+ | `labeled` | `boolean` | `true` | `false` hides titles and re-centres the glyphs |
178
+ | `iconSize` | `number` | `24` | Points |
179
+ | `style` | `ViewStyle` | — | The bar is laid out by Yoga; height and width are whatever this says |
180
+ | `pointerEvents` | `ViewProps['pointerEvents']` | — | `"box-none"` lets touches through the empty space around the items |
181
+ | `blurAmount` | `number` | `20` | **Android only.** `0` disables the blur entirely |
182
+ | `androidBackgroundColor` | `string` | `rgba(20,20,20,0.45)` | **Android only.** The material's tint, over the blur — keep it translucent |
183
+
184
+ ### `selectedIndex` is yours, on purpose
185
+
186
+ `onTabSelected` **reports** a press; it does not commit to it. Both bars put
187
+ their highlight back where `selectedIndex` says it is after every press, so a
188
+ tap you decline to act on — one that opens a detail screen instead of switching
189
+ tabs — cannot leave the bar showing the wrong item.
190
+
191
+ ```jsx
192
+ onTabSelected={index => {
193
+ if (index === HOME && activePremise) {
194
+ navigation.navigate('PremiseDetail'); // declined: the bar does not move
195
+ return;
196
+ }
197
+ setIndex(index);
198
+ }}
199
+ ```
200
+
201
+ ---
202
+
203
+ ## Layout: in flow, or overlaid?
204
+
205
+ The bar is sized entirely by `style`. UIKit's standard body is **49pt**,
206
+ excluding the home-indicator inset:
207
+
208
+ ```jsx
209
+ style={{ height: 49 + insets.bottom }}
210
+ ```
211
+
212
+ `marginHorizontal` plus a `borderRadius` narrows it into a floating pill; the
213
+ bar spreads its items evenly across whatever width it ends up with.
214
+
215
+ **Decide early which of these two you are doing, and apply it consistently.**
216
+ Mixing them is the single biggest source of layout bugs with this component.
217
+
218
+ **In flow** — the bar is an ordinary flex child and claims its own band:
219
+
220
+ ```jsx
221
+ <View style={{ flex: 1 }}>
222
+ <View style={{ flex: 1 }}>{screens}</View>
223
+ <LQBottomBar style={{ height: 49 + insets.bottom }} … />
224
+ </View>
225
+ ```
226
+
227
+ Every screen's viewport ends where the bar begins, so a control pinned at
228
+ `bottom: 0` sits above it and **no screen should add clearance** — doing so
229
+ reserves the band twice and opens a strip of dead page. The cost is that a
230
+ translucent `UITabBar` has only your root view behind it, so the material
231
+ renders over whatever colour that is. Give the container a background you are
232
+ happy to see through glass.
233
+
234
+ **Overlaid** — the bar floats over the content:
235
+
236
+ ```jsx
237
+ <LQBottomBar
238
+ pointerEvents="box-none"
239
+ style={{ position: 'absolute', left: 0, right: 0, bottom: 0,
240
+ height: 49 + insets.bottom }}
241
+ …
242
+ />
243
+ ```
244
+
245
+ This is what the material is designed for: the page runs underneath and the
246
+ glass refracts it. But then **every** screen must reserve the bar's height
247
+ itself — scroll views via `contentContainerStyle.paddingBottom`, pinned
248
+ controls via `bottom`. Any screen that forgets gets its bottom content covered.
249
+
250
+ ---
251
+
252
+ ## Android
253
+
254
+ There is no system bottom bar to borrow — Material's `NavigationBarView` is a
255
+ different control with its own shape and opinions — so the Android bar is drawn
256
+ to match: same dimensions, same tint crossfade, same icon bounce on UIKit's own
257
+ timing curve, with a blurred backdrop standing in for the iOS material.
258
+
259
+ Styling comes from React Native's standard props, because the view subclasses
260
+ `ReactViewGroup`: `borderRadius`, `backgroundColor`, `overflow` and elevation
261
+ all work as they do on a `<View>`.
262
+
263
+ ### The blur, and its one real limitation
264
+
265
+ Android has no API for blurring what is **behind** a view.
266
+ `RenderEffect.createBlurEffect` blurs a view's *own* content, and
267
+ `Window.setBackgroundBlurRadius` applies to a window, not a view inside one. So
268
+ the backdrop is produced the way every Android blur library produces it: draw
269
+ the root view into a small off-screen bitmap, blur that, and paint it under the
270
+ bar's contents. `RenderEffect` does the blur on API 31+; a three-pass box blur
271
+ does it below that.
272
+
273
+ That technique uses a **software** draw, and content the GPU owns exclusively
274
+ does not participate. A `SurfaceView` or `TextureView` — **video, a map, a
275
+ camera preview** — captures as a blank region, so the bar reads as frosted over
276
+ nothing there. There is no fix available from inside the view hierarchy. Where
277
+ that matters, pass `blurAmount={0}` and let the flat `androidBackgroundColor`
278
+ scrim carry the bar; the capture work and its per-frame cost stop entirely.
279
+
280
+ No JitPack dependency is involved, deliberately — the obvious off-the-shelf
281
+ choice would force every consuming app to add that repository to its own
282
+ `settings.gradle` before this package would resolve.
283
+
284
+ ---
285
+
286
+ ## Troubleshooting
287
+
288
+ **Labels show but glyphs do not.** Your icon component is not forwarding its
289
+ ref to an `<Svg>`. See the warning section above — this is nearly always it.
290
+
291
+ **The bar is lit on the wrong tab.** Fixed in 0.1.1. The bar restores its
292
+ highlight to `selectedIndex` after every press, to honour the report-not-commit
293
+ contract; before 0.1.1 it did so synchronously inside UIKit's own selection
294
+ callback, where UIKit overrode it. The highlight stuck to the tapped tab, and
295
+ since `selectedIndex` had not changed value nothing re-applied it — so the bar
296
+ stayed out of step with the app until some other prop change happened to
297
+ correct it. Upgrade.
298
+
299
+ **A white or mismatched strip under the bar.** The bar is in flow and the
300
+ translucent material has only your root view behind it. Give that container a
301
+ background colour, or move to the overlaid layout.
302
+
303
+ **Dead space above the bar.** Something is reserving the bar's height twice —
304
+ usually an in-flow bar plus a screen that also adds bottom clearance.
305
+
306
+ **`Unable to resolve module` when linked with `file:`.** Metro only serves
307
+ files under roots it knows about; add the package's real path to
308
+ `watchFolders`. Metro reads its config once at startup, so restart it.
309
+
310
+ **`Cannot read property 'useMemo' of null` when linked with `file:`.** Two
311
+ copies of React — the linked package has its own in `node_modules`. Pin the
312
+ singletons in `metro.config.js`; `extraNodeModules` will **not** do it, because
313
+ it is only a fallback for modules that fail to resolve, and these resolve fine
314
+ to the wrong copy:
315
+
316
+ ```js
317
+ const SINGLETONS = ['react', 'react-native', 'react-native-svg'];
318
+
319
+ resolver: {
320
+ resolveRequest: (context, moduleName, platform) =>
321
+ context.resolveRequest(
322
+ SINGLETONS.some(n => moduleName === n || moduleName.startsWith(`${n}/`))
323
+ ? { ...context, originModulePath: path.join(__dirname, 'index.js') }
324
+ : context,
325
+ moduleName,
326
+ platform,
327
+ ),
328
+ }
329
+ ```
330
+
331
+ ---
332
+
333
+ ## Architecture notes
334
+
335
+ Both platforms use a **legacy view manager**, bridged through React Native's
336
+ interop layer, rather than a Fabric component. On iOS a Fabric component view
337
+ must be written in Objective-C++, which cannot host Swift directly and
338
+ interacts badly with `use_frameworks!` linkage; on Android it would need a
339
+ codegen spec kept in step for no behaviour this bar needs.
340
+
341
+ The podspec calls `install_modules_dependencies` rather than depending on
342
+ `React-Core` directly. With the new architecture on, a pod that touches React
343
+ headers needs the whole Fabric/JSI header surface and its flags; `React-Core`
344
+ alone leaves `jsi/jsi.h` off the search path, and the failure then surfaces
345
+ inside React's **own** targets — which looks like a broken app rather than a
346
+ bad podspec.
347
+
348
+ ## License
349
+
350
+ MIT
@@ -0,0 +1,78 @@
1
+ buildscript {
2
+ ext {
3
+ buildToolsVersion = "34.0.0"
4
+ minSdkVersion = 24
5
+ compileSdkVersion = 35
6
+ targetSdkVersion = 35
7
+ kotlinVersion = "1.9.24"
8
+ }
9
+ repositories {
10
+ google()
11
+ mavenCentral()
12
+ }
13
+ dependencies {
14
+ classpath "com.android.tools.build:gradle:8.2.1"
15
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
16
+ }
17
+ }
18
+
19
+ apply plugin: "com.android.library"
20
+ apply plugin: "kotlin-android"
21
+
22
+ android {
23
+ namespace "com.honeypathkar.lqbottombar"
24
+ compileSdkVersion getExtOrDefault("compileSdkVersion", 35)
25
+
26
+ defaultConfig {
27
+ minSdkVersion getExtOrDefault("minSdkVersion", 24)
28
+ targetSdkVersion getExtOrDefault("targetSdkVersion", 35)
29
+ }
30
+
31
+ buildTypes {
32
+ release {
33
+ minifyEnabled false
34
+ }
35
+ }
36
+
37
+ lintOptions {
38
+ disable "GradleCompatible"
39
+ }
40
+
41
+ compileOptions {
42
+ sourceCompatibility JavaVersion.VERSION_17
43
+ targetCompatibility JavaVersion.VERSION_17
44
+ }
45
+
46
+ kotlinOptions {
47
+ jvmTarget = "17"
48
+ }
49
+ }
50
+
51
+ def getExtOrDefault(name, defaultValue) {
52
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : defaultValue
53
+ }
54
+
55
+ def getKotlinVersion() {
56
+ return rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : "1.9.24"
57
+ }
58
+
59
+ repositories {
60
+ google()
61
+ mavenCentral()
62
+ }
63
+
64
+ dependencies {
65
+ implementation "com.facebook.react:react-native:+"
66
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:${getKotlinVersion()}"
67
+ implementation "androidx.core:core-ktx:1.13.1"
68
+
69
+ // NO BLUR LIBRARY, deliberately.
70
+ //
71
+ // The obvious candidate is Dimezis/BlurView, but it is published on
72
+ // JitPack: every consuming app would have to add that repository to its
73
+ // own settings.gradle before this package would even resolve, which is a
74
+ // poor thing to impose from a dependency. The backdrop blur is instead
75
+ // implemented against the platform in BackdropBlur.kt — the same
76
+ // capture-downscale-blur technique, using RenderEffect where the platform
77
+ // has it and a stack blur below that.
78
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />