@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,287 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
`RCTViewManager` and `RCTDirectEventBlock` are Objective-C types from
|
|
5
|
+
React-Core. Swift does not see them implicitly: the pod ships a module map
|
|
6
|
+
(`Headers/Public/React/React-Core.modulemap`), so the module has to be
|
|
7
|
+
imported by name like any other.
|
|
8
|
+
*/
|
|
9
|
+
import React
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
THE SYSTEM TAB BAR, HOSTED ANYWHERE.
|
|
13
|
+
|
|
14
|
+
The premise everything else in this app ran aground on was that a native tab
|
|
15
|
+
bar only exists inside a `UITabBarController`, and a `UITabBarController`
|
|
16
|
+
only draws its bar for its own child view controllers. That is true of the
|
|
17
|
+
CONTROLLER. It is not true of the BAR.
|
|
18
|
+
|
|
19
|
+
`UITabBar` is a plain `UIView` subclass. It can be added to any view
|
|
20
|
+
hierarchy, given `UITabBarItem`s, and told which one is selected. Doing that
|
|
21
|
+
gets the real thing and not a copy of it:
|
|
22
|
+
|
|
23
|
+
- On iOS 26 the system applies LIQUID GLASS to it through
|
|
24
|
+
`UITabBarAppearance`, including the scroll-edge treatment, because the
|
|
25
|
+
appearance is resolved by the bar itself and not by its controller.
|
|
26
|
+
- The selection animation, the icon bounce and the label crossfade are
|
|
27
|
+
UIKit's own, driven by `setSelectedItem(_:animated:)`.
|
|
28
|
+
- VoiceOver, Dynamic Type, Reduce Motion and the high-contrast variants
|
|
29
|
+
all come for free, because this IS the system control.
|
|
30
|
+
|
|
31
|
+
So a React Native view that wraps one gives every screen the same bar,
|
|
32
|
+
whether or not it happens to be a child of a tab controller. That is the
|
|
33
|
+
whole point of this package.
|
|
34
|
+
|
|
35
|
+
WHY NOT `UITabBarController` IN A CONTAINER: hosting the controller would
|
|
36
|
+
also host its content area, and React Native already owns the screen's
|
|
37
|
+
content. We want its bar and nothing else, so we take the bar.
|
|
38
|
+
*/
|
|
39
|
+
@objc(LQBottomBarView)
|
|
40
|
+
public final class LQBottomBarView: UIView, UITabBarDelegate {
|
|
41
|
+
|
|
42
|
+
// MARK: - Bridged props
|
|
43
|
+
|
|
44
|
+
/// `[{ title, icon }]`, set from JS. Re-builds the items in place.
|
|
45
|
+
@objc public var items: NSArray = [] {
|
|
46
|
+
didSet { rebuildItems() }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Which item is lit. Setting it animates, which is the point.
|
|
50
|
+
@objc public var selectedIndex: NSNumber = 0 {
|
|
51
|
+
didSet { applySelection() }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Tint for the selected item. `nil` leaves the system default.
|
|
55
|
+
@objc public var selectedColor: UIColor? {
|
|
56
|
+
didSet { tabBar.tintColor = selectedColor }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Tint for everything else.
|
|
60
|
+
@objc public var unselectedColor: UIColor? {
|
|
61
|
+
didSet { applyAppearance() }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
Hide the labels and keep the glyphs.
|
|
66
|
+
|
|
67
|
+
UIKit has no switch for this, so it is done the way Apple's own compact
|
|
68
|
+
bars do it: the title is dropped and the image is nudged down by half the
|
|
69
|
+
space the title would have taken, which re-centres the glyph in the item.
|
|
70
|
+
*/
|
|
71
|
+
@objc public var labeled: Bool = true {
|
|
72
|
+
didSet { rebuildItems() }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Fired on tap. The JS side owns the selection; this only reports.
|
|
76
|
+
@objc public var onTabSelected: RCTDirectEventBlock?
|
|
77
|
+
|
|
78
|
+
// MARK: - View
|
|
79
|
+
|
|
80
|
+
private let tabBar = UITabBar()
|
|
81
|
+
|
|
82
|
+
public override init(frame: CGRect) {
|
|
83
|
+
super.init(frame: frame)
|
|
84
|
+
setUp()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
required init?(coder: NSCoder) {
|
|
88
|
+
super.init(coder: coder)
|
|
89
|
+
setUp()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private func setUp() {
|
|
93
|
+
tabBar.delegate = self
|
|
94
|
+
tabBar.translatesAutoresizingMaskIntoConstraints = false
|
|
95
|
+
addSubview(tabBar)
|
|
96
|
+
|
|
97
|
+
// Pinned to all four edges: React Native sizes this view through Yoga, so
|
|
98
|
+
// the bar takes whatever the layout gives it rather than carrying an
|
|
99
|
+
// intrinsic height of its own.
|
|
100
|
+
NSLayoutConstraint.activate([
|
|
101
|
+
tabBar.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
102
|
+
tabBar.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
103
|
+
tabBar.topAnchor.constraint(equalTo: topAnchor),
|
|
104
|
+
tabBar.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
applyAppearance()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// MARK: - Appearance
|
|
111
|
+
|
|
112
|
+
private func applyAppearance() {
|
|
113
|
+
let appearance = UITabBarAppearance()
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
`configureWithDefaultBackground()`, NOT a flat colour.
|
|
117
|
+
|
|
118
|
+
This is what opts the bar into the system material — on iOS 26 that is
|
|
119
|
+
liquid glass, below it a blur. Setting `backgroundColor` instead paints
|
|
120
|
+
an opaque sheet over the material, which is exactly how every hand-built
|
|
121
|
+
version of this bar ended up looking like a milky panel.
|
|
122
|
+
*/
|
|
123
|
+
appearance.configureWithDefaultBackground()
|
|
124
|
+
|
|
125
|
+
if let unselected = unselectedColor {
|
|
126
|
+
let items: [UITabBarItemAppearance] = [
|
|
127
|
+
appearance.stackedLayoutAppearance,
|
|
128
|
+
appearance.inlineLayoutAppearance,
|
|
129
|
+
appearance.compactInlineLayoutAppearance,
|
|
130
|
+
]
|
|
131
|
+
for item in items {
|
|
132
|
+
item.normal.iconColor = unselected
|
|
133
|
+
item.normal.titleTextAttributes = [.foregroundColor: unselected]
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
tabBar.standardAppearance = appearance
|
|
138
|
+
/*
|
|
139
|
+
The SCROLL EDGE appearance is a separate object and must be set too.
|
|
140
|
+
|
|
141
|
+
Without it the bar reverts to a transparent variant the moment a scroll
|
|
142
|
+
view underneath reaches its end, which reads as the bar vanishing. Using
|
|
143
|
+
the same appearance for both keeps the material constant.
|
|
144
|
+
*/
|
|
145
|
+
tabBar.scrollEdgeAppearance = appearance
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// MARK: - Items
|
|
149
|
+
|
|
150
|
+
private func rebuildItems() {
|
|
151
|
+
var built: [UITabBarItem] = []
|
|
152
|
+
|
|
153
|
+
for (index, raw) in items.enumerated() {
|
|
154
|
+
guard let spec = raw as? NSDictionary else { continue }
|
|
155
|
+
|
|
156
|
+
let title = labeled ? spec["title"] as? String : nil
|
|
157
|
+
let image = loadImage(spec["icon"])
|
|
158
|
+
|
|
159
|
+
let item = UITabBarItem(title: title, image: image, tag: index)
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
`alwaysTemplate` so UIKit tints the glyph.
|
|
163
|
+
|
|
164
|
+
A PNG loaded as-is keeps its own colours and ignores `tintColor`, so
|
|
165
|
+
the selected state would look identical to the unselected one. The
|
|
166
|
+
alpha channel is the shape; the colour is the system's to decide.
|
|
167
|
+
|
|
168
|
+
`tintable: false` opts out, for an icon whose own colours ARE the
|
|
169
|
+
content — an avatar, a brand mark. Such an item then looks the same
|
|
170
|
+
selected as not, which is the caller's choice to make.
|
|
171
|
+
*/
|
|
172
|
+
let tintable = (spec["tintable"] as? NSNumber)?.boolValue ?? true
|
|
173
|
+
item.image = image?.withRenderingMode(tintable ? .alwaysTemplate : .alwaysOriginal)
|
|
174
|
+
item.selectedImage = item.image
|
|
175
|
+
|
|
176
|
+
// Falls back to the title, which is what UIKit would have read anyway.
|
|
177
|
+
if let label = spec["accessibilityLabel"] as? String {
|
|
178
|
+
item.accessibilityLabel = label
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if !labeled {
|
|
182
|
+
// Half the title's line height, so the glyph sits centred in the
|
|
183
|
+
// item rather than riding high where the label used to be.
|
|
184
|
+
item.imageInsets = UIEdgeInsets(top: 6, left: 0, bottom: -6, right: 0)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
built.append(item)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
tabBar.setItems(built, animated: false)
|
|
191
|
+
applySelection()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
React Native hands over either a bundled asset (a `uri` dictionary) or a
|
|
196
|
+
name in the app's own asset catalogue. Both are accepted so the JS side can
|
|
197
|
+
use `require('./icon.png')` in development, where assets are served over
|
|
198
|
+
HTTP from Metro, and a catalogue name in release.
|
|
199
|
+
*/
|
|
200
|
+
private func loadImage(_ raw: Any?) -> UIImage? {
|
|
201
|
+
if let name = raw as? String {
|
|
202
|
+
return UIImage(named: name)
|
|
203
|
+
}
|
|
204
|
+
if let dict = raw as? NSDictionary {
|
|
205
|
+
if let name = dict["name"] as? String, let image = UIImage(named: name) {
|
|
206
|
+
return image
|
|
207
|
+
}
|
|
208
|
+
if let symbol = dict["sfSymbol"] as? String {
|
|
209
|
+
return UIImage(systemName: symbol)
|
|
210
|
+
}
|
|
211
|
+
if let uri = dict["uri"] as? String {
|
|
212
|
+
/*
|
|
213
|
+
A RASTERISED REACT ICON, which is the main path now.
|
|
214
|
+
|
|
215
|
+
`src/rasterise.tsx` draws a lucide or SVG icon off-screen and captures
|
|
216
|
+
it with react-native-svg's `toDataURL`, so the icon arrives as an
|
|
217
|
+
inline base64 PNG rather than as a file or a URL. Without this branch
|
|
218
|
+
it would fall through to `UIImage(contentsOfFile:)`, which cannot
|
|
219
|
+
read it, and the bar would show titles with no glyphs — the failure
|
|
220
|
+
is silent, which is why it is worth naming here.
|
|
221
|
+
|
|
222
|
+
THE SCALE IS NOT OPTIONAL. The capture renders through a
|
|
223
|
+
`UIGraphicsImageRenderer` at the screen's scale, so a 24-point glyph
|
|
224
|
+
is a 72x72 pixel PNG on a 3x device. PNG carries no scale of its own,
|
|
225
|
+
so decoding without one yields a 72-POINT image and a glyph three
|
|
226
|
+
times too large. Passing the screen scale is what cancels it back.
|
|
227
|
+
*/
|
|
228
|
+
if uri.hasPrefix("data:") {
|
|
229
|
+
if let comma = uri.firstIndex(of: ","),
|
|
230
|
+
let data = Data(
|
|
231
|
+
base64Encoded: String(uri[uri.index(after: comma)...]),
|
|
232
|
+
options: .ignoreUnknownCharacters
|
|
233
|
+
)
|
|
234
|
+
{
|
|
235
|
+
return UIImage(data: data, scale: UIScreen.main.scale)
|
|
236
|
+
}
|
|
237
|
+
return nil
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A Metro-served asset. Loaded synchronously on purpose: a tab bar
|
|
241
|
+
// with no glyphs for a frame is worse than a frame's hitch, and these
|
|
242
|
+
// are a few hundred bytes each.
|
|
243
|
+
if uri.hasPrefix("http"), let url = URL(string: uri),
|
|
244
|
+
let data = try? Data(contentsOf: url)
|
|
245
|
+
{
|
|
246
|
+
return UIImage(data: data, scale: UIScreen.main.scale)
|
|
247
|
+
}
|
|
248
|
+
return UIImage(contentsOfFile: uri)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return nil
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
`selectedItem` is a plain property, and that is the whole API.
|
|
256
|
+
|
|
257
|
+
`UITabBarController` has an animated setter; `UITabBar` does not. It does
|
|
258
|
+
not need one: the bar animates the change itself when it is on screen, so
|
|
259
|
+
assigning the property runs the same icon and label transition UIKit uses
|
|
260
|
+
for its own bars. There is nothing to opt into.
|
|
261
|
+
*/
|
|
262
|
+
private func applySelection() {
|
|
263
|
+
let index = selectedIndex.intValue
|
|
264
|
+
guard let items = tabBar.items, index >= 0, index < items.count else {
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
tabBar.selectedItem = items[index]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// MARK: - UITabBarDelegate
|
|
271
|
+
|
|
272
|
+
public func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
|
|
273
|
+
/*
|
|
274
|
+
REPORT, DO NOT DECIDE.
|
|
275
|
+
|
|
276
|
+
The selection is JS state: a tab press can be vetoed (the Home tab opens
|
|
277
|
+
a premise detail instead of switching, for one), so the bar must not
|
|
278
|
+
consider itself switched until JS says so. `selectedIndex` coming back
|
|
279
|
+
down is what moves it.
|
|
280
|
+
*/
|
|
281
|
+
onTabSelected?(["index": item.tag])
|
|
282
|
+
|
|
283
|
+
// Put the bar back where JS still thinks it is, so a vetoed press does
|
|
284
|
+
// not leave the highlight on the wrong item for a frame.
|
|
285
|
+
applySelection()
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#import <React/RCTViewManager.h>
|
|
2
|
+
#import <React/RCTUIManager.h>
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
Prop and event declarations for `LQBottomBarViewManager`.
|
|
6
|
+
*
|
|
7
|
+
These macros cannot be written in Swift — `RCT_EXPORT_VIEW_PROPERTY` is a
|
|
8
|
+
preprocessor construct — so the manager is split: behaviour in Swift,
|
|
9
|
+
declarations here. This is the standard arrangement for a Swift view manager
|
|
10
|
+
and the reason the pair exists.
|
|
11
|
+
*/
|
|
12
|
+
@interface RCT_EXTERN_MODULE (LQBottomBarViewManager, RCTViewManager)
|
|
13
|
+
|
|
14
|
+
RCT_EXPORT_VIEW_PROPERTY(items, NSArray)
|
|
15
|
+
RCT_EXPORT_VIEW_PROPERTY(selectedIndex, NSNumber)
|
|
16
|
+
RCT_EXPORT_VIEW_PROPERTY(selectedColor, UIColor)
|
|
17
|
+
RCT_EXPORT_VIEW_PROPERTY(unselectedColor, UIColor)
|
|
18
|
+
RCT_EXPORT_VIEW_PROPERTY(labeled, BOOL)
|
|
19
|
+
RCT_EXPORT_VIEW_PROPERTY(onTabSelected, RCTDirectEventBlock)
|
|
20
|
+
|
|
21
|
+
@end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
`RCTViewManager` and `RCTDirectEventBlock` are Objective-C types from
|
|
5
|
+
React-Core. Swift does not see them implicitly: the pod ships a module map
|
|
6
|
+
(`Headers/Public/React/React-Core.modulemap`), so the module has to be
|
|
7
|
+
imported by name like any other.
|
|
8
|
+
*/
|
|
9
|
+
import React
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
The bridge's handle on `LQBottomBarView`.
|
|
13
|
+
|
|
14
|
+
A LEGACY view manager, deliberately, even though this app runs Fabric.
|
|
15
|
+
React Native 0.83 bridges legacy managers through its interop layer, and the
|
|
16
|
+
alternative — a Fabric component view — must be written in Objective-C++,
|
|
17
|
+
which cannot host Swift directly and interacts badly with the `use_frameworks!`
|
|
18
|
+
linkage this Podfile sets. One Swift file that works beats two that fight the
|
|
19
|
+
build.
|
|
20
|
+
*/
|
|
21
|
+
@objc(LQBottomBarViewManager)
|
|
22
|
+
final class LQBottomBarViewManager: RCTViewManager {
|
|
23
|
+
|
|
24
|
+
override func view() -> UIView! {
|
|
25
|
+
return LQBottomBarView()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
The bar is a leaf with no React children and no layout of its own to
|
|
30
|
+
compute, so it can be created on the main queue without stalling the
|
|
31
|
+
shadow tree.
|
|
32
|
+
*/
|
|
33
|
+
override static func requiresMainQueueSetup() -> Bool {
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HostComponent, StyleProp, ViewProps, ViewStyle } from 'react-native';
|
|
2
|
+
/**
|
|
3
|
+
* The native view, on whichever platform this is.
|
|
4
|
+
*
|
|
5
|
+
* Both platforms register the same component name, and both are legacy view
|
|
6
|
+
* managers bridged through React Native's interop layer — so there is one
|
|
7
|
+
* `requireNativeComponent` call and no per-platform file.
|
|
8
|
+
*
|
|
9
|
+
* `null` on anything else rather than absent: `requireNativeComponent` throws
|
|
10
|
+
* at REQUIRE time where the view is not registered, so it cannot sit at module
|
|
11
|
+
* scope unguarded on, say, web.
|
|
12
|
+
*/
|
|
13
|
+
/** The shape the native side is declared with, not the public API. */
|
|
14
|
+
export interface NativeBottomBarProps {
|
|
15
|
+
items: unknown[];
|
|
16
|
+
selectedIndex: number;
|
|
17
|
+
selectedColor?: string;
|
|
18
|
+
unselectedColor?: string;
|
|
19
|
+
labeled: boolean;
|
|
20
|
+
onTabSelected?: (event: {
|
|
21
|
+
nativeEvent: {
|
|
22
|
+
index: number;
|
|
23
|
+
};
|
|
24
|
+
}) => void;
|
|
25
|
+
style?: StyleProp<ViewStyle>;
|
|
26
|
+
pointerEvents?: ViewProps['pointerEvents'];
|
|
27
|
+
/** Android only; the iOS bar gets its material from UIKit. */
|
|
28
|
+
iconSize?: number;
|
|
29
|
+
blurAmount?: number;
|
|
30
|
+
scrimColor?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare const NativeBottomBar: HostComponent<NativeBottomBarProps> | null;
|
|
33
|
+
//# sourceMappingURL=NativeBottomBar.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NativeBottomBar.d.ts","sourceRoot":"","sources":["../../src/NativeBottomBar.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,aAAa,EACb,SAAS,EACT,SAAS,EACT,SAAS,EACV,MAAM,cAAc,CAAC;AAEtB;;;;;;;;;;GAUG;AAEH,sEAAsE;AACtE,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,OAAO,CAAC;IACjB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,WAAW,EAAE;YAAE,KAAK,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAK,IAAI,CAAC;IACpE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,aAAa,CAAC,EAAE,SAAS,CAAC,eAAe,CAAC,CAAC;IAE3C,8DAA8D;IAC9D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,eAAO,MAAM,eAAe,EAAE,aAAa,CAAC,oBAAoB,CAAC,GAAG,IAG1D,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NativeBottomBar = void 0;
|
|
4
|
+
const react_native_1 = require("react-native");
|
|
5
|
+
const SUPPORTED = react_native_1.Platform.OS === 'ios' || react_native_1.Platform.OS === 'android';
|
|
6
|
+
exports.NativeBottomBar = SUPPORTED
|
|
7
|
+
? (0, react_native_1.requireNativeComponent)('LQBottomBarView')
|
|
8
|
+
: null;
|
|
9
|
+
//# sourceMappingURL=NativeBottomBar.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NativeBottomBar.js","sourceRoot":"","sources":["../../src/NativeBottomBar.ts"],"names":[],"mappings":";;;AAAA,+CAAgE;AAqChE,MAAM,SAAS,GAAG,uBAAQ,CAAC,EAAE,KAAK,KAAK,IAAI,uBAAQ,CAAC,EAAE,KAAK,SAAS,CAAC;AAExD,QAAA,eAAe,GAC1B,SAAS;IACP,CAAC,CAAC,IAAA,qCAAsB,EAAuB,iBAAiB,CAAC;IACjE,CAAC,CAAC,IAAI,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { LQBottomBarItem, LQBottomBarProps, LQIcon, LQIconState } from './types';
|
|
3
|
+
export type { LQBottomBarItem, LQBottomBarProps, LQIcon, LQIconState };
|
|
4
|
+
declare const LQBottomBar: ({ items, selectedIndex, selectedColor, unselectedColor, labeled, iconSize, onTabSelected, style, pointerEvents, blurAmount, androidBackgroundColor, }: LQBottomBarProps) => React.JSX.Element | null;
|
|
5
|
+
export default LQBottomBar;
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAkB,MAAM,OAAO,CAAC;AAKvC,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,MAAM,EACN,WAAW,EACZ,MAAM,SAAS,CAAC;AAEjB,YAAY,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;AAoDvE,QAAA,MAAM,WAAW,GAAI,uJAYlB,gBAAgB,6BA4DlB,CAAC;AAEF,eAAe,WAAW,CAAC"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
const react_1 = __importStar(require("react"));
|
|
37
|
+
const react_native_1 = require("react-native");
|
|
38
|
+
const NativeBottomBar_1 = require("./NativeBottomBar");
|
|
39
|
+
const rasterise_1 = require("./rasterise");
|
|
40
|
+
/**
|
|
41
|
+
* THE SYSTEM TAB BAR, HOSTED ANYWHERE.
|
|
42
|
+
*
|
|
43
|
+
* A native tab bar normally only exists inside a `UITabBarController`, which
|
|
44
|
+
* only draws its bar for its own children — so a screen pushed above a tab
|
|
45
|
+
* navigator cannot have one. That is true of the CONTROLLER, not the BAR:
|
|
46
|
+
* `UITabBar` is a plain `UIView`, and hosting one directly gets the real
|
|
47
|
+
* thing rather than a drawing of it. Liquid glass on iOS 26, UIKit's own
|
|
48
|
+
* selection animation, and the platform's accessibility, on any screen.
|
|
49
|
+
*
|
|
50
|
+
* Android has no system bar to borrow, so it draws a matching one: same
|
|
51
|
+
* dimensions, same tint crossfade and icon bounce, with a blurred backdrop
|
|
52
|
+
* standing in for the iOS material.
|
|
53
|
+
*
|
|
54
|
+
* BOTH PLATFORMS DRAW THEIR OWN ITEMS, which is why `src/rasterise.tsx`
|
|
55
|
+
* exists: a view that renders its items natively cannot be handed a React
|
|
56
|
+
* element, so a lucide or SVG icon is captured to a PNG once and the capture
|
|
57
|
+
* is what crosses the bridge. See that file for the trade this makes and what
|
|
58
|
+
* it costs.
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* ASSETS ARE RESOLVED HERE, and they have to be.
|
|
62
|
+
*
|
|
63
|
+
* `require('./home.png')` evaluates to a NUMBER — an id into React Native's
|
|
64
|
+
* asset registry. The bridge converts that number into `{ uri, scale, ... }`
|
|
65
|
+
* only for props it knows are image sources, which it learns from the prop's
|
|
66
|
+
* declared type. `items` is declared as an array, so the bridge has no reason
|
|
67
|
+
* to look inside it: the number crosses to native as a number, the native
|
|
68
|
+
* loader finds nothing it recognises, and the bar draws labels with no
|
|
69
|
+
* glyphs. `Image.resolveAssetSource` does the same conversion by hand, which
|
|
70
|
+
* is what lets a caller keep writing `require(...)`.
|
|
71
|
+
*/
|
|
72
|
+
const resolveIcon = (icon) => {
|
|
73
|
+
if (typeof icon === 'number')
|
|
74
|
+
return react_native_1.Image.resolveAssetSource(icon);
|
|
75
|
+
return icon;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* `overflow: 'hidden'` on Android, always.
|
|
79
|
+
*
|
|
80
|
+
* The blurred backdrop is a real child view, so without clipping it squares
|
|
81
|
+
* off whatever `borderRadius` the caller gave the bar — the corners stay
|
|
82
|
+
* rounded on the bar's own background while the blur fills them in. iOS needs
|
|
83
|
+
* none of this: `UITabBar` shapes its own material.
|
|
84
|
+
*/
|
|
85
|
+
const androidClip = react_native_1.StyleSheet.create({
|
|
86
|
+
bar: { overflow: 'hidden' },
|
|
87
|
+
});
|
|
88
|
+
const LQBottomBar = ({ items, selectedIndex, selectedColor, unselectedColor, labeled = true, iconSize = 24, onTabSelected, style, pointerEvents, blurAmount = 20, androidBackgroundColor = 'rgba(20, 20, 20, 0.45)', }) => {
|
|
89
|
+
const { sources, host } = (0, rasterise_1.useRasterisedIcons)(items, iconSize);
|
|
90
|
+
const nativeItems = (0, react_1.useMemo)(() => items.map((item, index) => {
|
|
91
|
+
var _a;
|
|
92
|
+
const rasterised = sources[index];
|
|
93
|
+
return {
|
|
94
|
+
title: item.title,
|
|
95
|
+
/*
|
|
96
|
+
A captured glyph wins; otherwise the icon passes through as the
|
|
97
|
+
image or symbol descriptor it already was — which only iOS can
|
|
98
|
+
resolve. Until a capture lands this is `undefined`, which draws the
|
|
99
|
+
item with its title and no glyph for a frame; see the cost note in
|
|
100
|
+
`rasterise.tsx`.
|
|
101
|
+
*/
|
|
102
|
+
icon: rasterised ? { uri: rasterised } : resolveIcon(item.icon),
|
|
103
|
+
// Default true: one artwork, tinted by the platform for both states.
|
|
104
|
+
tintable: item.tintable !== false,
|
|
105
|
+
accessibilityLabel: (_a = item.accessibilityLabel) !== null && _a !== void 0 ? _a : item.title,
|
|
106
|
+
};
|
|
107
|
+
}), [items, sources]);
|
|
108
|
+
if (!NativeBottomBar_1.NativeBottomBar)
|
|
109
|
+
return null;
|
|
110
|
+
const platformProps = react_native_1.Platform.OS === 'android'
|
|
111
|
+
? {
|
|
112
|
+
iconSize,
|
|
113
|
+
blurAmount,
|
|
114
|
+
scrimColor: androidBackgroundColor,
|
|
115
|
+
}
|
|
116
|
+
: // Passing Android-only props to the iOS manager would log an unknown
|
|
117
|
+
// prop warning for each one on every render.
|
|
118
|
+
null;
|
|
119
|
+
return (react_1.default.createElement(react_1.default.Fragment, null,
|
|
120
|
+
host,
|
|
121
|
+
react_1.default.createElement(NativeBottomBar_1.NativeBottomBar, { style: react_native_1.Platform.OS === 'android' ? [style, androidClip.bar] : style, pointerEvents: pointerEvents, items: nativeItems, selectedIndex: selectedIndex, selectedColor: selectedColor, unselectedColor: unselectedColor, labeled: labeled !== false, ...platformProps,
|
|
122
|
+
/*
|
|
123
|
+
A REPORT, NOT A COMMIT. Both bars put their highlight back where
|
|
124
|
+
`selectedIndex` says it is, so a tap the caller declines to act on —
|
|
125
|
+
one that opens a detail screen instead of switching tabs — cannot
|
|
126
|
+
leave the bar showing the wrong item.
|
|
127
|
+
*/
|
|
128
|
+
onTabSelected: event => onTabSelected === null || onTabSelected === void 0 ? void 0 : onTabSelected(event.nativeEvent.index) })));
|
|
129
|
+
};
|
|
130
|
+
exports.default = LQBottomBar;
|
|
131
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.tsx"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,+CAAuC;AACvC,+CAA2D;AAE3D,uDAAoD;AACpD,2CAAiD;AAUjD;;;;;;;;;;;;;;;;;;;GAmBG;AAEH;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,GAAG,CAAC,IAAY,EAAW,EAAE;IAC5C,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,oBAAK,CAAC,kBAAkB,CAAC,IAAI,CAAC,CAAC;IACpE,OAAO,IAAI,CAAC;AACd,CAAC,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,WAAW,GAAG,yBAAU,CAAC,MAAM,CAAC;IACpC,GAAG,EAAE,EAAE,QAAQ,EAAE,QAAQ,EAAE;CAC5B,CAAC,CAAC;AAEH,MAAM,WAAW,GAAG,CAAC,EACnB,KAAK,EACL,aAAa,EACb,aAAa,EACb,eAAe,EACf,OAAO,GAAG,IAAI,EACd,QAAQ,GAAG,EAAE,EACb,aAAa,EACb,KAAK,EACL,aAAa,EACb,UAAU,GAAG,EAAE,EACf,sBAAsB,GAAG,wBAAwB,GAChC,EAAE,EAAE;IACrB,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,IAAA,8BAAkB,EAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IAE9D,MAAM,WAAW,GAAG,IAAA,eAAO,EACzB,GAAG,EAAE,CACH,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;;QACxB,MAAM,UAAU,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC;QAClC,OAAO;YACL,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB;;;;;;cAME;YACF,IAAI,EAAE,UAAU,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC;YAC/D,qEAAqE;YACrE,QAAQ,EAAE,IAAI,CAAC,QAAQ,KAAK,KAAK;YACjC,kBAAkB,EAAE,MAAA,IAAI,CAAC,kBAAkB,mCAAI,IAAI,CAAC,KAAK;SAC1D,CAAC;IACJ,CAAC,CAAC,EACJ,CAAC,KAAK,EAAE,OAAO,CAAC,CACjB,CAAC;IAEF,IAAI,CAAC,iCAAe;QAAE,OAAO,IAAI,CAAC;IAElC,MAAM,aAAa,GACjB,uBAAQ,CAAC,EAAE,KAAK,SAAS;QACvB,CAAC,CAAC;YACE,QAAQ;YACR,UAAU;YACV,UAAU,EAAE,sBAAsB;SACnC;QACH,CAAC,CAAC,qEAAqE;YACrE,6CAA6C;YAC7C,IAAI,CAAC;IAEX,OAAO,CACL;QACG,IAAI;QACL,8BAAC,iCAAe,IACd,KAAK,EAAE,uBAAQ,CAAC,EAAE,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,KAAK,EAAE,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,EACnE,aAAa,EAAE,aAAa,EAC5B,KAAK,EAAE,WAAW,EAClB,aAAa,EAAE,aAAa,EAC5B,aAAa,EAAE,aAAa,EAC5B,eAAe,EAAE,eAAe,EAChC,OAAO,EAAE,OAAO,KAAK,KAAK,KACtB,aAAa;YACjB;;;;;cAKE;YACF,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,aAAa,aAAb,aAAa,uBAAb,aAAa,CAAG,KAAK,CAAC,WAAW,CAAC,KAAK,CAAC,GAChE,CACD,CACJ,CAAC;AACJ,CAAC,CAAC;AAEF,kBAAe,WAAW,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import type { LQBottomBarItem } from './types';
|
|
3
|
+
export interface RasterisedIcons {
|
|
4
|
+
/**
|
|
5
|
+
* Per item: the captured data URI, or `null` when the icon needs no
|
|
6
|
+
* rasterising (an image or SF Symbol) or has not been captured yet.
|
|
7
|
+
*/
|
|
8
|
+
sources: (string | null)[];
|
|
9
|
+
/** Mount this somewhere in the tree. Renders nothing visible. */
|
|
10
|
+
host: ReactElement | null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Capture every React-element icon in `items`, memoised by icon identity.
|
|
14
|
+
*
|
|
15
|
+
* Returns the captured sources plus the off-screen host that does the
|
|
16
|
+
* capturing — the caller has to render the host, since a hook cannot mount
|
|
17
|
+
* views on its own.
|
|
18
|
+
*/
|
|
19
|
+
export declare const useRasterisedIcons: (items: LQBottomBarItem[], size: number) => RasterisedIcons;
|
|
20
|
+
//# sourceMappingURL=rasterise.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rasterise.d.ts","sourceRoot":"","sources":["../../src/rasterise.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,OAAO,CAAC;AAE1C,OAAO,KAAK,EAAE,eAAe,EAAU,MAAM,SAAS,CAAC;AAkTvD,MAAM,WAAW,eAAe;IAC9B;;;OAGG;IACH,OAAO,EAAE,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;IAC3B,iEAAiE;IACjE,IAAI,EAAE,YAAY,GAAG,IAAI,CAAC;CAC3B;AAED;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,GAC7B,OAAO,eAAe,EAAE,EACxB,MAAM,MAAM,KACX,eAyGF,CAAC"}
|