@onekeyfe/react-native-bottom-tabs 6.7.0-alpha.384 → 6.7.0-alpha.386
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/SPEC.md +36 -0
- package/ios/RCTBottomTabsComponentView.mm +37 -0
- package/ios/RCTBottomTabsContainerView.swift +184 -1
- package/lib/module/BottomTabs.js +58 -31
- package/lib/module/BottomTabsNativeComponent.ts +13 -0
- package/lib/typescript/src/BottomTabs.d.ts +28 -10
- package/lib/typescript/src/BottomTabsNativeComponent.d.ts +8 -0
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/types.d.ts +1 -0
- package/package.json +1 -1
- package/src/BottomTabs.tsx +126 -50
- package/src/BottomTabsNativeComponent.ts +13 -0
- package/src/index.tsx +1 -1
- package/src/types.ts +3 -0
package/docs/SPEC.md
CHANGED
|
@@ -260,6 +260,35 @@ boundary; `BottomTabsView.kt` owns scene geometry, appearance, and disposal;
|
|
|
260
260
|
and runner instructions are in `native-tests/android`. The example's settings
|
|
261
261
|
page exposes the switch and its scrolling scene reserves the reported bar height.
|
|
262
262
|
|
|
263
|
+
## iOS vertical tab bar placement and side accessory
|
|
264
|
+
|
|
265
|
+
Status: Implemented. Runtime results are in Conformance.
|
|
266
|
+
|
|
267
|
+
On iOS 27.1+, devices such as iPhone Duo can place the `UITabBarController`
|
|
268
|
+
bar vertically in a side strip next to the status bar. The view reports that
|
|
269
|
+
placement and can draw one round accessory button above the vertical bar.
|
|
270
|
+
|
|
271
|
+
- **Placement:** `onTabBarPlacementChange({ placement })` fires with `bottom`,
|
|
272
|
+
`leading`, or `trailing`, read from `traitCollection.verticalBarEdge` of the
|
|
273
|
+
tab bar controller. It fires once after the bar is created and again on every
|
|
274
|
+
change (rotation, folding, window resizing). Builds without the iOS 27.1 SDK
|
|
275
|
+
and earlier systems always report `bottom`. Android never fires it.
|
|
276
|
+
- **Side accessory:** `sideAccessoryIcon` (an image source, loaded with the
|
|
277
|
+
React image loader and drawn as a template tinted with `inactiveTintColor`),
|
|
278
|
+
`sideAccessoryLabel` (accessibility label), and `onSideAccessoryPress`. The
|
|
279
|
+
button is 48 pt, a Liquid Glass capsule on iOS 26+, centered horizontally on
|
|
280
|
+
the vertical bar's item platter and 12 pt above it.
|
|
281
|
+
- The button shows only when all hold: the placement is `leading` or
|
|
282
|
+
`trailing`, the tab bar is not hidden, the icon has loaded, and the platter
|
|
283
|
+
is found. Otherwise it is hidden; a bottom bar never shows it.
|
|
284
|
+
- UIKit does not expose the platter frame. The view finds the platter by its
|
|
285
|
+
view class name below the tab bar controller (skipping tab content views),
|
|
286
|
+
reads its frame, and calls no private API. If a later system renames or
|
|
287
|
+
restructures it, the accessory hides; no other behavior depends on it.
|
|
288
|
+
- Layout re-runs, coalesced on the next main-queue turn, after container
|
|
289
|
+
layout, tab rebuilds, tab bar hiding, icon loads, and changes to the traits
|
|
290
|
+
affecting `verticalBarEdge`.
|
|
291
|
+
|
|
263
292
|
## Threading and ownership
|
|
264
293
|
|
|
265
294
|
All gesture handling, animations, and timers run on the platform UI thread in
|
|
@@ -285,3 +314,10 @@ non-selected tab lifts the lens there; drag crossing tab centers does not change
|
|
|
285
314
|
pages; release selects the nearest tab once; cancel restores; hidden bar, dark
|
|
286
315
|
mode, and 3-button navigation insets lay out correctly. Source compilation is
|
|
287
316
|
recorded separately from runtime acceptance.
|
|
317
|
+
|
|
318
|
+
Vertical tab bar: `RCTBottomTabsContainerView.swift` (`currentTabBarPlacement`,
|
|
319
|
+
`updateTabBarPlacementAndSideAccessory`, `verticalTabBarPlatterFrame`).
|
|
320
|
+
Required iOS runtime cases on iPhone Duo: the outer display and landscape inner
|
|
321
|
+
display report `trailing` and show the accessory above the platter; the
|
|
322
|
+
portrait inner display reports `bottom` and hides it; hiding the tab bar hides
|
|
323
|
+
it; a press emits `onSideAccessoryPress` once.
|
|
@@ -171,6 +171,28 @@ static NSArray<NSDictionary *>* convertItemsToArray(const std::vector<RNBottomTa
|
|
|
171
171
|
}
|
|
172
172
|
};
|
|
173
173
|
[_containerView setValue:onNativeLayout forKey:@"onNativeLayout"];
|
|
174
|
+
|
|
175
|
+
EventBlock onTabBarPlacementChange = ^(NSDictionary *body) {
|
|
176
|
+
auto strongSelf = weakSelf;
|
|
177
|
+
if (!strongSelf) return;
|
|
178
|
+
auto emitter = std::static_pointer_cast<const RNBottomTabsViewEventEmitter>(strongSelf->_eventEmitter);
|
|
179
|
+
if (emitter) {
|
|
180
|
+
emitter->onTabBarPlacementChange({
|
|
181
|
+
.placement = std::string([[body objectForKey:@"placement"] UTF8String] ?: "bottom")
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
[_containerView setValue:onTabBarPlacementChange forKey:@"onTabBarPlacementChange"];
|
|
186
|
+
|
|
187
|
+
EventBlock onSideAccessoryPress = ^(NSDictionary *body) {
|
|
188
|
+
auto strongSelf = weakSelf;
|
|
189
|
+
if (!strongSelf) return;
|
|
190
|
+
auto emitter = std::static_pointer_cast<const RNBottomTabsViewEventEmitter>(strongSelf->_eventEmitter);
|
|
191
|
+
if (emitter) {
|
|
192
|
+
emitter->onSideAccessoryPress({});
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
[_containerView setValue:onSideAccessoryPress forKey:@"onSideAccessoryPress"];
|
|
174
196
|
}
|
|
175
197
|
|
|
176
198
|
return self;
|
|
@@ -308,6 +330,21 @@ static NSArray<NSDictionary *>* convertItemsToArray(const std::vector<RNBottomTa
|
|
|
308
330
|
[_containerView setValue:@(newViewProps.tabBarHidden) forKey:@"tabBarHidden"];
|
|
309
331
|
}
|
|
310
332
|
|
|
333
|
+
if (oldViewProps.sideAccessoryIcon != newViewProps.sideAccessoryIcon) {
|
|
334
|
+
const auto &source = newViewProps.sideAccessoryIcon;
|
|
335
|
+
NSDictionary *iconDict = source.uri.empty() ? nil : @{
|
|
336
|
+
@"uri": [NSString stringWithUTF8String:source.uri.c_str()],
|
|
337
|
+
@"width": @(source.size.width),
|
|
338
|
+
@"height": @(source.size.height),
|
|
339
|
+
@"scale": @(source.scale),
|
|
340
|
+
};
|
|
341
|
+
[_containerView setValue:iconDict forKey:@"sideAccessoryIcon"];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (oldViewProps.sideAccessoryLabel != newViewProps.sideAccessoryLabel) {
|
|
345
|
+
[_containerView setValue:NSStringFromStdStringNilIfEmpty(newViewProps.sideAccessoryLabel) forKey:@"sideAccessoryLabel"];
|
|
346
|
+
}
|
|
347
|
+
|
|
311
348
|
#undef SET_PROP
|
|
312
349
|
|
|
313
350
|
[super updateProps:props oldProps:oldProps];
|
|
@@ -70,6 +70,14 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
70
70
|
private var lastPressedTabTime: CFTimeInterval = 0
|
|
71
71
|
/// Cached view controllers keyed by tab key to avoid unnecessary reparenting
|
|
72
72
|
private var cachedViewControllers: [String: UIViewController] = [:]
|
|
73
|
+
// Vertical tab bar (iPhone Duo side strip): last reported placement and the
|
|
74
|
+
// round accessory button drawn above the bar.
|
|
75
|
+
private var reportedTabBarPlacement: String?
|
|
76
|
+
private var sideAccessoryButton: UIButton?
|
|
77
|
+
private var loadedSideAccessoryIcon: UIImage?
|
|
78
|
+
private var isSideAccessoryLayoutScheduled = false
|
|
79
|
+
private static let sideAccessorySize: CGFloat = 48
|
|
80
|
+
private static let sideAccessorySpacing: CGFloat = 12
|
|
73
81
|
|
|
74
82
|
// MARK: - Props (set by Fabric ComponentView via KVC)
|
|
75
83
|
|
|
@@ -128,7 +136,11 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
128
136
|
|
|
129
137
|
@objc var inactiveTintColor: UIColor? {
|
|
130
138
|
// Rebuild so the baked unselected-image tint follows theme changes.
|
|
131
|
-
didSet {
|
|
139
|
+
didSet {
|
|
140
|
+
updateTabBarAppearance()
|
|
141
|
+
rebuildViewControllers()
|
|
142
|
+
sideAccessoryButton?.tintColor = inactiveTintColor
|
|
143
|
+
}
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
@objc var rippleColor: UIColor? // Android only
|
|
@@ -147,12 +159,22 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
147
159
|
didSet { updateTabBarAppearance() }
|
|
148
160
|
}
|
|
149
161
|
|
|
162
|
+
@objc var sideAccessoryIcon: NSDictionary? {
|
|
163
|
+
didSet { loadSideAccessoryIcon() }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
@objc var sideAccessoryLabel: String? {
|
|
167
|
+
didSet { sideAccessoryButton?.accessibilityLabel = sideAccessoryLabel }
|
|
168
|
+
}
|
|
169
|
+
|
|
150
170
|
// MARK: - Events (RCTDirectEventBlock)
|
|
151
171
|
|
|
152
172
|
@objc var onPageSelected: RCTDirectEventBlock?
|
|
153
173
|
@objc var onTabDoublePress: RCTDirectEventBlock?
|
|
154
174
|
@objc var onTabBarMeasured: RCTDirectEventBlock?
|
|
155
175
|
@objc var onNativeLayout: RCTDirectEventBlock?
|
|
176
|
+
@objc var onTabBarPlacementChange: RCTDirectEventBlock?
|
|
177
|
+
@objc var onSideAccessoryPress: RCTDirectEventBlock?
|
|
156
178
|
|
|
157
179
|
// MARK: - Init
|
|
158
180
|
|
|
@@ -188,6 +210,161 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
188
210
|
// TODO: Re-enable when UITabBar.bottomAccessoryView is available in the SDK
|
|
189
211
|
}
|
|
190
212
|
|
|
213
|
+
// MARK: - Vertical tab bar (iOS 27.1+)
|
|
214
|
+
|
|
215
|
+
/// Where the system draws the tab bar: "bottom", or "leading"/"trailing" for
|
|
216
|
+
/// the vertical bar some devices (iPhone Duo) place in a side strip.
|
|
217
|
+
private func currentTabBarPlacement() -> String {
|
|
218
|
+
#if compiler(>=6.4)
|
|
219
|
+
if #available(iOS 27.1, *), let tbc = tabBarController {
|
|
220
|
+
switch tbc.traitCollection.verticalBarEdge {
|
|
221
|
+
case .leading: return "leading"
|
|
222
|
+
case .trailing: return "trailing"
|
|
223
|
+
default: break
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
#endif
|
|
227
|
+
return "bottom"
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private func registerVerticalBarTraitChanges(_ tbc: UITabBarController) {
|
|
231
|
+
#if compiler(>=6.4)
|
|
232
|
+
if #available(iOS 27.1, *) {
|
|
233
|
+
tbc.registerForTraitChanges(
|
|
234
|
+
UITraitCollection.systemTraitsAffectingVerticalBarEdge
|
|
235
|
+
) { [weak self] (_: UITabBarController, _: UITraitCollection) in
|
|
236
|
+
self?.scheduleSideAccessoryLayout()
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
#endif
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// Coalesces requests and runs after UIKit has laid out the bar, whose
|
|
243
|
+
/// floating platter is positioned in UIKit's own layout pass.
|
|
244
|
+
private func scheduleSideAccessoryLayout() {
|
|
245
|
+
guard !isSideAccessoryLayoutScheduled else { return }
|
|
246
|
+
isSideAccessoryLayoutScheduled = true
|
|
247
|
+
DispatchQueue.main.async { [weak self] in
|
|
248
|
+
guard let self else { return }
|
|
249
|
+
self.isSideAccessoryLayoutScheduled = false
|
|
250
|
+
self.updateTabBarPlacementAndSideAccessory()
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private func updateTabBarPlacementAndSideAccessory() {
|
|
255
|
+
guard let tbc = tabBarController else { return }
|
|
256
|
+
let placement = currentTabBarPlacement()
|
|
257
|
+
if placement != reportedTabBarPlacement {
|
|
258
|
+
reportedTabBarPlacement = placement
|
|
259
|
+
onTabBarPlacementChange?(["placement": placement])
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
guard placement != "bottom",
|
|
263
|
+
!tabBarHidden,
|
|
264
|
+
let icon = loadedSideAccessoryIcon,
|
|
265
|
+
let platterFrame = verticalTabBarPlatterFrame(in: tbc) else {
|
|
266
|
+
sideAccessoryButton?.isHidden = true
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let button = ensureSideAccessoryButton(in: tbc.view)
|
|
271
|
+
button.configuration?.image = icon
|
|
272
|
+
let size = RCTBottomTabsContainerView.sideAccessorySize
|
|
273
|
+
button.frame = CGRect(
|
|
274
|
+
x: platterFrame.midX - size / 2,
|
|
275
|
+
y: platterFrame.minY - RCTBottomTabsContainerView.sideAccessorySpacing - size,
|
|
276
|
+
width: size,
|
|
277
|
+
height: size
|
|
278
|
+
)
|
|
279
|
+
button.isHidden = false
|
|
280
|
+
tbc.view.bringSubviewToFront(button)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/// UIKit draws the vertical bar items in a floating platter it does not
|
|
284
|
+
/// expose. Read that view's frame (no private API is called) and return nil
|
|
285
|
+
/// when it cannot be found, so the accessory hides instead of misplacing.
|
|
286
|
+
private func verticalTabBarPlatterFrame(in tbc: UITabBarController) -> CGRect? {
|
|
287
|
+
let root = tbc.view!
|
|
288
|
+
// Tab content can hold large React trees; the bar lives outside them.
|
|
289
|
+
let contentViews = Set((tbc.viewControllers ?? []).compactMap { vc in
|
|
290
|
+
vc.isViewLoaded ? ObjectIdentifier(vc.view) : nil
|
|
291
|
+
})
|
|
292
|
+
var queue: [UIView] = root.subviews
|
|
293
|
+
var index = 0
|
|
294
|
+
while index < queue.count {
|
|
295
|
+
let view = queue[index]
|
|
296
|
+
index += 1
|
|
297
|
+
if view === sideAccessoryButton || contentViews.contains(ObjectIdentifier(view)) {
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
if NSStringFromClass(type(of: view)).contains("TabBarItemPlatterView") {
|
|
301
|
+
guard !view.isHidden, view.window != nil else { return nil }
|
|
302
|
+
let frame = view.convert(view.bounds, to: root)
|
|
303
|
+
return frame.isEmpty ? nil : frame
|
|
304
|
+
}
|
|
305
|
+
queue.append(contentsOf: view.subviews)
|
|
306
|
+
}
|
|
307
|
+
return nil
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private func ensureSideAccessoryButton(in parent: UIView) -> UIButton {
|
|
311
|
+
if let button = sideAccessoryButton {
|
|
312
|
+
if button.superview !== parent { parent.addSubview(button) }
|
|
313
|
+
return button
|
|
314
|
+
}
|
|
315
|
+
var configuration: UIButton.Configuration
|
|
316
|
+
#if compiler(>=6.2)
|
|
317
|
+
if #available(iOS 26.0, *) {
|
|
318
|
+
configuration = .glass()
|
|
319
|
+
} else {
|
|
320
|
+
configuration = .gray()
|
|
321
|
+
}
|
|
322
|
+
#else
|
|
323
|
+
configuration = .gray()
|
|
324
|
+
#endif
|
|
325
|
+
configuration.cornerStyle = .capsule
|
|
326
|
+
configuration.contentInsets = .zero
|
|
327
|
+
let button = UIButton(configuration: configuration)
|
|
328
|
+
button.tintColor = inactiveTintColor
|
|
329
|
+
button.accessibilityLabel = sideAccessoryLabel
|
|
330
|
+
button.addAction(UIAction { [weak self] _ in
|
|
331
|
+
self?.onSideAccessoryPress?([:])
|
|
332
|
+
}, for: .primaryActionTriggered)
|
|
333
|
+
parent.addSubview(button)
|
|
334
|
+
sideAccessoryButton = button
|
|
335
|
+
return button
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private func loadSideAccessoryIcon() {
|
|
339
|
+
let source = sideAccessoryIcon.map { IconSource(dict: $0) }
|
|
340
|
+
guard let source, let url = URL(string: source.uri) else {
|
|
341
|
+
loadedSideAccessoryIcon = nil
|
|
342
|
+
scheduleSideAccessoryLayout()
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
// The image loader arrives with the first state update; setImageLoader
|
|
346
|
+
// calls this again.
|
|
347
|
+
guard let imageLoader else { return }
|
|
348
|
+
imageLoader.loadImage(
|
|
349
|
+
with: URLRequest(url: url),
|
|
350
|
+
size: CGSize(width: source.width, height: source.height),
|
|
351
|
+
scale: CGFloat(source.scale),
|
|
352
|
+
clipped: true,
|
|
353
|
+
resizeMode: RCTResizeMode.contain,
|
|
354
|
+
progressBlock: { _, _ in },
|
|
355
|
+
partialLoad: { _ in },
|
|
356
|
+
completionBlock: { [weak self] error, image in
|
|
357
|
+
guard error == nil, let image else { return }
|
|
358
|
+
DispatchQueue.main.async {
|
|
359
|
+
guard let self else { return }
|
|
360
|
+
self.loadedSideAccessoryIcon = image
|
|
361
|
+
.resizeImageTo(size: self.iconSize)?
|
|
362
|
+
.withRenderingMode(.alwaysTemplate)
|
|
363
|
+
self.scheduleSideAccessoryLayout()
|
|
364
|
+
}
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
191
368
|
// MARK: - Lifecycle
|
|
192
369
|
|
|
193
370
|
private func handleLayout() {
|
|
@@ -197,6 +374,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
197
374
|
setupTabBarControllerIfNeeded()
|
|
198
375
|
tabBarController?.view.frame = bounds
|
|
199
376
|
onNativeLayout?(["width": Double(bounds.width), "height": Double(bounds.height)])
|
|
377
|
+
scheduleSideAccessoryLayout()
|
|
200
378
|
}
|
|
201
379
|
|
|
202
380
|
private func setupTabBarControllerIfNeeded() {
|
|
@@ -215,6 +393,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
215
393
|
tbc.didMove(toParent: parentVC)
|
|
216
394
|
|
|
217
395
|
self.tabBarController = tbc
|
|
396
|
+
registerVerticalBarTraitChanges(tbc)
|
|
218
397
|
|
|
219
398
|
rebuildViewControllers()
|
|
220
399
|
updateTabBarAppearance()
|
|
@@ -356,6 +535,8 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
356
535
|
}
|
|
357
536
|
|
|
358
537
|
updateSelectedTab()
|
|
538
|
+
// The platter height follows the number of visible tabs.
|
|
539
|
+
scheduleSideAccessoryLayout()
|
|
359
540
|
}
|
|
360
541
|
|
|
361
542
|
// MARK: - Selection
|
|
@@ -498,6 +679,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
498
679
|
tbc.tabBar.isUserInteractionEnabled = !tabBarHidden
|
|
499
680
|
|
|
500
681
|
onTabBarMeasured?(["height": tabBarHidden ? 0 : Double(tbc.tabBar.frame.size.height)])
|
|
682
|
+
scheduleSideAccessoryLayout()
|
|
501
683
|
}
|
|
502
684
|
|
|
503
685
|
private func updateSidebarAdaptable() {
|
|
@@ -569,6 +751,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
569
751
|
self.imageLoader = loader
|
|
570
752
|
loadIconsFromSources()
|
|
571
753
|
loadSelectedIconsFromSources()
|
|
754
|
+
loadSideAccessoryIcon()
|
|
572
755
|
}
|
|
573
756
|
|
|
574
757
|
private func loadIconsFromSources() {
|
package/lib/module/BottomTabs.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import React, { useLayoutEffect, useRef } from
|
|
4
|
-
import { Image, Platform, StyleSheet, View, processColor } from
|
|
3
|
+
import React, { useLayoutEffect, useRef } from "react";
|
|
4
|
+
import { Image, Platform, StyleSheet, View, processColor } from "react-native";
|
|
5
5
|
import { BottomTabBarHeightContext } from "./utils/BottomTabBarHeightContext.js";
|
|
6
6
|
|
|
7
7
|
// eslint-disable-next-line @react-native/no-deep-imports
|
|
8
8
|
|
|
9
|
-
import NativeBottomTabs from
|
|
10
|
-
import useLatestCallback from
|
|
9
|
+
import NativeBottomTabs from "./BottomTabsNativeComponent";
|
|
10
|
+
import useLatestCallback from "use-latest-callback";
|
|
11
11
|
import DelayedFreeze from "./DelayedFreeze.js";
|
|
12
|
-
import NativeBottomTabsDrawer from
|
|
12
|
+
import NativeBottomTabsDrawer from "./BottomTabsDrawerNativeComponent";
|
|
13
13
|
import { BottomAccessoryView } from "./BottomAccessoryView.js";
|
|
14
14
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
15
15
|
const isAppleSymbol = icon => icon?.sfSymbol;
|
|
@@ -65,7 +65,7 @@ const BottomTabs = ({
|
|
|
65
65
|
route
|
|
66
66
|
}) => route.preventsDefault,
|
|
67
67
|
hapticFeedbackEnabled = false,
|
|
68
|
-
labeled = Platform.OS !==
|
|
68
|
+
labeled = Platform.OS !== "android" ? true : undefined,
|
|
69
69
|
getFreezeOnBlur = ({
|
|
70
70
|
route
|
|
71
71
|
}) => route.freezeOnBlur,
|
|
@@ -74,8 +74,12 @@ const BottomTabs = ({
|
|
|
74
74
|
tabLabelStyle,
|
|
75
75
|
renderBottomAccessoryView,
|
|
76
76
|
activeIndicatorColor,
|
|
77
|
-
androidBottomBlurEnabled = Platform.OS ===
|
|
77
|
+
androidBottomBlurEnabled = Platform.OS === "android" && Number(Platform.Version) >= 36,
|
|
78
78
|
delayedFreeze = false,
|
|
79
|
+
onTabBarPlacementChange,
|
|
80
|
+
sideAccessoryIcon,
|
|
81
|
+
sideAccessoryLabel,
|
|
82
|
+
onSideAccessoryPress,
|
|
79
83
|
...props
|
|
80
84
|
}) => {
|
|
81
85
|
// @ts-ignore
|
|
@@ -86,11 +90,11 @@ const BottomTabs = ({
|
|
|
86
90
|
const [customTabBarHeight, setCustomTabBarHeight] = React.useState(0);
|
|
87
91
|
const tabBarHeight = renderCustomTabBar ? customTabBarHeight : props.tabBarHidden ? 0 : nativeTabBarHeight;
|
|
88
92
|
const [measuredDimensions, setMeasuredDimensions] = React.useState({
|
|
89
|
-
width:
|
|
90
|
-
height:
|
|
93
|
+
width: "100%",
|
|
94
|
+
height: "100%"
|
|
91
95
|
});
|
|
92
96
|
const trimmedRoutes = React.useMemo(() => {
|
|
93
|
-
if (Platform.OS ===
|
|
97
|
+
if (Platform.OS === "android" && navigationState.routes.length > ANDROID_MAX_TABS) {
|
|
94
98
|
console.warn(`BottomTabs only supports up to ${ANDROID_MAX_TABS} tabs on Android`);
|
|
95
99
|
return navigationState.routes.slice(0, ANDROID_MAX_TABS);
|
|
96
100
|
}
|
|
@@ -100,7 +104,7 @@ const BottomTabs = ({
|
|
|
100
104
|
if (!loaded.includes(focusedKey)) {
|
|
101
105
|
setLoaded(loaded => [...loaded, focusedKey]);
|
|
102
106
|
}
|
|
103
|
-
const isIOS = Platform.OS ===
|
|
107
|
+
const isIOS = Platform.OS === "ios";
|
|
104
108
|
|
|
105
109
|
// Unfocused (outline) icons — independent of focusedKey so the array stays
|
|
106
110
|
// stable across tab switches and the native side never reloads on switch.
|
|
@@ -127,8 +131,8 @@ const BottomTabs = ({
|
|
|
127
131
|
const items = React.useMemo(() => trimmedRoutes.map((route, index) => {
|
|
128
132
|
const icon = icons[index];
|
|
129
133
|
const isSfSymbol = isAppleSymbol(icon);
|
|
130
|
-
if (Platform.OS ===
|
|
131
|
-
console.warn(
|
|
134
|
+
if (Platform.OS === "android" && isSfSymbol) {
|
|
135
|
+
console.warn("SF Symbols are not supported on Android. Use require() or pass uri to load an image instead.");
|
|
132
136
|
}
|
|
133
137
|
const color = processColor(getActiveTintColor({
|
|
134
138
|
route
|
|
@@ -148,9 +152,9 @@ const BottomTabs = ({
|
|
|
148
152
|
badge: getBadge?.({
|
|
149
153
|
route
|
|
150
154
|
}),
|
|
151
|
-
badgeBackgroundColor: typeof badgeBgColor ===
|
|
152
|
-
badgeTextColor: typeof badgeTxtColor ===
|
|
153
|
-
activeTintColor: typeof color ===
|
|
155
|
+
badgeBackgroundColor: typeof badgeBgColor === "number" ? badgeBgColor : undefined,
|
|
156
|
+
badgeTextColor: typeof badgeTxtColor === "number" ? badgeTxtColor : undefined,
|
|
157
|
+
activeTintColor: typeof color === "number" ? color : undefined,
|
|
154
158
|
hidden: getHidden?.({
|
|
155
159
|
route
|
|
156
160
|
}),
|
|
@@ -170,14 +174,14 @@ const BottomTabs = ({
|
|
|
170
174
|
// @ts-ignore - resolveAssetSource accepts ImageSourcePropType
|
|
171
175
|
const resolved = Image.resolveAssetSource(icon);
|
|
172
176
|
return {
|
|
173
|
-
uri: resolved?.uri ??
|
|
177
|
+
uri: resolved?.uri ?? "",
|
|
174
178
|
width: resolved?.width ?? 0,
|
|
175
179
|
height: resolved?.height ?? 0,
|
|
176
180
|
scale: resolved?.scale ?? 1
|
|
177
181
|
};
|
|
178
182
|
}
|
|
179
183
|
return {
|
|
180
|
-
uri:
|
|
184
|
+
uri: "",
|
|
181
185
|
width: 0,
|
|
182
186
|
height: 0,
|
|
183
187
|
scale: 1
|
|
@@ -191,14 +195,14 @@ const BottomTabs = ({
|
|
|
191
195
|
// @ts-ignore - resolveAssetSource accepts ImageSourcePropType
|
|
192
196
|
const resolved = Image.resolveAssetSource(icon);
|
|
193
197
|
return {
|
|
194
|
-
uri: resolved?.uri ??
|
|
198
|
+
uri: resolved?.uri ?? "",
|
|
195
199
|
width: resolved?.width ?? 0,
|
|
196
200
|
height: resolved?.height ?? 0,
|
|
197
201
|
scale: resolved?.scale ?? 1
|
|
198
202
|
};
|
|
199
203
|
}
|
|
200
204
|
return {
|
|
201
|
-
uri:
|
|
205
|
+
uri: "",
|
|
202
206
|
width: 0,
|
|
203
207
|
height: 0,
|
|
204
208
|
scale: 1
|
|
@@ -244,6 +248,25 @@ const BottomTabs = ({
|
|
|
244
248
|
height
|
|
245
249
|
});
|
|
246
250
|
}, [setMeasuredDimensions]);
|
|
251
|
+
const handleTabBarPlacementChange = React.useCallback(event => {
|
|
252
|
+
onTabBarPlacementChange?.(event.nativeEvent.placement);
|
|
253
|
+
}, [onTabBarPlacementChange]);
|
|
254
|
+
const handleSideAccessoryPress = React.useCallback(() => {
|
|
255
|
+
onSideAccessoryPress?.();
|
|
256
|
+
}, [onSideAccessoryPress]);
|
|
257
|
+
const resolvedSideAccessoryIcon = React.useMemo(() => {
|
|
258
|
+
if (!isIOS || !sideAccessoryIcon) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
// @ts-ignore - resolveAssetSource accepts ImageSourcePropType
|
|
262
|
+
const resolved = Image.resolveAssetSource(sideAccessoryIcon);
|
|
263
|
+
return resolved?.uri ? {
|
|
264
|
+
uri: resolved.uri,
|
|
265
|
+
width: resolved.width ?? 0,
|
|
266
|
+
height: resolved.height ?? 0,
|
|
267
|
+
scale: resolved.scale ?? 1
|
|
268
|
+
} : undefined;
|
|
269
|
+
}, [sideAccessoryIcon]);
|
|
247
270
|
useLayoutEffect(() => {
|
|
248
271
|
if (renderCustomTabBar && customTabBarWrapperRef.current) {
|
|
249
272
|
customTabBarWrapperRef.current.measure((_x, _y, _width, height) => {
|
|
@@ -264,7 +287,7 @@ const BottomTabs = ({
|
|
|
264
287
|
children: [/*#__PURE__*/_jsx(View, {
|
|
265
288
|
collapsable: false,
|
|
266
289
|
style: styles.drawerMenu,
|
|
267
|
-
children: Platform.OS !==
|
|
290
|
+
children: Platform.OS !== "web" ? renderDrawerContent?.() : null
|
|
268
291
|
}), /*#__PURE__*/_jsxs(View, {
|
|
269
292
|
collapsable: false,
|
|
270
293
|
style: styles.fullWidth,
|
|
@@ -282,13 +305,17 @@ const BottomTabs = ({
|
|
|
282
305
|
onPageSelected: handlePageSelected,
|
|
283
306
|
onTabBarMeasured: handleTabBarMeasured,
|
|
284
307
|
onNativeLayout: handleNativeLayout,
|
|
308
|
+
onTabBarPlacementChange: handleTabBarPlacementChange,
|
|
309
|
+
sideAccessoryIcon: renderCustomTabBar ? undefined : resolvedSideAccessoryIcon,
|
|
310
|
+
sideAccessoryLabel: sideAccessoryLabel,
|
|
311
|
+
onSideAccessoryPress: handleSideAccessoryPress,
|
|
285
312
|
hapticFeedbackEnabled: hapticFeedbackEnabled,
|
|
286
313
|
activeTintColor: activeTintColor,
|
|
287
314
|
inactiveTintColor: inactiveTintColor,
|
|
288
315
|
barTintColor: tabBarStyle?.backgroundColor,
|
|
289
316
|
rippleColor: rippleColor,
|
|
290
317
|
activeIndicatorColor: activeIndicatorColor,
|
|
291
|
-
androidBottomBlurEnabled: Platform.OS ===
|
|
318
|
+
androidBottomBlurEnabled: Platform.OS === "android" && !renderCustomTabBar && androidBottomBlurEnabled,
|
|
292
319
|
labeled: labeled,
|
|
293
320
|
children: [trimmedRoutes.map(route => {
|
|
294
321
|
if (getLazy({
|
|
@@ -312,9 +339,9 @@ const BottomTabs = ({
|
|
|
312
339
|
return /*#__PURE__*/_jsx(View, {
|
|
313
340
|
style: [styles.screen, renderCustomTabBar ? styles.fullWidth : measuredDimensions, customStyle],
|
|
314
341
|
collapsable: false,
|
|
315
|
-
pointerEvents: focused ?
|
|
342
|
+
pointerEvents: focused ? "auto" : "none",
|
|
316
343
|
accessibilityElementsHidden: !focused,
|
|
317
|
-
importantForAccessibility: focused ?
|
|
344
|
+
importantForAccessibility: focused ? "auto" : "no-hide-descendants",
|
|
318
345
|
children: /*#__PURE__*/_jsx(DelayedFreeze, {
|
|
319
346
|
freeze: !!freeze,
|
|
320
347
|
delayedFreeze: delayedFreeze,
|
|
@@ -324,7 +351,7 @@ const BottomTabs = ({
|
|
|
324
351
|
})
|
|
325
352
|
})
|
|
326
353
|
}, route.key);
|
|
327
|
-
}), Platform.OS ===
|
|
354
|
+
}), Platform.OS === "ios" && parseFloat(Platform.Version) >= 26 && renderBottomAccessoryView && !renderCustomTabBar ? /*#__PURE__*/_jsx(BottomAccessoryView, {
|
|
328
355
|
renderBottomAccessoryView: renderBottomAccessoryView
|
|
329
356
|
}) : null]
|
|
330
357
|
}), renderCustomTabBar ? /*#__PURE__*/_jsx(View, {
|
|
@@ -338,17 +365,17 @@ const BottomTabs = ({
|
|
|
338
365
|
};
|
|
339
366
|
const styles = StyleSheet.create({
|
|
340
367
|
fullWidth: {
|
|
341
|
-
width:
|
|
342
|
-
height:
|
|
368
|
+
width: "100%",
|
|
369
|
+
height: "100%",
|
|
343
370
|
flex: 1
|
|
344
371
|
},
|
|
345
372
|
drawerMenu: {
|
|
346
|
-
position:
|
|
347
|
-
width:
|
|
348
|
-
height:
|
|
373
|
+
position: "absolute",
|
|
374
|
+
width: "78%",
|
|
375
|
+
height: "100%"
|
|
349
376
|
},
|
|
350
377
|
screen: {
|
|
351
|
-
position:
|
|
378
|
+
position: "absolute"
|
|
352
379
|
}
|
|
353
380
|
});
|
|
354
381
|
export default BottomTabs;
|
|
@@ -22,6 +22,14 @@ export type OnNativeLayout = Readonly<{
|
|
|
22
22
|
height: Double;
|
|
23
23
|
}>;
|
|
24
24
|
|
|
25
|
+
// Where the system draws the tab bar: 'bottom', or 'leading' / 'trailing' for
|
|
26
|
+
// a vertical bar (the iPhone Duo side strip, iOS 27.1+).
|
|
27
|
+
export type OnTabBarPlacementChange = Readonly<{
|
|
28
|
+
placement: string;
|
|
29
|
+
}>;
|
|
30
|
+
|
|
31
|
+
export type OnSideAccessoryPress = Readonly<{}>;
|
|
32
|
+
|
|
25
33
|
export type BottomTabsItems = ReadonlyArray<{
|
|
26
34
|
key: string;
|
|
27
35
|
title: string;
|
|
@@ -65,6 +73,11 @@ export interface BottomTabsNativeProps extends ViewProps {
|
|
|
65
73
|
ignoreBottomInsets?: boolean;
|
|
66
74
|
androidTabBarStyle?: WithDefault<'default' | 'floating', 'default'>;
|
|
67
75
|
ignoreKeyboardInsets?: WithDefault<boolean, true>;
|
|
76
|
+
onTabBarPlacementChange?: DirectEventHandler<OnTabBarPlacementChange>;
|
|
77
|
+
// A round button drawn above a vertical tab bar; iOS only.
|
|
78
|
+
sideAccessoryIcon?: ImageSource;
|
|
79
|
+
sideAccessoryLabel?: string;
|
|
80
|
+
onSideAccessoryPress?: DirectEventHandler<OnSideAccessoryPress>;
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
export default codegenNativeComponent<BottomTabsNativeProps>('RNBottomTabsView');
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import React from
|
|
2
|
-
import { type ColorValue, type StyleProp, type ViewStyle } from
|
|
3
|
-
import type { ImageSource } from
|
|
4
|
-
import type { AppleIcon, BaseRoute, NavigationState, TabRole } from
|
|
5
|
-
import { type RNBottomTabsAccessoryViewProps } from
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type ColorValue, type ImageSourcePropType, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import type { ImageSource } from "react-native/Libraries/Image/ImageSource";
|
|
4
|
+
import type { AppleIcon, BaseRoute, NavigationState, TabBarPlacement, TabRole } from "./types";
|
|
5
|
+
import { type RNBottomTabsAccessoryViewProps } from "./BottomAccessoryView";
|
|
6
6
|
interface Props<Route extends BaseRoute> {
|
|
7
7
|
labeled?: boolean;
|
|
8
8
|
/** Optional physical-left menu; all motion and input are owned by native. */
|
|
@@ -35,11 +35,11 @@ interface Props<Route extends BaseRoute> {
|
|
|
35
35
|
/**
|
|
36
36
|
* Describes the appearance attributes for the tabBar to use when an observable scroll view is scrolled to the bottom. (iOS only)
|
|
37
37
|
*/
|
|
38
|
-
scrollEdgeAppearance?:
|
|
38
|
+
scrollEdgeAppearance?: "default" | "opaque" | "transparent";
|
|
39
39
|
/**
|
|
40
40
|
* Behavior for minimizing the tab bar. (iOS 26+)
|
|
41
41
|
*/
|
|
42
|
-
minimizeBehavior?:
|
|
42
|
+
minimizeBehavior?: "automatic" | "onScrollDown" | "onScrollUp" | "never";
|
|
43
43
|
/**
|
|
44
44
|
* Active tab color.
|
|
45
45
|
*/
|
|
@@ -186,7 +186,7 @@ interface Props<Route extends BaseRoute> {
|
|
|
186
186
|
* A function that returns a React element to display as bottom accessory view.
|
|
187
187
|
* iOS 26+ only.
|
|
188
188
|
*/
|
|
189
|
-
renderBottomAccessoryView?: RNBottomTabsAccessoryViewProps[
|
|
189
|
+
renderBottomAccessoryView?: RNBottomTabsAccessoryViewProps["renderBottomAccessoryView"];
|
|
190
190
|
/**
|
|
191
191
|
* Whether the tab bar is hidden.
|
|
192
192
|
*/
|
|
@@ -198,7 +198,7 @@ interface Props<Route extends BaseRoute> {
|
|
|
198
198
|
/**
|
|
199
199
|
* Visual style of the Android tab bar. Android only.
|
|
200
200
|
*/
|
|
201
|
-
androidTabBarStyle?:
|
|
201
|
+
androidTabBarStyle?: "default" | "floating";
|
|
202
202
|
/**
|
|
203
203
|
* Whether the tab bar ignores the soft keyboard. Defaults to true, so the
|
|
204
204
|
* keyboard covers the tab bar as on iOS. Set to false to pad the tab bar by
|
|
@@ -211,7 +211,25 @@ interface Props<Route extends BaseRoute> {
|
|
|
211
211
|
* original delayed behavior.
|
|
212
212
|
*/
|
|
213
213
|
delayedFreeze?: boolean;
|
|
214
|
+
/**
|
|
215
|
+
* Called when the system moves the tab bar between the bottom edge and a
|
|
216
|
+
* vertical bar on the leading or trailing edge (iPhone Duo). iOS only.
|
|
217
|
+
*/
|
|
218
|
+
onTabBarPlacementChange?: (placement: TabBarPlacement) => void;
|
|
219
|
+
/**
|
|
220
|
+
* Icon of a round button shown above a vertical tab bar. The button only
|
|
221
|
+
* appears while the tab bar is vertical and visible. iOS only.
|
|
222
|
+
*/
|
|
223
|
+
sideAccessoryIcon?: ImageSourcePropType;
|
|
224
|
+
/**
|
|
225
|
+
* Accessibility label of the side accessory button.
|
|
226
|
+
*/
|
|
227
|
+
sideAccessoryLabel?: string;
|
|
228
|
+
/**
|
|
229
|
+
* Called when the side accessory button is pressed.
|
|
230
|
+
*/
|
|
231
|
+
onSideAccessoryPress?: () => void;
|
|
214
232
|
}
|
|
215
|
-
declare const BottomTabs: <Route extends BaseRoute>({ navigationState, renderScene, renderLazyPlaceholder, onIndexChange, onTabDoublePress, renderDrawerContent, drawerOpen, drawerSwipeEnabled, onDrawerOpenChange, rippleColor, tabBarActiveTintColor: activeTintColor, tabBarInactiveTintColor: inactiveTintColor, getBadge, getBadgeBackgroundColor, getBadgeTextColor, getLazy, getLabelText, getIcon, getHidden, getActiveTintColor, getTestID, getRole, getSceneStyle, getPreventsDefault, hapticFeedbackEnabled, labeled, getFreezeOnBlur, tabBar: renderCustomTabBar, tabBarStyle, tabLabelStyle, renderBottomAccessoryView, activeIndicatorColor, androidBottomBlurEnabled, delayedFreeze, ...props }: Props<Route>) => import("react/jsx-runtime").JSX.Element;
|
|
233
|
+
declare const BottomTabs: <Route extends BaseRoute>({ navigationState, renderScene, renderLazyPlaceholder, onIndexChange, onTabDoublePress, renderDrawerContent, drawerOpen, drawerSwipeEnabled, onDrawerOpenChange, rippleColor, tabBarActiveTintColor: activeTintColor, tabBarInactiveTintColor: inactiveTintColor, getBadge, getBadgeBackgroundColor, getBadgeTextColor, getLazy, getLabelText, getIcon, getHidden, getActiveTintColor, getTestID, getRole, getSceneStyle, getPreventsDefault, hapticFeedbackEnabled, labeled, getFreezeOnBlur, tabBar: renderCustomTabBar, tabBarStyle, tabLabelStyle, renderBottomAccessoryView, activeIndicatorColor, androidBottomBlurEnabled, delayedFreeze, onTabBarPlacementChange, sideAccessoryIcon, sideAccessoryLabel, onSideAccessoryPress, ...props }: Props<Route>) => import("react/jsx-runtime").JSX.Element;
|
|
216
234
|
export default BottomTabs;
|
|
217
235
|
//# sourceMappingURL=BottomTabs.d.ts.map
|
|
@@ -11,6 +11,10 @@ export type OnNativeLayout = Readonly<{
|
|
|
11
11
|
width: Double;
|
|
12
12
|
height: Double;
|
|
13
13
|
}>;
|
|
14
|
+
export type OnTabBarPlacementChange = Readonly<{
|
|
15
|
+
placement: string;
|
|
16
|
+
}>;
|
|
17
|
+
export type OnSideAccessoryPress = Readonly<{}>;
|
|
14
18
|
export type BottomTabsItems = ReadonlyArray<{
|
|
15
19
|
key: string;
|
|
16
20
|
title: string;
|
|
@@ -53,6 +57,10 @@ export interface BottomTabsNativeProps extends ViewProps {
|
|
|
53
57
|
ignoreBottomInsets?: boolean;
|
|
54
58
|
androidTabBarStyle?: WithDefault<'default' | 'floating', 'default'>;
|
|
55
59
|
ignoreKeyboardInsets?: WithDefault<boolean, true>;
|
|
60
|
+
onTabBarPlacementChange?: DirectEventHandler<OnTabBarPlacementChange>;
|
|
61
|
+
sideAccessoryIcon?: ImageSource;
|
|
62
|
+
sideAccessoryLabel?: string;
|
|
63
|
+
onSideAccessoryPress?: DirectEventHandler<OnSideAccessoryPress>;
|
|
56
64
|
}
|
|
57
65
|
declare const _default: import("react-native").HostComponent<BottomTabsNativeProps>;
|
|
58
66
|
export default _default;
|
|
@@ -12,5 +12,5 @@ export { BottomTabBarHeightContext } from './utils/BottomTabBarHeightContext';
|
|
|
12
12
|
/**
|
|
13
13
|
* Types
|
|
14
14
|
*/
|
|
15
|
-
export type { AppleIcon, TabRole } from './types';
|
|
15
|
+
export type { AppleIcon, TabBarPlacement, TabRole } from './types';
|
|
16
16
|
//# sourceMappingURL=index.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onekeyfe/react-native-bottom-tabs",
|
|
3
|
-
"version": "6.7.0-alpha.
|
|
3
|
+
"version": "6.7.0-alpha.386",
|
|
4
4
|
"description": "Native bottom tabs (tab bar + scene container) for React Native on iOS and Android",
|
|
5
5
|
"source": "./src/index.tsx",
|
|
6
6
|
"main": "./lib/module/index.js",
|
package/src/BottomTabs.tsx
CHANGED
|
@@ -1,31 +1,38 @@
|
|
|
1
|
-
import React, { useLayoutEffect, useRef } from
|
|
2
|
-
import type { NativeSyntheticEvent } from
|
|
1
|
+
import React, { useLayoutEffect, useRef } from "react";
|
|
2
|
+
import type { NativeSyntheticEvent } from "react-native";
|
|
3
3
|
import {
|
|
4
4
|
type ColorValue,
|
|
5
5
|
type DimensionValue,
|
|
6
6
|
Image,
|
|
7
|
+
type ImageSourcePropType,
|
|
7
8
|
Platform,
|
|
8
9
|
type StyleProp,
|
|
9
10
|
StyleSheet,
|
|
10
11
|
View,
|
|
11
12
|
type ViewStyle,
|
|
12
13
|
processColor,
|
|
13
|
-
} from
|
|
14
|
-
import { BottomTabBarHeightContext } from
|
|
14
|
+
} from "react-native";
|
|
15
|
+
import { BottomTabBarHeightContext } from "./utils/BottomTabBarHeightContext";
|
|
15
16
|
|
|
16
17
|
// eslint-disable-next-line @react-native/no-deep-imports
|
|
17
|
-
import type { ImageSource } from
|
|
18
|
+
import type { ImageSource } from "react-native/Libraries/Image/ImageSource";
|
|
18
19
|
import NativeBottomTabs, {
|
|
19
20
|
type BottomTabsItems,
|
|
20
|
-
} from
|
|
21
|
-
import useLatestCallback from
|
|
22
|
-
import type {
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
} from "./BottomTabsNativeComponent";
|
|
22
|
+
import useLatestCallback from "use-latest-callback";
|
|
23
|
+
import type {
|
|
24
|
+
AppleIcon,
|
|
25
|
+
BaseRoute,
|
|
26
|
+
NavigationState,
|
|
27
|
+
TabBarPlacement,
|
|
28
|
+
TabRole,
|
|
29
|
+
} from "./types";
|
|
30
|
+
import DelayedFreeze from "./DelayedFreeze";
|
|
31
|
+
import NativeBottomTabsDrawer from "./BottomTabsDrawerNativeComponent";
|
|
25
32
|
import {
|
|
26
33
|
BottomAccessoryView,
|
|
27
34
|
type RNBottomTabsAccessoryViewProps,
|
|
28
|
-
} from
|
|
35
|
+
} from "./BottomAccessoryView";
|
|
29
36
|
|
|
30
37
|
const isAppleSymbol = (icon: any): icon is { sfSymbol: string } =>
|
|
31
38
|
icon?.sfSymbol;
|
|
@@ -65,12 +72,12 @@ interface Props<Route extends BaseRoute> {
|
|
|
65
72
|
/**
|
|
66
73
|
* Describes the appearance attributes for the tabBar to use when an observable scroll view is scrolled to the bottom. (iOS only)
|
|
67
74
|
*/
|
|
68
|
-
scrollEdgeAppearance?:
|
|
75
|
+
scrollEdgeAppearance?: "default" | "opaque" | "transparent";
|
|
69
76
|
|
|
70
77
|
/**
|
|
71
78
|
* Behavior for minimizing the tab bar. (iOS 26+)
|
|
72
79
|
*/
|
|
73
|
-
minimizeBehavior?:
|
|
80
|
+
minimizeBehavior?: "automatic" | "onScrollDown" | "onScrollUp" | "never";
|
|
74
81
|
/**
|
|
75
82
|
* Active tab color.
|
|
76
83
|
*/
|
|
@@ -199,7 +206,7 @@ interface Props<Route extends BaseRoute> {
|
|
|
199
206
|
* A function that returns a React element to display as bottom accessory view.
|
|
200
207
|
* iOS 26+ only.
|
|
201
208
|
*/
|
|
202
|
-
renderBottomAccessoryView?: RNBottomTabsAccessoryViewProps[
|
|
209
|
+
renderBottomAccessoryView?: RNBottomTabsAccessoryViewProps["renderBottomAccessoryView"];
|
|
203
210
|
/**
|
|
204
211
|
* Whether the tab bar is hidden.
|
|
205
212
|
*/
|
|
@@ -211,7 +218,7 @@ interface Props<Route extends BaseRoute> {
|
|
|
211
218
|
/**
|
|
212
219
|
* Visual style of the Android tab bar. Android only.
|
|
213
220
|
*/
|
|
214
|
-
androidTabBarStyle?:
|
|
221
|
+
androidTabBarStyle?: "default" | "floating";
|
|
215
222
|
/**
|
|
216
223
|
* Whether the tab bar ignores the soft keyboard. Defaults to true, so the
|
|
217
224
|
* keyboard covers the tab bar as on iOS. Set to false to pad the tab bar by
|
|
@@ -224,6 +231,24 @@ interface Props<Route extends BaseRoute> {
|
|
|
224
231
|
* original delayed behavior.
|
|
225
232
|
*/
|
|
226
233
|
delayedFreeze?: boolean;
|
|
234
|
+
/**
|
|
235
|
+
* Called when the system moves the tab bar between the bottom edge and a
|
|
236
|
+
* vertical bar on the leading or trailing edge (iPhone Duo). iOS only.
|
|
237
|
+
*/
|
|
238
|
+
onTabBarPlacementChange?: (placement: TabBarPlacement) => void;
|
|
239
|
+
/**
|
|
240
|
+
* Icon of a round button shown above a vertical tab bar. The button only
|
|
241
|
+
* appears while the tab bar is vertical and visible. iOS only.
|
|
242
|
+
*/
|
|
243
|
+
sideAccessoryIcon?: ImageSourcePropType;
|
|
244
|
+
/**
|
|
245
|
+
* Accessibility label of the side accessory button.
|
|
246
|
+
*/
|
|
247
|
+
sideAccessoryLabel?: string;
|
|
248
|
+
/**
|
|
249
|
+
* Called when the side accessory button is pressed.
|
|
250
|
+
*/
|
|
251
|
+
onSideAccessoryPress?: () => void;
|
|
227
252
|
}
|
|
228
253
|
|
|
229
254
|
const ANDROID_MAX_TABS = 100;
|
|
@@ -260,34 +285,41 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
260
285
|
getSceneStyle = ({ route }: { route: Route }) => route.style,
|
|
261
286
|
getPreventsDefault = ({ route }: { route: Route }) => route.preventsDefault,
|
|
262
287
|
hapticFeedbackEnabled = false,
|
|
263
|
-
labeled = Platform.OS !==
|
|
288
|
+
labeled = Platform.OS !== "android" ? true : undefined,
|
|
264
289
|
getFreezeOnBlur = ({ route }: { route: Route }) => route.freezeOnBlur,
|
|
265
290
|
tabBar: renderCustomTabBar,
|
|
266
291
|
tabBarStyle,
|
|
267
292
|
tabLabelStyle,
|
|
268
293
|
renderBottomAccessoryView,
|
|
269
294
|
activeIndicatorColor,
|
|
270
|
-
androidBottomBlurEnabled = Platform.OS ===
|
|
295
|
+
androidBottomBlurEnabled = Platform.OS === "android" &&
|
|
271
296
|
Number(Platform.Version) >= 36,
|
|
272
297
|
delayedFreeze = false,
|
|
298
|
+
onTabBarPlacementChange,
|
|
299
|
+
sideAccessoryIcon,
|
|
300
|
+
sideAccessoryLabel,
|
|
301
|
+
onSideAccessoryPress,
|
|
273
302
|
...props
|
|
274
303
|
}: Props<Route>) => {
|
|
275
304
|
// @ts-ignore
|
|
276
305
|
const focusedKey = navigationState.routes[navigationState.index].key;
|
|
277
306
|
const customTabBarWrapperRef = useRef<View>(null) as React.RefObject<any>;
|
|
278
|
-
const nativeBottomTabsRef =
|
|
307
|
+
const nativeBottomTabsRef =
|
|
308
|
+
useRef<React.ElementRef<typeof NativeBottomTabs>>(null);
|
|
279
309
|
const [nativeTabBarHeight, setNativeTabBarHeight] = React.useState(0);
|
|
280
310
|
const [customTabBarHeight, setCustomTabBarHeight] = React.useState(0);
|
|
281
311
|
const tabBarHeight = renderCustomTabBar
|
|
282
312
|
? customTabBarHeight
|
|
283
|
-
: props.tabBarHidden
|
|
313
|
+
: props.tabBarHidden
|
|
314
|
+
? 0
|
|
315
|
+
: nativeTabBarHeight;
|
|
284
316
|
const [measuredDimensions, setMeasuredDimensions] = React.useState<
|
|
285
317
|
{ width: DimensionValue; height: DimensionValue } | undefined
|
|
286
|
-
>({ width:
|
|
318
|
+
>({ width: "100%", height: "100%" });
|
|
287
319
|
|
|
288
320
|
const trimmedRoutes = React.useMemo(() => {
|
|
289
321
|
if (
|
|
290
|
-
Platform.OS ===
|
|
322
|
+
Platform.OS === "android" &&
|
|
291
323
|
navigationState.routes.length > ANDROID_MAX_TABS
|
|
292
324
|
) {
|
|
293
325
|
console.warn(
|
|
@@ -304,7 +336,7 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
304
336
|
setLoaded((loaded) => [...loaded, focusedKey]);
|
|
305
337
|
}
|
|
306
338
|
|
|
307
|
-
const isIOS = Platform.OS ===
|
|
339
|
+
const isIOS = Platform.OS === "ios";
|
|
308
340
|
|
|
309
341
|
// Unfocused (outline) icons — independent of focusedKey so the array stays
|
|
310
342
|
// stable across tab switches and the native side never reloads on switch.
|
|
@@ -341,9 +373,9 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
341
373
|
const icon = icons[index];
|
|
342
374
|
const isSfSymbol = isAppleSymbol(icon);
|
|
343
375
|
|
|
344
|
-
if (Platform.OS ===
|
|
376
|
+
if (Platform.OS === "android" && isSfSymbol) {
|
|
345
377
|
console.warn(
|
|
346
|
-
|
|
378
|
+
"SF Symbols are not supported on Android. Use require() or pass uri to load an image instead."
|
|
347
379
|
);
|
|
348
380
|
}
|
|
349
381
|
|
|
@@ -357,10 +389,10 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
357
389
|
sfSymbol: isSfSymbol ? icon.sfSymbol : undefined,
|
|
358
390
|
badge: getBadge?.({ route }),
|
|
359
391
|
badgeBackgroundColor:
|
|
360
|
-
typeof badgeBgColor ===
|
|
392
|
+
typeof badgeBgColor === "number" ? badgeBgColor : undefined,
|
|
361
393
|
badgeTextColor:
|
|
362
|
-
typeof badgeTxtColor ===
|
|
363
|
-
activeTintColor: typeof color ===
|
|
394
|
+
typeof badgeTxtColor === "number" ? badgeTxtColor : undefined,
|
|
395
|
+
activeTintColor: typeof color === "number" ? color : undefined,
|
|
364
396
|
hidden: getHidden?.({ route }),
|
|
365
397
|
testID: getTestID?.({ route }),
|
|
366
398
|
role: getRole?.({ route }),
|
|
@@ -389,13 +421,13 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
389
421
|
// @ts-ignore - resolveAssetSource accepts ImageSourcePropType
|
|
390
422
|
const resolved = Image.resolveAssetSource(icon);
|
|
391
423
|
return {
|
|
392
|
-
uri: resolved?.uri ??
|
|
424
|
+
uri: resolved?.uri ?? "",
|
|
393
425
|
width: resolved?.width ?? 0,
|
|
394
426
|
height: resolved?.height ?? 0,
|
|
395
427
|
scale: resolved?.scale ?? 1,
|
|
396
428
|
};
|
|
397
429
|
}
|
|
398
|
-
return { uri:
|
|
430
|
+
return { uri: "", width: 0, height: 0, scale: 1 };
|
|
399
431
|
}),
|
|
400
432
|
[icons]
|
|
401
433
|
);
|
|
@@ -409,13 +441,13 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
409
441
|
// @ts-ignore - resolveAssetSource accepts ImageSourcePropType
|
|
410
442
|
const resolved = Image.resolveAssetSource(icon);
|
|
411
443
|
return {
|
|
412
|
-
uri: resolved?.uri ??
|
|
444
|
+
uri: resolved?.uri ?? "",
|
|
413
445
|
width: resolved?.width ?? 0,
|
|
414
446
|
height: resolved?.height ?? 0,
|
|
415
447
|
scale: resolved?.scale ?? 1,
|
|
416
448
|
};
|
|
417
449
|
}
|
|
418
|
-
return { uri:
|
|
450
|
+
return { uri: "", width: 0, height: 0, scale: 1 };
|
|
419
451
|
}),
|
|
420
452
|
[focusedIcons]
|
|
421
453
|
);
|
|
@@ -444,10 +476,12 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
444
476
|
const { key } = event.nativeEvent;
|
|
445
477
|
const accepted = jumpTo(key);
|
|
446
478
|
if (accepted === false) {
|
|
447
|
-
nativeBottomTabsRef.current?.setNativeProps({
|
|
479
|
+
nativeBottomTabsRef.current?.setNativeProps({
|
|
480
|
+
selectedPage: focusedKey,
|
|
481
|
+
});
|
|
448
482
|
}
|
|
449
483
|
},
|
|
450
|
-
[focusedKey, jumpTo]
|
|
484
|
+
[focusedKey, jumpTo]
|
|
451
485
|
);
|
|
452
486
|
|
|
453
487
|
const handleTabBarMeasured = React.useCallback(
|
|
@@ -465,11 +499,40 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
465
499
|
[setMeasuredDimensions]
|
|
466
500
|
);
|
|
467
501
|
|
|
502
|
+
const handleTabBarPlacementChange = React.useCallback(
|
|
503
|
+
(event: NativeSyntheticEvent<{ placement: string }>) => {
|
|
504
|
+
onTabBarPlacementChange?.(event.nativeEvent.placement as TabBarPlacement);
|
|
505
|
+
},
|
|
506
|
+
[onTabBarPlacementChange]
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
const handleSideAccessoryPress = React.useCallback(() => {
|
|
510
|
+
onSideAccessoryPress?.();
|
|
511
|
+
}, [onSideAccessoryPress]);
|
|
512
|
+
|
|
513
|
+
const resolvedSideAccessoryIcon = React.useMemo(() => {
|
|
514
|
+
if (!isIOS || !sideAccessoryIcon) {
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
// @ts-ignore - resolveAssetSource accepts ImageSourcePropType
|
|
518
|
+
const resolved = Image.resolveAssetSource(sideAccessoryIcon);
|
|
519
|
+
return resolved?.uri
|
|
520
|
+
? {
|
|
521
|
+
uri: resolved.uri,
|
|
522
|
+
width: resolved.width ?? 0,
|
|
523
|
+
height: resolved.height ?? 0,
|
|
524
|
+
scale: resolved.scale ?? 1,
|
|
525
|
+
}
|
|
526
|
+
: undefined;
|
|
527
|
+
}, [sideAccessoryIcon]);
|
|
528
|
+
|
|
468
529
|
useLayoutEffect(() => {
|
|
469
530
|
if (renderCustomTabBar && customTabBarWrapperRef.current) {
|
|
470
|
-
customTabBarWrapperRef.current.measure(
|
|
471
|
-
|
|
472
|
-
|
|
531
|
+
customTabBarWrapperRef.current.measure(
|
|
532
|
+
(_x: number, _y: number, _width: number, height: number) => {
|
|
533
|
+
setCustomTabBarHeight(height);
|
|
534
|
+
}
|
|
535
|
+
);
|
|
473
536
|
}
|
|
474
537
|
}, [renderCustomTabBar]);
|
|
475
538
|
|
|
@@ -487,7 +550,7 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
487
550
|
}
|
|
488
551
|
>
|
|
489
552
|
<View collapsable={false} style={styles.drawerMenu}>
|
|
490
|
-
{Platform.OS !==
|
|
553
|
+
{Platform.OS !== "web" ? renderDrawerContent?.() : null}
|
|
491
554
|
</View>
|
|
492
555
|
<View collapsable={false} style={styles.fullWidth}>
|
|
493
556
|
<NativeBottomTabs
|
|
@@ -498,7 +561,9 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
498
561
|
items={items}
|
|
499
562
|
icons={renderCustomTabBar ? undefined : resolvedIconAssets}
|
|
500
563
|
selectedIcons={
|
|
501
|
-
isIOS && !renderCustomTabBar
|
|
564
|
+
isIOS && !renderCustomTabBar
|
|
565
|
+
? resolvedSelectedIconAssets
|
|
566
|
+
: undefined
|
|
502
567
|
}
|
|
503
568
|
selectedPage={focusedKey}
|
|
504
569
|
tabBarHidden={props.tabBarHidden ?? !!renderCustomTabBar}
|
|
@@ -506,6 +571,12 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
506
571
|
onPageSelected={handlePageSelected}
|
|
507
572
|
onTabBarMeasured={handleTabBarMeasured}
|
|
508
573
|
onNativeLayout={handleNativeLayout}
|
|
574
|
+
onTabBarPlacementChange={handleTabBarPlacementChange}
|
|
575
|
+
sideAccessoryIcon={
|
|
576
|
+
renderCustomTabBar ? undefined : resolvedSideAccessoryIcon
|
|
577
|
+
}
|
|
578
|
+
sideAccessoryLabel={sideAccessoryLabel}
|
|
579
|
+
onSideAccessoryPress={handleSideAccessoryPress}
|
|
509
580
|
hapticFeedbackEnabled={hapticFeedbackEnabled}
|
|
510
581
|
activeTintColor={activeTintColor}
|
|
511
582
|
inactiveTintColor={inactiveTintColor}
|
|
@@ -513,7 +584,7 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
513
584
|
rippleColor={rippleColor}
|
|
514
585
|
activeIndicatorColor={activeIndicatorColor}
|
|
515
586
|
androidBottomBlurEnabled={
|
|
516
|
-
Platform.OS ===
|
|
587
|
+
Platform.OS === "android" &&
|
|
517
588
|
!renderCustomTabBar &&
|
|
518
589
|
androidBottomBlurEnabled
|
|
519
590
|
}
|
|
@@ -546,13 +617,16 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
546
617
|
customStyle,
|
|
547
618
|
]}
|
|
548
619
|
collapsable={false}
|
|
549
|
-
pointerEvents={focused ?
|
|
620
|
+
pointerEvents={focused ? "auto" : "none"}
|
|
550
621
|
accessibilityElementsHidden={!focused}
|
|
551
622
|
importantForAccessibility={
|
|
552
|
-
focused ?
|
|
623
|
+
focused ? "auto" : "no-hide-descendants"
|
|
553
624
|
}
|
|
554
625
|
>
|
|
555
|
-
<DelayedFreeze
|
|
626
|
+
<DelayedFreeze
|
|
627
|
+
freeze={!!freeze}
|
|
628
|
+
delayedFreeze={delayedFreeze}
|
|
629
|
+
>
|
|
556
630
|
{renderScene({
|
|
557
631
|
route,
|
|
558
632
|
jumpTo,
|
|
@@ -561,7 +635,7 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
561
635
|
</View>
|
|
562
636
|
);
|
|
563
637
|
})}
|
|
564
|
-
{Platform.OS ===
|
|
638
|
+
{Platform.OS === "ios" &&
|
|
565
639
|
parseFloat(Platform.Version) >= 26 &&
|
|
566
640
|
renderBottomAccessoryView &&
|
|
567
641
|
!renderCustomTabBar ? (
|
|
@@ -573,7 +647,9 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
573
647
|
{renderCustomTabBar ? (
|
|
574
648
|
<View
|
|
575
649
|
ref={customTabBarWrapperRef}
|
|
576
|
-
onLayout={(event) =>
|
|
650
|
+
onLayout={(event) =>
|
|
651
|
+
setCustomTabBarHeight(event.nativeEvent.layout.height)
|
|
652
|
+
}
|
|
577
653
|
>
|
|
578
654
|
{renderCustomTabBar()}
|
|
579
655
|
</View>
|
|
@@ -586,17 +662,17 @@ const BottomTabs = <Route extends BaseRoute>({
|
|
|
586
662
|
|
|
587
663
|
const styles = StyleSheet.create({
|
|
588
664
|
fullWidth: {
|
|
589
|
-
width:
|
|
590
|
-
height:
|
|
665
|
+
width: "100%",
|
|
666
|
+
height: "100%",
|
|
591
667
|
flex: 1,
|
|
592
668
|
},
|
|
593
669
|
drawerMenu: {
|
|
594
|
-
position:
|
|
595
|
-
width:
|
|
596
|
-
height:
|
|
670
|
+
position: "absolute",
|
|
671
|
+
width: "78%",
|
|
672
|
+
height: "100%",
|
|
597
673
|
},
|
|
598
674
|
screen: {
|
|
599
|
-
position:
|
|
675
|
+
position: "absolute",
|
|
600
676
|
},
|
|
601
677
|
});
|
|
602
678
|
|
|
@@ -22,6 +22,14 @@ export type OnNativeLayout = Readonly<{
|
|
|
22
22
|
height: Double;
|
|
23
23
|
}>;
|
|
24
24
|
|
|
25
|
+
// Where the system draws the tab bar: 'bottom', or 'leading' / 'trailing' for
|
|
26
|
+
// a vertical bar (the iPhone Duo side strip, iOS 27.1+).
|
|
27
|
+
export type OnTabBarPlacementChange = Readonly<{
|
|
28
|
+
placement: string;
|
|
29
|
+
}>;
|
|
30
|
+
|
|
31
|
+
export type OnSideAccessoryPress = Readonly<{}>;
|
|
32
|
+
|
|
25
33
|
export type BottomTabsItems = ReadonlyArray<{
|
|
26
34
|
key: string;
|
|
27
35
|
title: string;
|
|
@@ -65,6 +73,11 @@ export interface BottomTabsNativeProps extends ViewProps {
|
|
|
65
73
|
ignoreBottomInsets?: boolean;
|
|
66
74
|
androidTabBarStyle?: WithDefault<'default' | 'floating', 'default'>;
|
|
67
75
|
ignoreKeyboardInsets?: WithDefault<boolean, true>;
|
|
76
|
+
onTabBarPlacementChange?: DirectEventHandler<OnTabBarPlacementChange>;
|
|
77
|
+
// A round button drawn above a vertical tab bar; iOS only.
|
|
78
|
+
sideAccessoryIcon?: ImageSource;
|
|
79
|
+
sideAccessoryLabel?: string;
|
|
80
|
+
onSideAccessoryPress?: DirectEventHandler<OnSideAccessoryPress>;
|
|
68
81
|
}
|
|
69
82
|
|
|
70
83
|
export default codegenNativeComponent<BottomTabsNativeProps>('RNBottomTabsView');
|
package/src/index.tsx
CHANGED
package/src/types.ts
CHANGED
|
@@ -7,6 +7,9 @@ export type AppleIcon = { sfSymbol: SFSymbol };
|
|
|
7
7
|
|
|
8
8
|
export type TabRole = 'search';
|
|
9
9
|
|
|
10
|
+
// 'leading' and 'trailing' mean a vertical tab bar on that edge.
|
|
11
|
+
export type TabBarPlacement = 'bottom' | 'leading' | 'trailing';
|
|
12
|
+
|
|
10
13
|
export type BaseRoute = {
|
|
11
14
|
key: string;
|
|
12
15
|
title?: string;
|