@onekeyfe/react-native-bottom-tabs 6.7.0-alpha.383 → 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/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. Retained state is bounded to the current touch sequence, the last
150
- pressed tab and timestamp, and at most two running animators.
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
- if !tbc.tabBar.isHidden {
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
- if !tabBarHidden {
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,8 @@
1
+ #ifdef RCT_NEW_ARCH_ENABLED
2
+ #import <React/RCTViewComponentView.h>
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+ @interface RCTBottomTabsDrawerComponentView : RCTViewComponentView
6
+ @end
7
+ NS_ASSUME_NONNULL_END
8
+ #endif
@@ -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
+ }