@onekeyfe/react-native-bottom-tabs 6.7.0-19 → 6.7.0-20
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/android/src/main/java/com/onekey/bottomtabs/BottomTabsDrawerManager.kt +55 -0
- package/android/src/main/java/com/onekey/bottomtabs/BottomTabsDrawerView.kt +496 -0
- package/android/src/main/java/com/onekey/bottomtabs/BottomTabsPackage.kt +1 -1
- package/android/src/main/java/com/onekey/bottomtabs/BottomTabsViewManager.kt +3 -0
- package/docs/SPEC.md +163 -2
- package/ios/RCTBottomTabsComponentView.mm +37 -0
- package/ios/RCTBottomTabsContainerView.swift +192 -7
- package/ios/RCTBottomTabsDrawerComponentView.h +8 -0
- package/ios/RCTBottomTabsDrawerComponentView.mm +79 -0
- package/ios/RCTBottomTabsDrawerContainerView.swift +327 -0
- package/lib/module/BottomTabs.js +144 -87
- package/lib/module/BottomTabsDrawerNativeComponent.ts +19 -0
- package/lib/module/BottomTabsDrawerNativeComponent.web.js +13 -0
- package/lib/module/BottomTabsNativeComponent.ts +13 -0
- package/lib/typescript/src/BottomTabs.d.ts +36 -10
- package/lib/typescript/src/BottomTabsDrawerNativeComponent.d.ts +15 -0
- package/lib/typescript/src/BottomTabsDrawerNativeComponent.web.d.ts +4 -0
- 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 +3 -2
- package/react-native.config.js +4 -1
- package/src/BottomTabs.tsx +235 -115
- package/src/BottomTabsDrawerNativeComponent.ts +19 -0
- package/src/BottomTabsDrawerNativeComponent.web.tsx +9 -0
- package/src/BottomTabsNativeComponent.ts +13 -0
- package/src/index.tsx +1 -1
- package/src/types.ts +3 -0
package/docs/SPEC.md
CHANGED
|
@@ -1,5 +1,125 @@
|
|
|
1
1
|
# Native Tab View behavioral contract
|
|
2
2
|
|
|
3
|
+
## Native left drawer
|
|
4
|
+
|
|
5
|
+
Status: implemented in module source. Package TypeScript, iOS/Android Fabric
|
|
6
|
+
code generation, iOS Objective-C++ Fabric bridge compilation against RN 0.86.2,
|
|
7
|
+
13 React wrapper checks, twelve Android API 36 native tests and four real-Fabric
|
|
8
|
+
iOS review cases passed (shared-header/body arbitration and bar-height switches).
|
|
9
|
+
The static Home example also passed a focused TypeScript check.
|
|
10
|
+
Detailed iOS runtime evidence is recorded separately; physical-device and exact
|
|
11
|
+
reference-video acceptance remain pending.
|
|
12
|
+
|
|
13
|
+
`renderDrawerContent?: () => React.ReactNode` supplies an optional menu slot.
|
|
14
|
+
`drawerOpen?: boolean` defaults to false and requests its stable open state.
|
|
15
|
+
`onDrawerOpenChange?: (open: boolean) => void` reports a completed user transition;
|
|
16
|
+
controlled consumers MUST mirror it into `drawerOpen`. Prop-driven transitions,
|
|
17
|
+
initial layout and cancellation emit no event. A user transition returning to
|
|
18
|
+
the previous stable state emits only when it overrides an in-flight external
|
|
19
|
+
target, so the consumer can reconcile that target. `drawerSwipeEnabled?: boolean`
|
|
20
|
+
defaults to false; callers MUST enable it only where drawer navigation is appropriate (normally a root
|
|
21
|
+
screen). Existing `hapticFeedbackEnabled` enables one light native haptic after
|
|
22
|
+
a completed user transition changes the stable state. Programmatic updates and
|
|
23
|
+
cancelled gestures do not vibrate.
|
|
24
|
+
|
|
25
|
+
The drawer is physically left on both iOS and Android, including RTL. Its width
|
|
26
|
+
is 78 percent of the host. The foreground is the full tab container, including
|
|
27
|
+
the native tab bar and an optional custom JS bar. It keeps its full width and
|
|
28
|
+
height while moving right, without scaling, scene reparenting or remounting.
|
|
29
|
+
The native host and two slots remain mounted when the drawer is disabled or its
|
|
30
|
+
content changes. Missing content disables the drawer, resets it closed without
|
|
31
|
+
an event, and passes foreground input through. Web omits the drawer host/menu
|
|
32
|
+
and passes the existing tab content through; it implements no drawer gestures.
|
|
33
|
+
The existing tab view's other Web support is unchanged.
|
|
34
|
+
|
|
35
|
+
For normalized progress `p` in [0, 1], foreground translation is `.78 * width * p`,
|
|
36
|
+
its left circular corner radius is `64 * p` points/dp and its neutral gray
|
|
37
|
+
dim surface (RGB 28/28/28) has opacity `.7 * p`.
|
|
38
|
+
The menu translates from `-.12 * drawerWidth` to zero; a black menu shade fades
|
|
39
|
+
from .12 to zero. These are initial reference-video tuning values, not a claim
|
|
40
|
+
of pixel-perfect parity. Closed menus are hidden from touch/accessibility;
|
|
41
|
+
while visible, the foreground is blocked by a native dim surface. Tapping that
|
|
42
|
+
surface closes the drawer. iOS accessibility escape and Android Back close an
|
|
43
|
+
open drawer; the dim surface exposes an accessible close action.
|
|
44
|
+
On Android the dim surface MUST retain full foreground bounds even while hidden
|
|
45
|
+
and after React/Fabric ancestors suppress `requestLayout`. Internal containers
|
|
46
|
+
and both slots MUST be measured and laid out on host layout and slot changes,
|
|
47
|
+
without relying on a later ancestor layout pass. Visible dim input MUST prevent
|
|
48
|
+
native foreground controls from receiving the same touch.
|
|
49
|
+
While closed, that retained dim surface MUST also be excluded from React Native
|
|
50
|
+
touch-target lookup, so JS Pressable taps reach the foreground. Visibility alone
|
|
51
|
+
is insufficient for React Native's native target traversal.
|
|
52
|
+
|
|
53
|
+
The reported native tab-bar height MUST become zero when hidden and restore the
|
|
54
|
+
current height when shown. The context and drawer exclusion use one effective
|
|
55
|
+
height: a rendered custom bar's own measured height takes precedence; otherwise
|
|
56
|
+
`tabBarHidden` forces zero. Native hide measurements MUST NOT overwrite a custom
|
|
57
|
+
bar's height, including across native/custom presentation switches.
|
|
58
|
+
|
|
59
|
+
With opening swipes enabled, a one-finger rightward gesture may start anywhere
|
|
60
|
+
in the foreground content. At the first confirmed direction, the actual native
|
|
61
|
+
touch target and its ancestor chain determine ownership. A horizontal scroll
|
|
62
|
+
view, list or pager which can consume that physical direction takes priority;
|
|
63
|
+
only when every such ancestor is at its boundary may the drawer open. Vertical
|
|
64
|
+
scroll views do not block horizontal drawer intent. Once selected, the owner is
|
|
65
|
+
locked until up/cancel: reaching a boundary mid-gesture does not hand off.
|
|
66
|
+
Disabled pager input does not claim a gesture based on its retained scroll
|
|
67
|
+
range; an independently enabled horizontal child inside that page still can.
|
|
68
|
+
An ancestor pager which explicitly excludes the touched shared header MUST NOT
|
|
69
|
+
claim that header gesture solely because its horizontal offset is nonzero.
|
|
70
|
+
The header's own nested horizontal content retains its normal boundary priority.
|
|
71
|
+
On iOS this uses the repository's existing `excludedHeaderView` Objective-C
|
|
72
|
+
getter on the native smooth pager and the real touch target's ancestry. It does
|
|
73
|
+
not invoke another scroll view's UIKit recognition lifecycle hook, add a Pager
|
|
74
|
+
API, or infer a header exclusion from a pager's selected page.
|
|
75
|
+
The iOS UIPageViewController boundary uses its public data-source query once per
|
|
76
|
+
decision, skipping its internally centered scroll offset. Android observes
|
|
77
|
+
pending direction even when a nested pager initially disallows interception,
|
|
78
|
+
but stops overriding that request after the child wins. Sliders and editing controls,
|
|
79
|
+
web/graphics surfaces, system back gestures and starts in the bottom tab-bar
|
|
80
|
+
footprint take priority. When open, closing gestures
|
|
81
|
+
remain enabled even if opening swipes are disabled. Only the exposed
|
|
82
|
+
foreground accepts a closing pan; menu scrolling remains the consumer's own.
|
|
83
|
+
Vertical gestures fail before taking ownership. Active touches interrupted by
|
|
84
|
+
a second finger, system cancellation, disabling, detach or a newer prop update
|
|
85
|
+
cannot later emit an event. Cancellation returns to the previous stable state,
|
|
86
|
+
or resumes an external target whose programmatic animation the gesture interrupted.
|
|
87
|
+
No gesture progress crosses the JS bridge.
|
|
88
|
+
|
|
89
|
+
Both platforms settle on their UI thread with the same critically damped curve:
|
|
90
|
+
`p(t) = target + (a + b*t) * exp(-22*t)`, where `a = p0-target`,
|
|
91
|
+
`b = v0 + 22*a`, and normalized velocity `v0` is clamped to [-8, 8] per second.
|
|
92
|
+
Presentation is clamped to [0, 1], and the transition ends at or before 500 ms.
|
|
93
|
+
Release targets open when `p + v0*.12 >= .5`. A newer animation or touch starts
|
|
94
|
+
from the current displayed progress and cancels the previous completion.
|
|
95
|
+
Resize snaps a pending settlement to its target, emitting its user event once
|
|
96
|
+
if appropriate; an active drag follows the same cancellation rule. Detach cancels
|
|
97
|
+
animations and callbacks; reattachment reapplies the latest external target.
|
|
98
|
+
The host retains at most one display-link/animator, one touch
|
|
99
|
+
sequence, two React child slots, and native dim surfaces. It owns no snapshots,
|
|
100
|
+
workers, persistence, JS animation objects, RNGH or Reanimated dependency.
|
|
101
|
+
|
|
102
|
+
Implementation boundaries: `src/BottomTabs.tsx` owns slots and settled callbacks;
|
|
103
|
+
`src/BottomTabsDrawerNativeComponent.ts` defines the Fabric boundary;
|
|
104
|
+
`ios/RCTBottomTabsDrawerContainerView.swift` and
|
|
105
|
+
`android/src/main/java/com/onekey/bottomtabs/BottomTabsDrawerView.kt` own native
|
|
106
|
+
input, layout, animation and cleanup. The iOS Fabric component and Android manager only forward children,
|
|
107
|
+
props and the settled event. Required acceptance includes both bar styles,
|
|
108
|
+
custom bars, content/vertical gestures, dim close, rapid reversal, cancellation,
|
|
109
|
+
disable/remove-content, resize, detach, accessibility, RTL and bottom-bar
|
|
110
|
+
gesture ownership, including a real pager, nested horizontal list and vertical
|
|
111
|
+
scrolling in the complete Fabric example. Source checks and emulator checks do not establish physical
|
|
112
|
+
device smoothness or exact reference-video fidelity.
|
|
113
|
+
The Android native suite also exercises generated drawer prop forwarding in RTL,
|
|
114
|
+
physical-left placement, and closing pans while opening swipes are disabled.
|
|
115
|
+
|
|
116
|
+
The standalone `example/react-native/pages/BottomTabsHomeDrawerExample.tsx`
|
|
117
|
+
reproduces Home’s wallet toolbar, balance/actions/banner shared header and
|
|
118
|
+
Spot/Perps/Earn/NFT/History hierarchy using static data, native CollapsiblePager
|
|
119
|
+
and standard React Native scroll views. It is an equivalent interaction fixture;
|
|
120
|
+
the application’s existing JS collapsible-header implementation and business
|
|
121
|
+
NativeList pages are not migrated or exercised by this fixture.
|
|
122
|
+
|
|
3
123
|
## Tab presses and double press
|
|
4
124
|
|
|
5
125
|
Status: implemented in module source. Runtime verification status is recorded
|
|
@@ -142,12 +262,46 @@ boundary; `BottomTabsView.kt` owns scene geometry, appearance, and disposal;
|
|
|
142
262
|
and runner instructions are in `native-tests/android`. The example's settings
|
|
143
263
|
page exposes the switch and its scrolling scene reserves the reported bar height.
|
|
144
264
|
|
|
265
|
+
## iOS vertical tab bar placement and side accessory
|
|
266
|
+
|
|
267
|
+
Status: Implemented. Runtime results are in Conformance.
|
|
268
|
+
|
|
269
|
+
On iOS 27.1+, devices such as iPhone Duo can place the `UITabBarController`
|
|
270
|
+
bar vertically in a side strip next to the status bar. The view reports that
|
|
271
|
+
placement and can draw one round accessory button above the vertical bar.
|
|
272
|
+
|
|
273
|
+
- **Placement:** `onTabBarPlacementChange({ placement })` fires with `bottom`,
|
|
274
|
+
`leading`, or `trailing`, read from `traitCollection.verticalBarEdge` of the
|
|
275
|
+
tab bar controller. It fires once after the bar is created and again on every
|
|
276
|
+
change (rotation, folding, window resizing). Builds without the iOS 27.1 SDK
|
|
277
|
+
and earlier systems always report `bottom`. Android never fires it.
|
|
278
|
+
- **Side accessory:** `sideAccessoryIcon` (an image source, loaded with the
|
|
279
|
+
React image loader and drawn as a template tinted with `inactiveTintColor`),
|
|
280
|
+
`sideAccessoryLabel` (accessibility label), and `onSideAccessoryPress`. The
|
|
281
|
+
button is 48 pt, a Liquid Glass capsule on iOS 26+, centered horizontally on
|
|
282
|
+
the vertical bar's item platter and 12 pt above it.
|
|
283
|
+
- Android MUST accept `sideAccessoryIcon` and `sideAccessoryLabel`, including
|
|
284
|
+
null resets, as no-ops through its generated ViewManager interface. It MUST
|
|
285
|
+
NOT create a side accessory or emit placement/accessory events. Shared Fabric
|
|
286
|
+
props still require Android setters even when the feature is iOS-only.
|
|
287
|
+
- The button shows only when all hold: the placement is `leading` or
|
|
288
|
+
`trailing`, the tab bar is not hidden, the icon has loaded, and the platter
|
|
289
|
+
is found. Otherwise it is hidden; a bottom bar never shows it.
|
|
290
|
+
- UIKit does not expose the platter frame. The view finds the platter by its
|
|
291
|
+
view class name below the tab bar controller (skipping tab content views),
|
|
292
|
+
reads its frame, and calls no private API. If a later system renames or
|
|
293
|
+
restructures it, the accessory hides; no other behavior depends on it.
|
|
294
|
+
- Layout re-runs, coalesced on the next main-queue turn, after container
|
|
295
|
+
layout, tab rebuilds, tab bar hiding, icon loads, and changes to the traits
|
|
296
|
+
affecting `verticalBarEdge`.
|
|
297
|
+
|
|
145
298
|
## Threading and ownership
|
|
146
299
|
|
|
147
300
|
All gesture handling, animations, and timers run on the platform UI thread in
|
|
148
301
|
the main-runtime native view. The view owns no background-runtime or shared JS
|
|
149
|
-
objects.
|
|
150
|
-
|
|
302
|
+
objects. The tab bar retains the current touch sequence, the last pressed tab
|
|
303
|
+
and timestamp, and at most two running animators. The optional drawer's additional
|
|
304
|
+
single animation and touch ownership are bounded in the Native left drawer section.
|
|
151
305
|
|
|
152
306
|
## Conformance and acceptance
|
|
153
307
|
|
|
@@ -166,3 +320,10 @@ non-selected tab lifts the lens there; drag crossing tab centers does not change
|
|
|
166
320
|
pages; release selects the nearest tab once; cancel restores; hidden bar, dark
|
|
167
321
|
mode, and 3-button navigation insets lay out correctly. Source compilation is
|
|
168
322
|
recorded separately from runtime acceptance.
|
|
323
|
+
|
|
324
|
+
Vertical tab bar: `RCTBottomTabsContainerView.swift` (`currentTabBarPlacement`,
|
|
325
|
+
`updateTabBarPlacementAndSideAccessory`, `verticalTabBarPlatterFrame`).
|
|
326
|
+
Required iOS runtime cases on iPhone Duo: the outer display and landscape inner
|
|
327
|
+
display report `trailing` and show the accessory above the platter; the
|
|
328
|
+
portrait inner display reports `bottom` and hides it; hiding the tab bar hides
|
|
329
|
+
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,16 @@ 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
|
+
// Increments per icon load; a completion applies only to the latest one.
|
|
79
|
+
private var sideAccessoryIconRequest = 0
|
|
80
|
+
private var isSideAccessoryLayoutScheduled = false
|
|
81
|
+
private static let sideAccessorySize: CGFloat = 48
|
|
82
|
+
private static let sideAccessorySpacing: CGFloat = 12
|
|
73
83
|
|
|
74
84
|
// MARK: - Props (set by Fabric ComponentView via KVC)
|
|
75
85
|
|
|
@@ -128,7 +138,11 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
128
138
|
|
|
129
139
|
@objc var inactiveTintColor: UIColor? {
|
|
130
140
|
// Rebuild so the baked unselected-image tint follows theme changes.
|
|
131
|
-
didSet {
|
|
141
|
+
didSet {
|
|
142
|
+
updateTabBarAppearance()
|
|
143
|
+
rebuildViewControllers()
|
|
144
|
+
sideAccessoryButton?.tintColor = inactiveTintColor
|
|
145
|
+
}
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
@objc var rippleColor: UIColor? // Android only
|
|
@@ -147,12 +161,22 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
147
161
|
didSet { updateTabBarAppearance() }
|
|
148
162
|
}
|
|
149
163
|
|
|
164
|
+
@objc var sideAccessoryIcon: NSDictionary? {
|
|
165
|
+
didSet { loadSideAccessoryIcon() }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@objc var sideAccessoryLabel: String? {
|
|
169
|
+
didSet { sideAccessoryButton?.accessibilityLabel = sideAccessoryLabel }
|
|
170
|
+
}
|
|
171
|
+
|
|
150
172
|
// MARK: - Events (RCTDirectEventBlock)
|
|
151
173
|
|
|
152
174
|
@objc var onPageSelected: RCTDirectEventBlock?
|
|
153
175
|
@objc var onTabDoublePress: RCTDirectEventBlock?
|
|
154
176
|
@objc var onTabBarMeasured: RCTDirectEventBlock?
|
|
155
177
|
@objc var onNativeLayout: RCTDirectEventBlock?
|
|
178
|
+
@objc var onTabBarPlacementChange: RCTDirectEventBlock?
|
|
179
|
+
@objc var onSideAccessoryPress: RCTDirectEventBlock?
|
|
156
180
|
|
|
157
181
|
// MARK: - Init
|
|
158
182
|
|
|
@@ -188,6 +212,165 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
188
212
|
// TODO: Re-enable when UITabBar.bottomAccessoryView is available in the SDK
|
|
189
213
|
}
|
|
190
214
|
|
|
215
|
+
// MARK: - Vertical tab bar (iOS 27.1+)
|
|
216
|
+
|
|
217
|
+
/// Where the system draws the tab bar: "bottom", or "leading"/"trailing" for
|
|
218
|
+
/// the vertical bar some devices (iPhone Duo) place in a side strip.
|
|
219
|
+
private func currentTabBarPlacement() -> String {
|
|
220
|
+
#if compiler(>=6.4)
|
|
221
|
+
if #available(iOS 27.1, *), let tbc = tabBarController {
|
|
222
|
+
switch tbc.traitCollection.verticalBarEdge {
|
|
223
|
+
case .leading: return "leading"
|
|
224
|
+
case .trailing: return "trailing"
|
|
225
|
+
default: break
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
#endif
|
|
229
|
+
return "bottom"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private func registerVerticalBarTraitChanges(_ tbc: UITabBarController) {
|
|
233
|
+
#if compiler(>=6.4)
|
|
234
|
+
if #available(iOS 27.1, *) {
|
|
235
|
+
tbc.registerForTraitChanges(
|
|
236
|
+
UITraitCollection.systemTraitsAffectingVerticalBarEdge
|
|
237
|
+
) { [weak self] (_: UITabBarController, _: UITraitCollection) in
|
|
238
|
+
self?.scheduleSideAccessoryLayout()
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
#endif
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// Coalesces requests and runs after UIKit has laid out the bar, whose
|
|
245
|
+
/// floating platter is positioned in UIKit's own layout pass.
|
|
246
|
+
private func scheduleSideAccessoryLayout() {
|
|
247
|
+
guard !isSideAccessoryLayoutScheduled else { return }
|
|
248
|
+
isSideAccessoryLayoutScheduled = true
|
|
249
|
+
DispatchQueue.main.async { [weak self] in
|
|
250
|
+
guard let self else { return }
|
|
251
|
+
self.isSideAccessoryLayoutScheduled = false
|
|
252
|
+
self.updateTabBarPlacementAndSideAccessory()
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private func updateTabBarPlacementAndSideAccessory() {
|
|
257
|
+
guard let tbc = tabBarController else { return }
|
|
258
|
+
let placement = currentTabBarPlacement()
|
|
259
|
+
if placement != reportedTabBarPlacement {
|
|
260
|
+
reportedTabBarPlacement = placement
|
|
261
|
+
onTabBarPlacementChange?(["placement": placement])
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
guard placement != "bottom",
|
|
265
|
+
!tabBarHidden,
|
|
266
|
+
let icon = loadedSideAccessoryIcon,
|
|
267
|
+
let platterFrame = verticalTabBarPlatterFrame(in: tbc) else {
|
|
268
|
+
sideAccessoryButton?.isHidden = true
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let button = ensureSideAccessoryButton(in: tbc.view)
|
|
273
|
+
button.configuration?.image = icon
|
|
274
|
+
let size = RCTBottomTabsContainerView.sideAccessorySize
|
|
275
|
+
button.frame = CGRect(
|
|
276
|
+
x: platterFrame.midX - size / 2,
|
|
277
|
+
y: platterFrame.minY - RCTBottomTabsContainerView.sideAccessorySpacing - size,
|
|
278
|
+
width: size,
|
|
279
|
+
height: size
|
|
280
|
+
)
|
|
281
|
+
button.isHidden = false
|
|
282
|
+
tbc.view.bringSubviewToFront(button)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// UIKit draws the vertical bar items in a floating platter it does not
|
|
286
|
+
/// expose. Read that view's frame (no private API is called) and return nil
|
|
287
|
+
/// when it cannot be found, so the accessory hides instead of misplacing.
|
|
288
|
+
private func verticalTabBarPlatterFrame(in tbc: UITabBarController) -> CGRect? {
|
|
289
|
+
let root = tbc.view!
|
|
290
|
+
// Tab content can hold large React trees; the bar lives outside them.
|
|
291
|
+
let contentViews = Set((tbc.viewControllers ?? []).compactMap { vc in
|
|
292
|
+
vc.isViewLoaded ? ObjectIdentifier(vc.view) : nil
|
|
293
|
+
})
|
|
294
|
+
var queue: [UIView] = root.subviews
|
|
295
|
+
var index = 0
|
|
296
|
+
while index < queue.count {
|
|
297
|
+
let view = queue[index]
|
|
298
|
+
index += 1
|
|
299
|
+
if view === sideAccessoryButton || contentViews.contains(ObjectIdentifier(view)) {
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
if NSStringFromClass(type(of: view)).contains("TabBarItemPlatterView") {
|
|
303
|
+
guard !view.isHidden, view.window != nil else { return nil }
|
|
304
|
+
let frame = view.convert(view.bounds, to: root)
|
|
305
|
+
return frame.isEmpty ? nil : frame
|
|
306
|
+
}
|
|
307
|
+
queue.append(contentsOf: view.subviews)
|
|
308
|
+
}
|
|
309
|
+
return nil
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private func ensureSideAccessoryButton(in parent: UIView) -> UIButton {
|
|
313
|
+
if let button = sideAccessoryButton {
|
|
314
|
+
if button.superview !== parent { parent.addSubview(button) }
|
|
315
|
+
return button
|
|
316
|
+
}
|
|
317
|
+
var configuration: UIButton.Configuration
|
|
318
|
+
#if compiler(>=6.2)
|
|
319
|
+
if #available(iOS 26.0, *) {
|
|
320
|
+
configuration = .glass()
|
|
321
|
+
} else {
|
|
322
|
+
configuration = .gray()
|
|
323
|
+
}
|
|
324
|
+
#else
|
|
325
|
+
configuration = .gray()
|
|
326
|
+
#endif
|
|
327
|
+
configuration.cornerStyle = .capsule
|
|
328
|
+
configuration.contentInsets = .zero
|
|
329
|
+
let button = UIButton(configuration: configuration)
|
|
330
|
+
button.tintColor = inactiveTintColor
|
|
331
|
+
button.accessibilityLabel = sideAccessoryLabel
|
|
332
|
+
button.addAction(UIAction { [weak self] _ in
|
|
333
|
+
self?.onSideAccessoryPress?([:])
|
|
334
|
+
}, for: .primaryActionTriggered)
|
|
335
|
+
parent.addSubview(button)
|
|
336
|
+
sideAccessoryButton = button
|
|
337
|
+
return button
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private func loadSideAccessoryIcon() {
|
|
341
|
+
sideAccessoryIconRequest += 1
|
|
342
|
+
let request = sideAccessoryIconRequest
|
|
343
|
+
// Drop the previous icon so a slower or failed load never leaves it shown
|
|
344
|
+
// for the new source.
|
|
345
|
+
if loadedSideAccessoryIcon != nil {
|
|
346
|
+
loadedSideAccessoryIcon = nil
|
|
347
|
+
scheduleSideAccessoryLayout()
|
|
348
|
+
}
|
|
349
|
+
let source = sideAccessoryIcon.map { IconSource(dict: $0) }
|
|
350
|
+
guard let source, let url = URL(string: source.uri) else { return }
|
|
351
|
+
// The image loader arrives with the first state update; setImageLoader
|
|
352
|
+
// calls this again.
|
|
353
|
+
guard let imageLoader else { return }
|
|
354
|
+
imageLoader.loadImage(
|
|
355
|
+
with: URLRequest(url: url),
|
|
356
|
+
size: CGSize(width: source.width, height: source.height),
|
|
357
|
+
scale: CGFloat(source.scale),
|
|
358
|
+
clipped: true,
|
|
359
|
+
resizeMode: RCTResizeMode.contain,
|
|
360
|
+
progressBlock: { _, _ in },
|
|
361
|
+
partialLoad: { _ in },
|
|
362
|
+
completionBlock: { [weak self] error, image in
|
|
363
|
+
guard error == nil, let image else { return }
|
|
364
|
+
DispatchQueue.main.async {
|
|
365
|
+
guard let self, request == self.sideAccessoryIconRequest else { return }
|
|
366
|
+
self.loadedSideAccessoryIcon = image
|
|
367
|
+
.resizeImageTo(size: self.iconSize)?
|
|
368
|
+
.withRenderingMode(.alwaysTemplate)
|
|
369
|
+
self.scheduleSideAccessoryLayout()
|
|
370
|
+
}
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
|
|
191
374
|
// MARK: - Lifecycle
|
|
192
375
|
|
|
193
376
|
private func handleLayout() {
|
|
@@ -197,6 +380,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
197
380
|
setupTabBarControllerIfNeeded()
|
|
198
381
|
tabBarController?.view.frame = bounds
|
|
199
382
|
onNativeLayout?(["width": Double(bounds.width), "height": Double(bounds.height)])
|
|
383
|
+
scheduleSideAccessoryLayout()
|
|
200
384
|
}
|
|
201
385
|
|
|
202
386
|
private func setupTabBarControllerIfNeeded() {
|
|
@@ -215,6 +399,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
215
399
|
tbc.didMove(toParent: parentVC)
|
|
216
400
|
|
|
217
401
|
self.tabBarController = tbc
|
|
402
|
+
registerVerticalBarTraitChanges(tbc)
|
|
218
403
|
|
|
219
404
|
rebuildViewControllers()
|
|
220
405
|
updateTabBarAppearance()
|
|
@@ -225,9 +410,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
225
410
|
|
|
226
411
|
DispatchQueue.main.async { [weak self] in
|
|
227
412
|
guard let self, let tbc = self.tabBarController else { return }
|
|
228
|
-
|
|
229
|
-
self.onTabBarMeasured?(["height": Double(tbc.tabBar.frame.size.height)])
|
|
230
|
-
}
|
|
413
|
+
self.onTabBarMeasured?(["height": self.tabBarHidden ? 0 : Double(tbc.tabBar.frame.size.height)])
|
|
231
414
|
}
|
|
232
415
|
}
|
|
233
416
|
|
|
@@ -358,6 +541,8 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
358
541
|
}
|
|
359
542
|
|
|
360
543
|
updateSelectedTab()
|
|
544
|
+
// The platter height follows the number of visible tabs.
|
|
545
|
+
scheduleSideAccessoryLayout()
|
|
361
546
|
}
|
|
362
547
|
|
|
363
548
|
// MARK: - Selection
|
|
@@ -499,9 +684,8 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
499
684
|
#endif
|
|
500
685
|
tbc.tabBar.isUserInteractionEnabled = !tabBarHidden
|
|
501
686
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
687
|
+
onTabBarMeasured?(["height": tabBarHidden ? 0 : Double(tbc.tabBar.frame.size.height)])
|
|
688
|
+
scheduleSideAccessoryLayout()
|
|
505
689
|
}
|
|
506
690
|
|
|
507
691
|
private func updateSidebarAdaptable() {
|
|
@@ -573,6 +757,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
573
757
|
self.imageLoader = loader
|
|
574
758
|
loadIconsFromSources()
|
|
575
759
|
loadSelectedIconsFromSources()
|
|
760
|
+
loadSideAccessoryIcon()
|
|
576
761
|
}
|
|
577
762
|
|
|
578
763
|
private func loadIconsFromSources() {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#import "RCTBottomTabsDrawerComponentView.h"
|
|
2
|
+
#import <react/renderer/components/RNBottomTabs/ComponentDescriptors.h>
|
|
3
|
+
#import <react/renderer/components/RNBottomTabs/EventEmitters.h>
|
|
4
|
+
#import <react/renderer/components/RNBottomTabs/Props.h>
|
|
5
|
+
#import <React/RCTFabricComponentsPlugins.h>
|
|
6
|
+
|
|
7
|
+
// Swift keeps the host internal to this module, so its framework compatibility
|
|
8
|
+
// header does not export the class. Its explicit @objc name and selectors form
|
|
9
|
+
// this private boundary, matching the existing tab container's runtime lookup.
|
|
10
|
+
@protocol RNBottomTabsDrawerContainer <NSObject>
|
|
11
|
+
@property(nonatomic) BOOL enabled;
|
|
12
|
+
@property(nonatomic) BOOL open;
|
|
13
|
+
@property(nonatomic) BOOL swipeEnabled;
|
|
14
|
+
@property(nonatomic) BOOL hapticFeedbackEnabled;
|
|
15
|
+
@property(nonatomic) double bottomBarHeight;
|
|
16
|
+
@property(nonatomic, copy) void (^onDrawerOpenChange)(BOOL open);
|
|
17
|
+
- (void)insertChild:(UIView *)child atIndex:(NSInteger)index;
|
|
18
|
+
- (void)removeChildAtIndex:(NSInteger)index;
|
|
19
|
+
@end
|
|
20
|
+
|
|
21
|
+
using namespace facebook::react;
|
|
22
|
+
|
|
23
|
+
@implementation RCTBottomTabsDrawerComponentView {
|
|
24
|
+
UIView<RNBottomTabsDrawerContainer> *_drawer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
+ (ComponentDescriptorProvider)componentDescriptorProvider
|
|
28
|
+
{
|
|
29
|
+
return concreteComponentDescriptorProvider<RNBottomTabsDrawerComponentDescriptor>();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
+ (BOOL)shouldBeRecycled { return NO; }
|
|
33
|
+
|
|
34
|
+
- (instancetype)initWithFrame:(CGRect)frame
|
|
35
|
+
{
|
|
36
|
+
if (self = [super initWithFrame:frame]) {
|
|
37
|
+
_props = std::make_shared<const RNBottomTabsDrawerProps>();
|
|
38
|
+
Class containerClass = NSClassFromString(@"RCTBottomTabsDrawerContainerView");
|
|
39
|
+
_drawer = (UIView<RNBottomTabsDrawerContainer> *)[[containerClass alloc] initWithFrame:frame];
|
|
40
|
+
self.contentView = _drawer;
|
|
41
|
+
__weak RCTBottomTabsDrawerComponentView *weakSelf = self;
|
|
42
|
+
_drawer.onDrawerOpenChange = ^(BOOL open) {
|
|
43
|
+
auto strongSelf = weakSelf;
|
|
44
|
+
if (!strongSelf) return;
|
|
45
|
+
auto emitter = std::static_pointer_cast<const RNBottomTabsDrawerEventEmitter>(strongSelf->_eventEmitter);
|
|
46
|
+
if (emitter) emitter->onDrawerOpenChange({.open = (bool)open});
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return self;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)child index:(NSInteger)index
|
|
53
|
+
{
|
|
54
|
+
[_drawer insertChild:child atIndex:index];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)child index:(NSInteger)index
|
|
58
|
+
{
|
|
59
|
+
[_drawer removeChildAtIndex:index];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
|
|
63
|
+
{
|
|
64
|
+
const auto &next = *std::static_pointer_cast<const RNBottomTabsDrawerProps>(props);
|
|
65
|
+
const auto &previous = *std::static_pointer_cast<const RNBottomTabsDrawerProps>(_props);
|
|
66
|
+
if (next.enabled != previous.enabled) _drawer.enabled = next.enabled;
|
|
67
|
+
if (next.open != previous.open) _drawer.open = next.open;
|
|
68
|
+
if (next.swipeEnabled != previous.swipeEnabled) _drawer.swipeEnabled = next.swipeEnabled;
|
|
69
|
+
if (next.hapticFeedbackEnabled != previous.hapticFeedbackEnabled)
|
|
70
|
+
_drawer.hapticFeedbackEnabled = next.hapticFeedbackEnabled;
|
|
71
|
+
if (next.bottomBarHeight != previous.bottomBarHeight) _drawer.bottomBarHeight = next.bottomBarHeight;
|
|
72
|
+
[super updateProps:props oldProps:oldProps];
|
|
73
|
+
}
|
|
74
|
+
@end
|
|
75
|
+
|
|
76
|
+
Class<RCTComponentViewProtocol> RNBottomTabsDrawerCls(void)
|
|
77
|
+
{
|
|
78
|
+
return RCTBottomTabsDrawerComponentView.class;
|
|
79
|
+
}
|