@onekeyfe/react-native-bottom-tabs 6.7.0-alpha.382 → 6.7.0-alpha.384
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/docs/SPEC.md +121 -2
- package/ios/RCTBottomTabsContainerView.swift +2 -6
- package/ios/RCTBottomTabsDrawerComponentView.h +8 -0
- package/ios/RCTBottomTabsDrawerComponentView.mm +79 -0
- package/ios/RCTBottomTabsDrawerContainerView.swift +327 -0
- package/lib/module/BottomTabs.js +95 -65
- package/lib/module/BottomTabsDrawerNativeComponent.ts +19 -0
- package/lib/module/BottomTabsDrawerNativeComponent.web.js +13 -0
- package/lib/typescript/src/BottomTabs.d.ts +9 -1
- package/lib/typescript/src/BottomTabsDrawerNativeComponent.d.ts +15 -0
- package/lib/typescript/src/BottomTabsDrawerNativeComponent.web.d.ts +4 -0
- package/package.json +3 -2
- package/react-native.config.js +4 -1
- package/src/BottomTabs.tsx +124 -80
- package/src/BottomTabsDrawerNativeComponent.ts +19 -0
- package/src/BottomTabsDrawerNativeComponent.web.tsx +9 -0
package/docs/SPEC.md
CHANGED
|
@@ -1,5 +1,123 @@
|
|
|
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, eleven 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
|
+
|
|
114
|
+
The standalone `example/react-native/pages/BottomTabsHomeDrawerExample.tsx`
|
|
115
|
+
reproduces Home’s wallet toolbar, balance/actions/banner shared header and
|
|
116
|
+
Spot/Perps/Earn/NFT/History hierarchy using static data, native CollapsiblePager
|
|
117
|
+
and standard React Native scroll views. It is an equivalent interaction fixture;
|
|
118
|
+
the application’s existing JS collapsible-header implementation and business
|
|
119
|
+
NativeList pages are not migrated or exercised by this fixture.
|
|
120
|
+
|
|
3
121
|
## Tab presses and double press
|
|
4
122
|
|
|
5
123
|
Status: implemented in module source. Runtime verification status is recorded
|
|
@@ -146,8 +264,9 @@ page exposes the switch and its scrolling scene reserves the reported bar height
|
|
|
146
264
|
|
|
147
265
|
All gesture handling, animations, and timers run on the platform UI thread in
|
|
148
266
|
the main-runtime native view. The view owns no background-runtime or shared JS
|
|
149
|
-
objects.
|
|
150
|
-
|
|
267
|
+
objects. The tab bar retains the current touch sequence, the last pressed tab
|
|
268
|
+
and timestamp, and at most two running animators. The optional drawer's additional
|
|
269
|
+
single animation and touch ownership are bounded in the Native left drawer section.
|
|
151
270
|
|
|
152
271
|
## Conformance and acceptance
|
|
153
272
|
|
|
@@ -225,9 +225,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
225
225
|
|
|
226
226
|
DispatchQueue.main.async { [weak self] in
|
|
227
227
|
guard let self, let tbc = self.tabBarController else { return }
|
|
228
|
-
|
|
229
|
-
self.onTabBarMeasured?(["height": Double(tbc.tabBar.frame.size.height)])
|
|
230
|
-
}
|
|
228
|
+
self.onTabBarMeasured?(["height": self.tabBarHidden ? 0 : Double(tbc.tabBar.frame.size.height)])
|
|
231
229
|
}
|
|
232
230
|
}
|
|
233
231
|
|
|
@@ -499,9 +497,7 @@ class RCTBottomTabsContainerView: UIView {
|
|
|
499
497
|
#endif
|
|
500
498
|
tbc.tabBar.isUserInteractionEnabled = !tabBarHidden
|
|
501
499
|
|
|
502
|
-
|
|
503
|
-
onTabBarMeasured?(["height": Double(tbc.tabBar.frame.size.height)])
|
|
504
|
-
}
|
|
500
|
+
onTabBarMeasured?(["height": tabBarHidden ? 0 : Double(tbc.tabBar.frame.size.height)])
|
|
505
501
|
}
|
|
506
502
|
|
|
507
503
|
private func updateSidebarAdaptable() {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// Internal native host. React owns two fixed slots: menu, then foreground.
|
|
4
|
+
@objc(RCTBottomTabsDrawerContainerView)
|
|
5
|
+
final class RCTBottomTabsDrawerContainerView: UIView, UIGestureRecognizerDelegate {
|
|
6
|
+
@objc var enabled = false {
|
|
7
|
+
didSet {
|
|
8
|
+
guard enabled != oldValue else { return }
|
|
9
|
+
interruptGesture()
|
|
10
|
+
stopAnimation()
|
|
11
|
+
stableOpen = false
|
|
12
|
+
setProgress(0)
|
|
13
|
+
if enabled && open { settle(to: true, velocity: 0, user: false) }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
@objc var open = false {
|
|
17
|
+
didSet {
|
|
18
|
+
guard open != oldValue else { return }
|
|
19
|
+
interruptGesture()
|
|
20
|
+
settle(to: enabled && open, velocity: 0, user: false)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
@objc var swipeEnabled = false
|
|
24
|
+
@objc var hapticFeedbackEnabled = false
|
|
25
|
+
@objc var bottomBarHeight: Double = 0
|
|
26
|
+
@objc var onDrawerOpenChange: ((Bool) -> Void)?
|
|
27
|
+
|
|
28
|
+
private let menu = UIView()
|
|
29
|
+
private let menuShade = UIView()
|
|
30
|
+
private let foreground = UIView()
|
|
31
|
+
private let foregroundContent = UIView()
|
|
32
|
+
private let dim = UIButton(type: .custom)
|
|
33
|
+
private var slots: [UIView] = []
|
|
34
|
+
private var pan: UIPanGestureRecognizer!
|
|
35
|
+
private var progress: CGFloat = 0
|
|
36
|
+
private var stableOpen = false
|
|
37
|
+
private var gestureStartProgress: CGFloat = 0
|
|
38
|
+
private var gestureReturnOpen = false
|
|
39
|
+
private weak var touchTarget: UIView?
|
|
40
|
+
private var suppressCancellation = false
|
|
41
|
+
private var displayLink: CADisplayLink?
|
|
42
|
+
private var animationStart: CFTimeInterval = 0
|
|
43
|
+
private var animationFrom: CGFloat = 0
|
|
44
|
+
private var animationVelocity: CGFloat = 0
|
|
45
|
+
private var animationTarget = false
|
|
46
|
+
private var animationUser = false
|
|
47
|
+
private var previousSize = CGSize.zero
|
|
48
|
+
|
|
49
|
+
override init(frame: CGRect) {
|
|
50
|
+
super.init(frame: frame)
|
|
51
|
+
clipsToBounds = true
|
|
52
|
+
addSubview(menu)
|
|
53
|
+
menuShade.backgroundColor = .black
|
|
54
|
+
menuShade.isUserInteractionEnabled = false
|
|
55
|
+
menu.addSubview(menuShade)
|
|
56
|
+
addSubview(foreground)
|
|
57
|
+
foreground.addSubview(foregroundContent)
|
|
58
|
+
dim.backgroundColor = UIColor(white: 28.0 / 255.0, alpha: 1)
|
|
59
|
+
dim.accessibilityLabel = NSLocalizedString("Close menu", comment: "Close the tab drawer")
|
|
60
|
+
dim.addTarget(self, action: #selector(closeFromUser), for: .touchUpInside)
|
|
61
|
+
foreground.addSubview(dim)
|
|
62
|
+
foreground.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
|
|
63
|
+
foreground.layer.cornerCurve = .circular
|
|
64
|
+
foreground.clipsToBounds = true
|
|
65
|
+
pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
|
66
|
+
pan.maximumNumberOfTouches = 1
|
|
67
|
+
pan.delegate = self
|
|
68
|
+
addGestureRecognizer(pan)
|
|
69
|
+
setProgress(0)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
73
|
+
|
|
74
|
+
@objc func insertChild(_ child: UIView, atIndex index: Int) {
|
|
75
|
+
guard index >= 0 && index <= slots.count && slots.count < 2 else { return }
|
|
76
|
+
slots.insert(child, at: index)
|
|
77
|
+
updateSlots()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@objc func removeChild(atIndex index: Int) {
|
|
81
|
+
guard slots.indices.contains(index) else { return }
|
|
82
|
+
slots.remove(at: index).removeFromSuperview()
|
|
83
|
+
updateSlots()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func updateSlots() {
|
|
87
|
+
for (index, child) in slots.enumerated() {
|
|
88
|
+
(index == 0 ? menu : foregroundContent).insertSubview(child, at: 0)
|
|
89
|
+
}
|
|
90
|
+
menu.bringSubviewToFront(menuShade)
|
|
91
|
+
setNeedsLayout()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override func layoutSubviews() {
|
|
95
|
+
super.layoutSubviews()
|
|
96
|
+
if previousSize != bounds.size {
|
|
97
|
+
let dragging = pan.state == .began || pan.state == .changed
|
|
98
|
+
let target = displayLink != nil ? animationTarget : (dragging ? gestureReturnOpen : stableOpen)
|
|
99
|
+
let user = displayLink != nil && animationUser && previousSize != .zero
|
|
100
|
+
interruptGesture()
|
|
101
|
+
stopAnimation()
|
|
102
|
+
finish(target: enabled && (previousSize == .zero ? open : target), user: user)
|
|
103
|
+
previousSize = bounds.size
|
|
104
|
+
}
|
|
105
|
+
menu.transform = .identity
|
|
106
|
+
foreground.transform = .identity
|
|
107
|
+
menu.frame = CGRect(x: 0, y: 0, width: bounds.width * 0.78, height: bounds.height)
|
|
108
|
+
foreground.frame = bounds
|
|
109
|
+
foregroundContent.frame = foreground.bounds
|
|
110
|
+
dim.frame = foreground.bounds
|
|
111
|
+
menuShade.frame = menu.bounds
|
|
112
|
+
if slots.count > 0 { slots[0].frame = menu.bounds }
|
|
113
|
+
if slots.count > 1 { slots[1].frame = foreground.bounds }
|
|
114
|
+
setProgress(progress)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
override func didMoveToWindow() {
|
|
118
|
+
super.didMoveToWindow()
|
|
119
|
+
if window == nil {
|
|
120
|
+
interruptGesture()
|
|
121
|
+
stopAnimation()
|
|
122
|
+
setProgress(stableOpen && enabled ? 1 : 0)
|
|
123
|
+
} else if enabled && stableOpen != open {
|
|
124
|
+
settle(to: open, velocity: 0, user: false)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
override func accessibilityPerformEscape() -> Bool {
|
|
129
|
+
guard enabled && progress > 0 else { return false }
|
|
130
|
+
closeFromUser()
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
135
|
+
guard enabled else { return false }
|
|
136
|
+
let start = touch.location(in: self)
|
|
137
|
+
guard start.y < bounds.height - CGFloat(max(0, bottomBarHeight)) else { return false }
|
|
138
|
+
touchTarget = touch.view
|
|
139
|
+
if progress > 0 { return start.x >= bounds.width * 0.78 * progress }
|
|
140
|
+
return swipeEnabled && !touchPathContainsExclusiveControl()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
144
|
+
guard enabled, bounds.width > 0 else { return false }
|
|
145
|
+
let velocity = pan.velocity(in: self)
|
|
146
|
+
guard abs(velocity.x) > abs(velocity.y) else { return false }
|
|
147
|
+
let location = pan.location(in: self)
|
|
148
|
+
let translation = pan.translation(in: self)
|
|
149
|
+
let start = CGPoint(x: location.x - translation.x, y: location.y - translation.y)
|
|
150
|
+
guard start.y < bounds.height - CGFloat(max(0, bottomBarHeight)) else { return false }
|
|
151
|
+
if progress > 0 {
|
|
152
|
+
return start.x >= bounds.width * 0.78 * progress
|
|
153
|
+
}
|
|
154
|
+
return swipeEnabled && velocity.x > 0 && !touchPathCanScrollRight()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Let this recognizer make its one-time direction/boundary decision before
|
|
158
|
+
// descendant scroll pans. Returning false from shouldBegin releases them.
|
|
159
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
|
160
|
+
shouldBeRequiredToFailBy other: UIGestureRecognizer) -> Bool {
|
|
161
|
+
guard gestureRecognizer === pan,
|
|
162
|
+
let scroll = other.view as? UIScrollView,
|
|
163
|
+
other === scroll.panGestureRecognizer else { return false }
|
|
164
|
+
return scroll.isDescendant(of: foregroundContent)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
|
168
|
+
shouldRequireFailureOf other: UIGestureRecognizer) -> Bool {
|
|
169
|
+
guard gestureRecognizer === pan else { return false }
|
|
170
|
+
// UIKit's navigation edge gesture retains priority over the drawer.
|
|
171
|
+
return other is UIScreenEdgePanGestureRecognizer
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private func touchPathContainsExclusiveControl() -> Bool {
|
|
175
|
+
var view = touchTarget
|
|
176
|
+
while let current = view, current !== self {
|
|
177
|
+
if current is UISlider || current is UISwitch || current is UISegmentedControl ||
|
|
178
|
+
current is UITextField || current is UITextView { return true }
|
|
179
|
+
if let webClass = NSClassFromString("WKWebView"), current.isKind(of: webClass) { return true }
|
|
180
|
+
if current.layer is CAMetalLayer { return true }
|
|
181
|
+
view = current.superview
|
|
182
|
+
}
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private func pageController(for scroll: UIScrollView) -> UIPageViewController? {
|
|
187
|
+
var responder: UIResponder? = scroll.next
|
|
188
|
+
while let current = responder, current !== self {
|
|
189
|
+
if let page = current as? UIPageViewController, scroll.superview === page.view { return page }
|
|
190
|
+
responder = current.next
|
|
191
|
+
}
|
|
192
|
+
return nil
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private func touchPathCanScrollRight() -> Bool {
|
|
196
|
+
var view = touchTarget
|
|
197
|
+
while let current = view, current !== self {
|
|
198
|
+
if let scroll = current as? UIScrollView, scroll.isScrollEnabled {
|
|
199
|
+
// The native smooth pager exposes this existing header exclusion. Read
|
|
200
|
+
// its getter instead of invoking UIKit's recognition lifecycle hook.
|
|
201
|
+
let selector = NSSelectorFromString("excludedHeaderView")
|
|
202
|
+
if scroll.responds(to: selector),
|
|
203
|
+
let header = scroll.perform(selector)?.takeUnretainedValue() as? UIView,
|
|
204
|
+
let target = touchTarget, target.isDescendant(of: header) {
|
|
205
|
+
view = current.superview
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
if let page = pageController(for: scroll) {
|
|
209
|
+
// UIPageViewController keeps its private scroll view centered even at
|
|
210
|
+
// the first page. Its public before query describes physical right.
|
|
211
|
+
if page.navigationOrientation == .horizontal,
|
|
212
|
+
let displayed = page.viewControllers?.first,
|
|
213
|
+
page.dataSource?.pageViewController(page, viewControllerBefore: displayed) != nil { return true }
|
|
214
|
+
} else {
|
|
215
|
+
let inset = scroll.adjustedContentInset
|
|
216
|
+
let horizontal = scroll.contentSize.width + inset.left + inset.right > scroll.bounds.width + 0.5
|
|
217
|
+
if horizontal && scroll.contentOffset.x > -inset.left + 0.5 { return true }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
view = current.superview
|
|
221
|
+
}
|
|
222
|
+
return false
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
|
|
226
|
+
switch recognizer.state {
|
|
227
|
+
case .began:
|
|
228
|
+
gestureReturnOpen = displayLink != nil && !animationUser ? animationTarget : stableOpen
|
|
229
|
+
stopAnimation()
|
|
230
|
+
gestureStartProgress = progress
|
|
231
|
+
case .changed:
|
|
232
|
+
guard recognizer.numberOfTouches <= 1 else {
|
|
233
|
+
interruptGesture()
|
|
234
|
+
settle(to: gestureReturnOpen, velocity: 0, user: false)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
setProgress(gestureStartProgress + recognizer.translation(in: self).x / max(1, bounds.width * 0.78))
|
|
238
|
+
case .ended:
|
|
239
|
+
let velocity = min(8, max(-8, recognizer.velocity(in: self).x / max(1, bounds.width * 0.78)))
|
|
240
|
+
settle(to: progress + velocity * 0.12 >= 0.5, velocity: velocity, user: true)
|
|
241
|
+
case .cancelled, .failed:
|
|
242
|
+
if !suppressCancellation { settle(to: gestureReturnOpen, velocity: 0, user: false) }
|
|
243
|
+
default: break
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private func interruptGesture() {
|
|
248
|
+
guard pan != nil else { return }
|
|
249
|
+
suppressCancellation = true
|
|
250
|
+
pan.isEnabled = false
|
|
251
|
+
pan.isEnabled = true
|
|
252
|
+
touchTarget = nil
|
|
253
|
+
suppressCancellation = false
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@objc private func closeFromUser() {
|
|
257
|
+
interruptGesture()
|
|
258
|
+
settle(to: false, velocity: 0, user: true)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private func setProgress(_ value: CGFloat) {
|
|
262
|
+
progress = min(1, max(0, enabled ? value : 0))
|
|
263
|
+
foreground.transform = CGAffineTransform(translationX: bounds.width * 0.78 * progress, y: 0)
|
|
264
|
+
foreground.layer.cornerRadius = 64 * progress
|
|
265
|
+
dim.alpha = 0.7 * progress
|
|
266
|
+
dim.isHidden = progress == 0
|
|
267
|
+
foregroundContent.isUserInteractionEnabled = progress == 0
|
|
268
|
+
foregroundContent.accessibilityElementsHidden = progress > 0
|
|
269
|
+
menu.transform = CGAffineTransform(translationX: -0.12 * bounds.width * 0.78 * (1 - progress), y: 0)
|
|
270
|
+
menuShade.alpha = 0.12 * (1 - progress)
|
|
271
|
+
menu.isHidden = progress == 0
|
|
272
|
+
menu.accessibilityElementsHidden = progress == 0
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private func settle(to target: Bool, velocity: CGFloat, user: Bool) {
|
|
276
|
+
stopAnimation()
|
|
277
|
+
let target = enabled && target
|
|
278
|
+
guard bounds.width > 0, window != nil else {
|
|
279
|
+
stableOpen = target
|
|
280
|
+
setProgress(target ? 1 : 0)
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if progress == (target ? 1 : 0) {
|
|
284
|
+
finish(target: target, user: user)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
animationFrom = progress
|
|
288
|
+
animationVelocity = min(8, max(-8, velocity))
|
|
289
|
+
animationTarget = target
|
|
290
|
+
animationUser = user
|
|
291
|
+
animationStart = CACurrentMediaTime()
|
|
292
|
+
let link = CADisplayLink(target: self, selector: #selector(tick))
|
|
293
|
+
link.add(to: .main, forMode: .common)
|
|
294
|
+
displayLink = link
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@objc private func tick() {
|
|
298
|
+
let elapsed = CGFloat(CACurrentMediaTime() - animationStart)
|
|
299
|
+
let target: CGFloat = animationTarget ? 1 : 0
|
|
300
|
+
let a = animationFrom - target
|
|
301
|
+
let b = animationVelocity + 22 * a
|
|
302
|
+
setProgress(target + (a + b * elapsed) * exp(-22 * elapsed))
|
|
303
|
+
if elapsed >= 0.5 {
|
|
304
|
+
let target = animationTarget
|
|
305
|
+
let user = animationUser
|
|
306
|
+
stopAnimation()
|
|
307
|
+
finish(target: target, user: user)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private func finish(target: Bool, user: Bool) {
|
|
312
|
+
let changed = stableOpen != target
|
|
313
|
+
stableOpen = target
|
|
314
|
+
setProgress(target ? 1 : 0)
|
|
315
|
+
if user && (changed || target != open) {
|
|
316
|
+
if hapticFeedbackEnabled && changed { UIImpactFeedbackGenerator(style: .light).impactOccurred() }
|
|
317
|
+
onDrawerOpenChange?(target)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private func stopAnimation() {
|
|
322
|
+
displayLink?.invalidate()
|
|
323
|
+
displayLink = nil
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
deinit { displayLink?.invalidate() }
|
|
327
|
+
}
|