react-native-nitro-modal 0.2.0 → 0.3.1-rc
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/README.md +52 -4
- package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +399 -34
- package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +67 -22
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +35 -1
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +48 -5
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +63 -4
- package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +68 -8
- package/ios/HybridNitroModal.swift +115 -20
- package/ios/ModalConfig.swift +48 -2
- package/ios/NitroModalHostView.swift +73 -8
- package/ios/SheetView.swift +647 -0
- package/ios/SheetViewController.swift +19 -450
- package/lib/module/NitroModal.js +54 -6
- package/lib/module/NitroModal.js.map +1 -1
- package/lib/module/NitroModalView.web.js +86 -26
- package/lib/module/NitroModalView.web.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/web/AnimatedValue.js +3 -0
- package/lib/module/web/AnimatedValue.js.map +1 -1
- package/lib/module/web/ModalConfig.js +10 -4
- package/lib/module/web/ModalConfig.js.map +1 -1
- package/lib/module/web/ModalController.js +29 -2
- package/lib/module/web/ModalController.js.map +1 -1
- package/lib/module/web/ModalPresenter.js +47 -12
- package/lib/module/web/ModalPresenter.js.map +1 -1
- package/lib/module/web/SheetPresenter.js +118 -34
- package/lib/module/web/SheetPresenter.js.map +1 -1
- package/lib/module/web/dom.js +16 -0
- package/lib/module/web/dom.js.map +1 -1
- package/lib/typescript/src/NitroModal.d.ts +42 -2
- package/lib/typescript/src/NitroModal.d.ts.map +1 -1
- package/lib/typescript/src/NitroModal.nitro.d.ts +37 -2
- package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/web/AnimatedValue.d.ts +1 -0
- package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -1
- package/lib/typescript/src/web/ModalConfig.d.ts +10 -0
- package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -1
- package/lib/typescript/src/web/ModalController.d.ts +5 -0
- package/lib/typescript/src/web/ModalController.d.ts.map +1 -1
- package/lib/typescript/src/web/ModalPresenter.d.ts +13 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/SheetPresenter.d.ts +13 -2
- package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/dom.d.ts +4 -0
- package/lib/typescript/src/web/dom.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +78 -6
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +14 -2
- package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
- package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
- package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
- package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +30 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +52 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
- package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +68 -8
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
- package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +46 -5
- package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +36 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +6 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +158 -5
- package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
- package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +12 -0
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +18 -5
- package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +13 -1
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +21 -2
- package/nitrogen/generated/shared/json/NitroModalConfig.json +6 -0
- package/package.json +1 -1
- package/src/NitroModal.nitro.ts +38 -2
- package/src/NitroModal.tsx +93 -4
- package/src/NitroModalView.web.tsx +79 -12
- package/src/index.tsx +2 -0
- package/src/web/AnimatedValue.ts +4 -0
- package/src/web/ModalConfig.ts +19 -3
- package/src/web/ModalController.ts +35 -2
- package/src/web/ModalPresenter.ts +50 -11
- package/src/web/SheetPresenter.ts +169 -37
- package/src/web/dom.ts +20 -0
|
@@ -5,67 +5,29 @@
|
|
|
5
5
|
|
|
6
6
|
import UIKit
|
|
7
7
|
|
|
8
|
-
/// `bottomSheet` mode: an edge-attached
|
|
8
|
+
/// Modal `bottomSheet` mode: an edge-attached `SheetView` presented
|
|
9
|
+
/// `.overFullScreen`.
|
|
9
10
|
///
|
|
10
11
|
/// `UISheetPresentationController` is not used because iOS 26 always floats
|
|
11
12
|
/// sheets inset from the screen edges at partial detents, with no API to opt
|
|
12
13
|
/// out. Detents, dragging, swipe-to-dismiss and the hand-off to scroll views
|
|
13
|
-
/// inside the content are implemented
|
|
14
|
-
final class SheetViewController: UIViewController, ModalPresenting, UIViewControllerTransitioningDelegate
|
|
15
|
-
weak var presentationDelegate: ModalPresentationDelegate?
|
|
16
|
-
|
|
17
|
-
/// Gap kept above the sheet at its tallest, like a system page sheet.
|
|
18
|
-
private static let topGap: CGFloat = 10
|
|
19
|
-
/// Extra sheet height below the screen so it stays attached to the bottom
|
|
20
|
-
/// edge while rubber-banding upward.
|
|
21
|
-
private static let overscroll: CGFloat = 400
|
|
22
|
-
private static let defaultCornerRadius: CGFloat = 16
|
|
23
|
-
private static let regularMaxWidth: CGFloat = 704
|
|
24
|
-
private static let grabberSize = CGSize(width: 36, height: 5)
|
|
25
|
-
|
|
26
|
-
private let contentView: NitroModalContentView
|
|
27
|
-
private let backdrop = BackdropView()
|
|
28
|
-
private let sheetView = UIView()
|
|
29
|
-
private let grabber = UIView()
|
|
30
|
-
private var config: ModalConfig
|
|
31
|
-
private var selectedIndex: Int
|
|
32
|
-
private var keyboardHeight: CGFloat = 0
|
|
33
|
-
|
|
34
|
-
/// The sheet sits below the screen (before presenting / while dismissing).
|
|
35
|
-
private var isOffscreen = true
|
|
36
|
-
/// Top of the sheet while the user drags it; `nil` rests on `selectedIndex`.
|
|
37
|
-
private var dragTop: CGFloat?
|
|
38
|
-
private var drag: DragState?
|
|
39
|
-
/// Vertical velocity of the swipe that requested the dismissal.
|
|
40
|
-
private var releaseVelocity: CGFloat = 0
|
|
41
|
-
|
|
42
|
-
private var lockedScrollView: UIScrollView?
|
|
43
|
-
private var lockedOffsetY: CGFloat = 0
|
|
44
|
-
private var scrollLock: NSKeyValueObservation?
|
|
45
|
-
|
|
46
|
-
private struct DragState {
|
|
47
|
-
/// Unconstrained sheet top, before rubber-banding.
|
|
48
|
-
var rawTop: CGFloat
|
|
49
|
-
var lastTranslation: CGFloat = 0
|
|
50
|
-
var scrollView: UIScrollView?
|
|
51
|
-
/// Whether the last movement moved the sheet rather than the scroll view.
|
|
52
|
-
var sheetDrove = false
|
|
14
|
+
/// inside the content are implemented by `SheetView` with a pan gesture.
|
|
15
|
+
final class SheetViewController: UIViewController, ModalPresenting, UIViewControllerTransitioningDelegate {
|
|
16
|
+
weak var presentationDelegate: ModalPresentationDelegate? {
|
|
17
|
+
didSet { sheet.delegate = presentationDelegate }
|
|
53
18
|
}
|
|
54
19
|
|
|
20
|
+
private let sheet: SheetView
|
|
21
|
+
|
|
55
22
|
init(contentView: NitroModalContentView, config: ModalConfig) {
|
|
56
|
-
|
|
57
|
-
self.config = config
|
|
58
|
-
selectedIndex = config.clampedInitialDetentIndex
|
|
23
|
+
sheet = SheetView(contentView: contentView, config: config, isInline: false)
|
|
59
24
|
super.init(nibName: nil, bundle: nil)
|
|
60
25
|
modalPresentationStyle = .overFullScreen
|
|
61
26
|
transitioningDelegate = self
|
|
62
|
-
|
|
63
|
-
self
|
|
27
|
+
sheet.isTransitioning = { [weak self] in
|
|
28
|
+
guard let self else { return false }
|
|
29
|
+
return self.isBeingPresented || self.isBeingDismissed
|
|
64
30
|
}
|
|
65
|
-
|
|
66
|
-
let center = NotificationCenter.default
|
|
67
|
-
center.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
|
68
|
-
center.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
|
69
31
|
}
|
|
70
32
|
|
|
71
33
|
@available(*, unavailable)
|
|
@@ -74,67 +36,30 @@ final class SheetViewController: UIViewController, ModalPresenting, UIViewContro
|
|
|
74
36
|
}
|
|
75
37
|
|
|
76
38
|
override func loadView() {
|
|
77
|
-
|
|
78
|
-
root.backgroundColor = .clear
|
|
79
|
-
root.addSubview(backdrop)
|
|
80
|
-
|
|
81
|
-
sheetView.clipsToBounds = true
|
|
82
|
-
sheetView.layer.cornerCurve = .continuous
|
|
83
|
-
sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
84
|
-
sheetView.accessibilityViewIsModal = true
|
|
85
|
-
sheetView.addSubview(contentView)
|
|
86
|
-
|
|
87
|
-
grabber.isUserInteractionEnabled = false
|
|
88
|
-
grabber.backgroundColor = .tertiaryLabel
|
|
89
|
-
grabber.layer.cornerRadius = Self.grabberSize.height / 2
|
|
90
|
-
sheetView.addSubview(grabber)
|
|
91
|
-
root.addSubview(sheetView)
|
|
92
|
-
|
|
93
|
-
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
|
94
|
-
pan.delegate = self
|
|
95
|
-
sheetView.addGestureRecognizer(pan)
|
|
96
|
-
|
|
97
|
-
view = root
|
|
98
|
-
applyAppearance()
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
override func viewDidLayoutSubviews() {
|
|
102
|
-
super.viewDidLayoutSubviews()
|
|
103
|
-
layoutSheet()
|
|
104
|
-
reportContentArea()
|
|
39
|
+
view = sheet
|
|
105
40
|
}
|
|
106
41
|
|
|
107
42
|
override func viewDidDisappear(_ animated: Bool) {
|
|
108
43
|
super.viewDidDisappear(animated)
|
|
109
|
-
unlockScroll()
|
|
44
|
+
sheet.unlockScroll()
|
|
110
45
|
}
|
|
111
46
|
|
|
112
47
|
override func accessibilityPerformEscape() -> Bool {
|
|
113
|
-
|
|
114
|
-
presentationDelegate?.modalPresentationDidRequestSwipeDismiss()
|
|
115
|
-
return true
|
|
48
|
+
sheet.performEscape()
|
|
116
49
|
}
|
|
117
50
|
|
|
118
51
|
// MARK: ModalPresenting
|
|
119
52
|
|
|
120
53
|
func apply(_ newConfig: ModalConfig) {
|
|
121
|
-
|
|
122
|
-
selectedIndex = min(selectedIndex, max(detents.count - 1, 0))
|
|
123
|
-
guard isViewLoaded else { return }
|
|
124
|
-
applyAppearance()
|
|
125
|
-
animateToRest()
|
|
54
|
+
sheet.apply(newConfig)
|
|
126
55
|
}
|
|
127
56
|
|
|
128
57
|
func contentSizeDidChange() {
|
|
129
|
-
|
|
130
|
-
animateToRest()
|
|
58
|
+
sheet.contentSizeDidChange()
|
|
131
59
|
}
|
|
132
60
|
|
|
133
61
|
func snapToDetent(at index: Int) {
|
|
134
|
-
|
|
135
|
-
selectedIndex = index
|
|
136
|
-
animateToRest()
|
|
137
|
-
presentationDelegate?.modalPresentationDidChangeDetent(index)
|
|
62
|
+
sheet.snapToDetent(at: index)
|
|
138
63
|
}
|
|
139
64
|
|
|
140
65
|
// MARK: UIViewControllerTransitioningDelegate
|
|
@@ -151,349 +76,8 @@ final class SheetViewController: UIViewController, ModalPresenting, UIViewContro
|
|
|
151
76
|
SheetTransition(presenting: false)
|
|
152
77
|
}
|
|
153
78
|
|
|
154
|
-
/// Slides the sheet in or out; a swipe dismissal keeps its release velocity.
|
|
155
79
|
fileprivate func animateTransition(toOffscreen offscreen: Bool, animated: Bool, duration: TimeInterval, completion: @escaping () -> Void) {
|
|
156
|
-
|
|
157
|
-
let velocity = offscreen ? releaseVelocity : 0
|
|
158
|
-
releaseVelocity = 0
|
|
159
|
-
isOffscreen = offscreen
|
|
160
|
-
dragTop = nil
|
|
161
|
-
guard animated else {
|
|
162
|
-
layoutSheet()
|
|
163
|
-
completion()
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
animate(
|
|
167
|
-
distance: currentTop - from,
|
|
168
|
-
velocity: velocity,
|
|
169
|
-
damping: offscreen ? 1 : 0.88,
|
|
170
|
-
duration: duration,
|
|
171
|
-
animations: { self.layoutSheet() },
|
|
172
|
-
completion: completion
|
|
173
|
-
)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// MARK: Layout
|
|
177
|
-
|
|
178
|
-
private var detents: [SheetDetent] {
|
|
179
|
-
config.detents.isEmpty ? [.fitcontent] : config.detents
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/// Tallest height the content may occupy (excludes the bottom safe area).
|
|
183
|
-
private var maximumDetentValue: CGFloat {
|
|
184
|
-
let insets = view.safeAreaInsets
|
|
185
|
-
return max(0, view.bounds.height - insets.top - insets.bottom - Self.topGap)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
private var keyboardLift: CGFloat {
|
|
189
|
-
config.keyboardBehavior == .none ? 0 : max(0, keyboardHeight - view.safeAreaInsets.bottom)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
private func height(of detent: SheetDetent) -> CGFloat {
|
|
193
|
-
let maximum = maximumDetentValue
|
|
194
|
-
switch detent {
|
|
195
|
-
case .small: return maximum * 0.25
|
|
196
|
-
case .medium: return maximum * 0.5
|
|
197
|
-
case .large: return maximum
|
|
198
|
-
case .fitcontent: return min(max(contentView.contentSize.height, 1), maximum)
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/// Sheet top when resting on `detents[index]`. The bottom safe area is
|
|
203
|
-
/// added below the content.
|
|
204
|
-
private func restingTop(for index: Int) -> CGFloat {
|
|
205
|
-
let detent = detents[min(max(index, 0), detents.count - 1)]
|
|
206
|
-
let top = view.bounds.height - view.safeAreaInsets.bottom - keyboardLift - height(of: detent)
|
|
207
|
-
return max(top, view.safeAreaInsets.top + Self.topGap)
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private var detentTops: [CGFloat] {
|
|
211
|
-
detents.indices.map(restingTop(for:))
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
private var currentTop: CGFloat {
|
|
215
|
-
if isOffscreen { return view.bounds.height }
|
|
216
|
-
return dragTop ?? restingTop(for: selectedIndex)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
private func layoutSheet() {
|
|
220
|
-
let bounds = view.bounds
|
|
221
|
-
let width = Self.width(for: bounds.width, sizeClass: traitCollection.horizontalSizeClass)
|
|
222
|
-
let top = currentTop
|
|
223
|
-
backdrop.frame = bounds
|
|
224
|
-
sheetView.frame = CGRect(x: (bounds.width - width) / 2, y: top, width: width, height: bounds.height + Self.overscroll)
|
|
225
|
-
contentView.frame = CGRect(x: 0, y: 0, width: width, height: maximumDetentValue)
|
|
226
|
-
grabber.frame = CGRect(
|
|
227
|
-
origin: CGPoint(x: (width - Self.grabberSize.width) / 2, y: 5),
|
|
228
|
-
size: Self.grabberSize
|
|
229
|
-
)
|
|
230
|
-
backdrop.alpha = backdropAlpha(forTop: top)
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/// Fully dimmed at every detent; fades out below the lowest one.
|
|
234
|
-
private func backdropAlpha(forTop top: CGFloat) -> CGFloat {
|
|
235
|
-
let bottom = view.bounds.height
|
|
236
|
-
guard let lowest = detentTops.max(), bottom > lowest else { return top >= bottom ? 0 : 1 }
|
|
237
|
-
return min(max((bottom - top) / (bottom - lowest), 0), 1)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private func applyAppearance() {
|
|
241
|
-
backdrop.configure(color: config.backdropColor, opacity: config.backdropOpacity, blurRadius: config.backdropBlurRadius)
|
|
242
|
-
sheetView.backgroundColor = config.contentBackgroundColor
|
|
243
|
-
sheetView.layer.cornerRadius = config.cornerRadius ?? Self.defaultCornerRadius
|
|
244
|
-
grabber.isHidden = !config.grabberVisible
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private func animateToRest() {
|
|
248
|
-
guard isViewLoaded, drag == nil, !isOffscreen else { return }
|
|
249
|
-
dragTop = nil
|
|
250
|
-
UIView.animate(withDuration: 0.3, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction]) {
|
|
251
|
-
self.view.setNeedsLayout()
|
|
252
|
-
self.view.layoutIfNeeded()
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
private func animate(
|
|
257
|
-
distance: CGFloat,
|
|
258
|
-
velocity: CGFloat,
|
|
259
|
-
damping: CGFloat = 0.9,
|
|
260
|
-
duration: TimeInterval = 0.45,
|
|
261
|
-
animations: @escaping () -> Void,
|
|
262
|
-
completion: (() -> Void)? = nil
|
|
263
|
-
) {
|
|
264
|
-
// UIKit expects the initial velocity relative to the distance travelled.
|
|
265
|
-
let relativeVelocity = abs(distance) > 1 ? min(max(velocity / distance, -30), 30) : 0
|
|
266
|
-
UIView.animate(
|
|
267
|
-
withDuration: duration,
|
|
268
|
-
delay: 0,
|
|
269
|
-
usingSpringWithDamping: damping,
|
|
270
|
-
initialSpringVelocity: relativeVelocity,
|
|
271
|
-
options: [.allowUserInteraction, .beginFromCurrentState],
|
|
272
|
-
animations: animations,
|
|
273
|
-
completion: { _ in completion?() }
|
|
274
|
-
)
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// MARK: Content area
|
|
278
|
-
|
|
279
|
-
private func reportContentArea() {
|
|
280
|
-
guard isViewLoaded, view.bounds.width > 0 else { return }
|
|
281
|
-
var height = Self.contentHeight(for: config.detents, maximum: maximumDetentValue)
|
|
282
|
-
if config.keyboardBehavior == .resize {
|
|
283
|
-
height -= keyboardLift
|
|
284
|
-
}
|
|
285
|
-
let width = Self.width(for: view.bounds.width, sizeClass: traitCollection.horizontalSizeClass)
|
|
286
|
-
presentationDelegate?.modalPresentationDidChangeContentArea(CGSize(width: width, height: max(height, 0)))
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/// Height the content may occupy at the largest of `detents`.
|
|
290
|
-
static func contentHeight(for detents: [SheetDetent], maximum: CGFloat) -> CGFloat {
|
|
291
|
-
if detents.isEmpty || detents.contains(.large) || detents.contains(.fitcontent) {
|
|
292
|
-
return maximum
|
|
293
|
-
}
|
|
294
|
-
return detents.contains(.medium) ? maximum * 0.5 : maximum * 0.25
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/// Best guess of `maximumDetentValue` before the sheet is on screen.
|
|
298
|
-
static func estimatedMaximumDetentValue(in window: UIWindow) -> CGFloat {
|
|
299
|
-
let insets = window.safeAreaInsets
|
|
300
|
-
return window.bounds.height - insets.top - insets.bottom - topGap
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/// Best guess of the sheet width before it is on screen.
|
|
304
|
-
static func estimatedWidth(in window: UIWindow) -> CGFloat {
|
|
305
|
-
width(for: window.bounds.width, sizeClass: window.traitCollection.horizontalSizeClass)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
private static func width(for available: CGFloat, sizeClass: UIUserInterfaceSizeClass) -> CGFloat {
|
|
309
|
-
sizeClass == .regular ? min(available, regularMaxWidth) : available
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// MARK: Dragging
|
|
313
|
-
|
|
314
|
-
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
315
|
-
guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
|
|
316
|
-
!isBeingPresented, !isBeingDismissed, !isOffscreen else { return false }
|
|
317
|
-
let velocity = pan.velocity(in: view)
|
|
318
|
-
return abs(velocity.y) > abs(velocity.x)
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/// Scroll views inside the content keep scrolling; `handlePan` decides
|
|
322
|
-
/// frame by frame whether the sheet or the scroll view moves.
|
|
323
|
-
func gestureRecognizer(
|
|
324
|
-
_ gestureRecognizer: UIGestureRecognizer,
|
|
325
|
-
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
|
326
|
-
) -> Bool {
|
|
327
|
-
guard let scrollView = other.view as? UIScrollView else { return false }
|
|
328
|
-
return other === scrollView.panGestureRecognizer && scrollView.isDescendant(of: sheetView)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
@objc private func handlePan(_ pan: UIPanGestureRecognizer) {
|
|
332
|
-
switch pan.state {
|
|
333
|
-
case .began:
|
|
334
|
-
// Grab the sheet where it is, even mid-animation.
|
|
335
|
-
let top = sheetView.layer.presentation()?.frame.minY ?? sheetView.frame.minY
|
|
336
|
-
sheetView.layer.removeAllAnimations()
|
|
337
|
-
backdrop.layer.removeAllAnimations()
|
|
338
|
-
let translation = pan.translation(in: sheetView)
|
|
339
|
-
let location = pan.location(in: sheetView)
|
|
340
|
-
let start = CGPoint(x: location.x - translation.x, y: location.y - translation.y)
|
|
341
|
-
var state = DragState(rawTop: top)
|
|
342
|
-
state.lastTranslation = pan.translation(in: view).y
|
|
343
|
-
state.scrollView = verticalScrollView(at: start)
|
|
344
|
-
if let scrollView = state.scrollView {
|
|
345
|
-
lockedOffsetY = scrollView.contentOffset.y
|
|
346
|
-
}
|
|
347
|
-
drag = state
|
|
348
|
-
dragTop = top
|
|
349
|
-
layoutSheet()
|
|
350
|
-
|
|
351
|
-
case .changed:
|
|
352
|
-
guard var state = drag, let minTop = detentTops.min() else { return }
|
|
353
|
-
let translation = pan.translation(in: view).y
|
|
354
|
-
let delta = translation - state.lastTranslation
|
|
355
|
-
state.lastTranslation = translation
|
|
356
|
-
|
|
357
|
-
if let scrollView = state.scrollView {
|
|
358
|
-
// Up: expand the sheet before scrolling. Down: scroll back to the top
|
|
359
|
-
// before collapsing the sheet.
|
|
360
|
-
let drive = delta < 0 ? state.rawTop > minTop + 0.5 : scrollView.isScrolledToTop
|
|
361
|
-
if drive {
|
|
362
|
-
state.rawTop = max(state.rawTop + delta, minTop)
|
|
363
|
-
lockScroll(scrollView, at: delta < 0 ? lockedOffsetY : scrollView.topOffsetY)
|
|
364
|
-
} else {
|
|
365
|
-
unlockScroll()
|
|
366
|
-
lockedOffsetY = scrollView.contentOffset.y
|
|
367
|
-
}
|
|
368
|
-
state.sheetDrove = drive
|
|
369
|
-
} else {
|
|
370
|
-
state.rawTop += delta
|
|
371
|
-
state.sheetDrove = true
|
|
372
|
-
}
|
|
373
|
-
drag = state
|
|
374
|
-
dragTop = constrained(state.rawTop)
|
|
375
|
-
layoutSheet()
|
|
376
|
-
|
|
377
|
-
case .ended, .cancelled, .failed:
|
|
378
|
-
guard let state = drag else { return }
|
|
379
|
-
drag = nil
|
|
380
|
-
let velocity = state.sheetDrove ? pan.velocity(in: view).y : 0
|
|
381
|
-
endDrag(at: dragTop ?? currentTop, velocity: velocity, holdingScroll: state.sheetDrove)
|
|
382
|
-
|
|
383
|
-
default:
|
|
384
|
-
break
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
private func endDrag(at top: CGFloat, velocity: CGFloat, holdingScroll: Bool) {
|
|
389
|
-
if !holdingScroll {
|
|
390
|
-
unlockScroll()
|
|
391
|
-
}
|
|
392
|
-
let tops = detentTops
|
|
393
|
-
guard let lowest = tops.max() else { return }
|
|
394
|
-
// Where a fling of this velocity would come to rest.
|
|
395
|
-
let projected = top + velocity * 0.2
|
|
396
|
-
|
|
397
|
-
if config.dismissOnSwipe, velocity >= 0, projected > lowest + (view.bounds.height - lowest) / 2 {
|
|
398
|
-
releaseVelocity = velocity
|
|
399
|
-
presentationDelegate?.modalPresentationDidRequestSwipeDismiss()
|
|
400
|
-
if isBeingDismissed {
|
|
401
|
-
return
|
|
402
|
-
}
|
|
403
|
-
releaseVelocity = 0
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
let index = tops.indices.min { abs(tops[$0] - projected) < abs(tops[$1] - projected) } ?? selectedIndex
|
|
407
|
-
if index != selectedIndex {
|
|
408
|
-
selectedIndex = index
|
|
409
|
-
presentationDelegate?.modalPresentationDidChangeDetent(index)
|
|
410
|
-
}
|
|
411
|
-
dragTop = nil
|
|
412
|
-
animate(distance: restingTop(for: index) - top, velocity: velocity, animations: { self.layoutSheet() }) { [weak self] in
|
|
413
|
-
guard let self, self.drag == nil else { return }
|
|
414
|
-
self.unlockScroll()
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/// Rubber-bands above the tallest detent, and below the lowest one when
|
|
419
|
-
/// swiping cannot dismiss.
|
|
420
|
-
private func constrained(_ top: CGFloat) -> CGFloat {
|
|
421
|
-
let tops = detentTops
|
|
422
|
-
guard let minTop = tops.min(), let maxTop = tops.max() else { return top }
|
|
423
|
-
let dimension = max(view.bounds.height, 1)
|
|
424
|
-
if top < minTop {
|
|
425
|
-
return minTop - Self.rubberBand(minTop - top, dimension: dimension)
|
|
426
|
-
}
|
|
427
|
-
if !config.dismissOnSwipe, top > maxTop {
|
|
428
|
-
return maxTop + Self.rubberBand(top - maxTop, dimension: dimension)
|
|
429
|
-
}
|
|
430
|
-
return top
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
private static func rubberBand(_ offset: CGFloat, dimension: CGFloat) -> CGFloat {
|
|
434
|
-
(1 - 1 / (offset * 0.55 / dimension + 1)) * dimension
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
/// The innermost vertically scrollable view under `point` (in `sheetView`).
|
|
438
|
-
private func verticalScrollView(at point: CGPoint) -> UIScrollView? {
|
|
439
|
-
var candidate = sheetView.hitTest(point, with: nil)
|
|
440
|
-
while let current = candidate, current !== sheetView {
|
|
441
|
-
if let scrollView = current as? UIScrollView, scrollView.isScrollEnabled, scrollView.canScrollVertically {
|
|
442
|
-
return scrollView
|
|
443
|
-
}
|
|
444
|
-
candidate = current.superview
|
|
445
|
-
}
|
|
446
|
-
return nil
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/// Pins the scroll view's offset while the sheet moves, so content does not
|
|
450
|
-
/// scroll (or bounce) at the same time.
|
|
451
|
-
private func lockScroll(_ scrollView: UIScrollView, at offsetY: CGFloat) {
|
|
452
|
-
if lockedScrollView !== scrollView {
|
|
453
|
-
unlockScroll()
|
|
454
|
-
lockedScrollView = scrollView
|
|
455
|
-
}
|
|
456
|
-
lockedOffsetY = offsetY
|
|
457
|
-
if scrollView.contentOffset.y != offsetY {
|
|
458
|
-
scrollView.contentOffset.y = offsetY
|
|
459
|
-
}
|
|
460
|
-
guard scrollLock == nil else { return }
|
|
461
|
-
scrollLock = scrollView.observe(\.contentOffset) { [weak self] scrollView, _ in
|
|
462
|
-
guard let self, scrollView.contentOffset.y != self.lockedOffsetY else { return }
|
|
463
|
-
scrollView.contentOffset.y = self.lockedOffsetY
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
private func unlockScroll() {
|
|
468
|
-
guard let scrollView = lockedScrollView else { return }
|
|
469
|
-
scrollLock?.invalidate()
|
|
470
|
-
scrollLock = nil
|
|
471
|
-
lockedScrollView = nil
|
|
472
|
-
// Stops any deceleration left over from a fling that moved the sheet.
|
|
473
|
-
scrollView.setContentOffset(scrollView.contentOffset, animated: false)
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// MARK: Keyboard
|
|
477
|
-
|
|
478
|
-
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
|
|
479
|
-
guard isViewLoaded, let window = view.window,
|
|
480
|
-
let frame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }
|
|
481
|
-
let keyboard = window.convert(frame, from: window.screen.coordinateSpace)
|
|
482
|
-
updateKeyboardHeight(max(0, window.bounds.maxY - keyboard.minY), notification: notification)
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
@objc private func keyboardWillHide(_ notification: Notification) {
|
|
486
|
-
updateKeyboardHeight(0, notification: notification)
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
private func updateKeyboardHeight(_ height: CGFloat, notification: Notification) {
|
|
490
|
-
guard isViewLoaded, height != keyboardHeight else { return }
|
|
491
|
-
keyboardHeight = height
|
|
492
|
-
guard drag == nil else { return }
|
|
493
|
-
notification.animateAlongsideKeyboard {
|
|
494
|
-
self.view.setNeedsLayout()
|
|
495
|
-
self.view.layoutIfNeeded()
|
|
496
|
-
}
|
|
80
|
+
sheet.animateTransition(toOffscreen: offscreen, animated: animated, duration: duration, completion: completion)
|
|
497
81
|
}
|
|
498
82
|
}
|
|
499
83
|
|
|
@@ -531,18 +115,3 @@ private final class SheetTransition: NSObject, UIViewControllerAnimatedTransitio
|
|
|
531
115
|
}
|
|
532
116
|
}
|
|
533
117
|
}
|
|
534
|
-
|
|
535
|
-
private extension UIScrollView {
|
|
536
|
-
var topOffsetY: CGFloat {
|
|
537
|
-
-adjustedContentInset.top
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
var isScrolledToTop: Bool {
|
|
541
|
-
contentOffset.y <= topOffsetY + 0.5
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
var canScrollVertically: Bool {
|
|
545
|
-
alwaysBounceVertical
|
|
546
|
-
|| contentSize.height + adjustedContentInset.top + adjustedContentInset.bottom > bounds.height + 0.5
|
|
547
|
-
}
|
|
548
|
-
}
|
package/lib/module/NitroModal.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
import { useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { processColor, StyleSheet, useWindowDimensions, View } from 'react-native';
|
|
5
5
|
import { callback, NitroModalView } from './NitroModalView';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where a `bottomSheet` lives.
|
|
9
|
+
* - `modal`: presented over the whole app, with a backdrop.
|
|
10
|
+
* - `inline`: inside the component's own frame (`style`), as part of your
|
|
11
|
+
* screen. No backdrop, the views behind it stay interactive, and the user
|
|
12
|
+
* can't dismiss it.
|
|
13
|
+
*/
|
|
6
14
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
7
15
|
const DEFAULT_DETENTS = ['fitContent'];
|
|
8
16
|
const BLACK = 0xff000000;
|
|
17
|
+
function parseDetent(value) {
|
|
18
|
+
const points = Number(value);
|
|
19
|
+
return value !== '' && Number.isFinite(points) ? points : value;
|
|
20
|
+
}
|
|
9
21
|
function toNativeColor(color) {
|
|
10
22
|
if (color == null) return undefined;
|
|
11
23
|
const processed = processColor(color);
|
|
@@ -15,6 +27,8 @@ function toNativeColor(color) {
|
|
|
15
27
|
export function NitroModal({
|
|
16
28
|
isOpen,
|
|
17
29
|
mode = 'bottomSheet',
|
|
30
|
+
presentation = 'modal',
|
|
31
|
+
style,
|
|
18
32
|
detents = DEFAULT_DETENTS,
|
|
19
33
|
initialDetentIndex = 0,
|
|
20
34
|
backdropColor = 'black',
|
|
@@ -34,11 +48,15 @@ export function NitroModal({
|
|
|
34
48
|
onDetentChange,
|
|
35
49
|
onBackdropPress,
|
|
36
50
|
onBackButtonPress,
|
|
51
|
+
onPullToRefresh,
|
|
52
|
+
refreshing,
|
|
53
|
+
refreshIndicatorColor,
|
|
37
54
|
children,
|
|
38
55
|
testID,
|
|
39
56
|
ref
|
|
40
57
|
}) {
|
|
41
58
|
const isControlled = isOpen !== undefined;
|
|
59
|
+
const isInline = presentation === 'inline' && mode === 'bottomSheet';
|
|
42
60
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
43
61
|
const open = isControlled ? isOpen : internalOpen;
|
|
44
62
|
|
|
@@ -52,26 +70,42 @@ export function NitroModal({
|
|
|
52
70
|
}
|
|
53
71
|
const renderContent = open || sessionActive;
|
|
54
72
|
const [area, setArea] = useState(null);
|
|
73
|
+
// Inline (iOS/Android): where the sheet's top rests in the host. The content
|
|
74
|
+
// is laid out there so `measure()`, which presses rely on, matches the
|
|
75
|
+
// screen; native cancels the offset out on screen.
|
|
76
|
+
const [restingTop, setRestingTop] = useState(0);
|
|
77
|
+
// Native starts refreshing as soon as a pull is released. Reporting `true`
|
|
78
|
+
// for the render that follows, then `refreshing` again, stops it if the
|
|
79
|
+
// handler didn't start a refresh (unchanged props never reach native).
|
|
80
|
+
const isRefreshControlled = refreshing !== undefined;
|
|
81
|
+
const [pullPending, setPullPending] = useState(false);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (pullPending) setPullPending(false);
|
|
84
|
+
}, [pullPending]);
|
|
55
85
|
const hybridRef = useRef(null);
|
|
56
86
|
|
|
57
87
|
// Native callbacks are created once (stable props, no native updates on
|
|
58
88
|
// re-render) and read the latest handlers from this ref.
|
|
59
89
|
const latest = useRef({
|
|
60
90
|
isControlled,
|
|
91
|
+
isRefreshControlled,
|
|
61
92
|
onPresent,
|
|
62
93
|
onDismiss,
|
|
63
94
|
onDetentChange,
|
|
64
95
|
onBackdropPress,
|
|
65
|
-
onBackButtonPress
|
|
96
|
+
onBackButtonPress,
|
|
97
|
+
onPullToRefresh
|
|
66
98
|
});
|
|
67
99
|
useLayoutEffect(() => {
|
|
68
100
|
latest.current = {
|
|
69
101
|
isControlled,
|
|
102
|
+
isRefreshControlled,
|
|
70
103
|
onPresent,
|
|
71
104
|
onDismiss,
|
|
72
105
|
onDetentChange,
|
|
73
106
|
onBackdropPress,
|
|
74
|
-
onBackButtonPress
|
|
107
|
+
onBackButtonPress,
|
|
108
|
+
onPullToRefresh
|
|
75
109
|
};
|
|
76
110
|
});
|
|
77
111
|
const nativeCallbacks = useMemo(() => ({
|
|
@@ -96,6 +130,13 @@ export function NitroModal({
|
|
|
96
130
|
onBackButtonPress: callback(() => {
|
|
97
131
|
latest.current.onBackButtonPress?.();
|
|
98
132
|
}),
|
|
133
|
+
onPullToRefresh: callback(() => {
|
|
134
|
+
if (latest.current.isRefreshControlled) setPullPending(true);
|
|
135
|
+
latest.current.onPullToRefresh?.();
|
|
136
|
+
}),
|
|
137
|
+
onRestingTopChange: callback(top => {
|
|
138
|
+
setRestingTop(top);
|
|
139
|
+
}),
|
|
99
140
|
onContentAreaChange: callback(next => {
|
|
100
141
|
setArea(current => current?.width === next.width && current.height === next.height ? current : next);
|
|
101
142
|
})
|
|
@@ -122,7 +163,7 @@ export function NitroModal({
|
|
|
122
163
|
|
|
123
164
|
// Arrays are diffed by identity, so keep one instance per distinct value.
|
|
124
165
|
const detentsKey = detents.join(',');
|
|
125
|
-
const nativeDetents = useMemo(() => [...new Set(detentsKey.split(','))], [detentsKey]);
|
|
166
|
+
const nativeDetents = useMemo(() => [...new Set(detentsKey.split(','))].map(parseDetent), [detentsKey]);
|
|
126
167
|
|
|
127
168
|
// Native reports the exact area; this only covers the first frame.
|
|
128
169
|
const windowSize = useWindowDimensions();
|
|
@@ -141,9 +182,11 @@ export function NitroModal({
|
|
|
141
182
|
height: contentArea.height
|
|
142
183
|
};
|
|
143
184
|
return /*#__PURE__*/_jsx(NitroModalView, {
|
|
144
|
-
style: styles.host,
|
|
185
|
+
style: isInline ? [StyleSheet.absoluteFill, style] : styles.host,
|
|
186
|
+
pointerEvents: isInline ? 'box-none' : undefined,
|
|
145
187
|
isOpen: open,
|
|
146
188
|
mode: mode,
|
|
189
|
+
isInline: isInline,
|
|
147
190
|
detents: nativeDetents,
|
|
148
191
|
initialDetentIndex: initialDetentIndex,
|
|
149
192
|
backdropColor: toNativeColor(backdropColor) ?? BLACK,
|
|
@@ -157,11 +200,16 @@ export function NitroModal({
|
|
|
157
200
|
contentBackgroundColor: toNativeColor(backgroundColor),
|
|
158
201
|
keyboardBehavior: keyboardBehavior,
|
|
159
202
|
popupAnimation: popupAnimation,
|
|
203
|
+
pullToRefreshEnabled: onPullToRefresh != null,
|
|
204
|
+
refreshing: isRefreshControlled ? refreshing || pullPending : undefined,
|
|
205
|
+
refreshIndicatorColor: toNativeColor(refreshIndicatorColor),
|
|
160
206
|
...nativeCallbacks,
|
|
161
207
|
children: renderContent ? /*#__PURE__*/_jsx(View, {
|
|
162
208
|
collapsable: false,
|
|
163
209
|
testID: testID,
|
|
164
|
-
style: [styles.container, containerStyle, contentContainerStyle
|
|
210
|
+
style: [styles.container, containerStyle, contentContainerStyle, isInline && restingTop > 0 ? {
|
|
211
|
+
top: restingTop
|
|
212
|
+
} : null],
|
|
165
213
|
children: children
|
|
166
214
|
}) : null
|
|
167
215
|
});
|