react-native-nitro-modal 0.1.2 → 0.1.4

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 CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ---
15
15
 
16
- `react-native-nitro-modal` renders your React content inside the platform's own modal primitives — `UISheetPresentationController` on iOS and Material `BottomSheetBehavior` on Android — so gestures, detent snapping, keyboard handling and transitions are handled natively, not re-implemented in JavaScript.
16
+ `react-native-nitro-modal` renders your React content inside native modal presentations — an edge-attached UIKit sheet on iOS and Material `BottomSheetBehavior` on Android — so gestures, detent snapping, keyboard handling and transitions are handled natively, not re-implemented in JavaScript.
17
17
 
18
18
  ## Features
19
19
 
@@ -242,8 +242,7 @@ import type {
242
242
 
243
243
  **iOS**
244
244
 
245
- - Bottom sheets use `UISheetPresentationController`.
246
- - On iOS 15, only the system `medium` and `large` detents exist; `small` and `fitContent` fall back to `medium`.
245
+ - Bottom sheets are a custom edge-attached presentation (native pan gesture, spring snapping and scroll-view hand-off) instead of `UISheetPresentationController`, which on iOS 26+ always floats partial-height sheets inset from the screen edges. All detents work on every supported iOS version.
247
246
  - UIKit has no public blur-radius API, so `backdropBlur` is approximated by blending a thin system material.
248
247
 
249
248
  **Android**
@@ -211,7 +211,9 @@ internal class BottomSheetPresenter(
211
211
  }
212
212
  }
213
213
 
214
- val areaHeight = sheetHeight - bottomInset -
214
+ val availableHeight =
215
+ if (SheetDetent.FITCONTENT in requested) maxContentHeight else sheetHeight - bottomInset
216
+ val areaHeight = availableHeight -
215
217
  if (config.keyboardBehavior == KeyboardBehavior.RESIZE) keyboardOverlap else 0
216
218
  val sheetWidth = minOf(coordinator.width, behavior.maxWidth.takeIf { it > 0 } ?: Int.MAX_VALUE)
217
219
  reportContentArea(sheetWidth - systemInsets.left - systemInsets.right, areaHeight)
@@ -312,11 +312,10 @@ extension HybridNitroModal: ModalPresentationDelegate {
312
312
  reconcile()
313
313
  }
314
314
 
315
- func modalPresentationDidDismissInteractively() {
316
- guard phase == .presented else { return }
317
- wantsOpen = false
318
- dismissReason = .swipe
319
- finishDismiss()
315
+ func modalPresentationDidRequestSwipeDismiss() {
316
+ guard config.dismissOnSwipe, phase == .presenting || phase == .presented else { return }
317
+ requestDismiss(.swipe)
318
+ reconcile()
320
319
  }
321
320
 
322
321
  func modalPresentationDidChangeDetent(_ index: Int) {
@@ -30,8 +30,8 @@ struct ModalConfig: Equatable {
30
30
  /// Callbacks from a presented modal controller back to `HybridNitroModal`.
31
31
  protocol ModalPresentationDelegate: AnyObject {
32
32
  func modalPresentationDidTapBackdrop()
33
- /// The user swiped the sheet away (the dismissal already happened).
34
- func modalPresentationDidDismissInteractively()
33
+ /// The user swiped the sheet down far enough to dismiss it.
34
+ func modalPresentationDidRequestSwipeDismiss()
35
35
  func modalPresentationDidChangeDetent(_ index: Int)
36
36
  func modalPresentationDidChangeContentArea(_ size: CGSize)
37
37
  }
@@ -5,30 +5,63 @@
5
5
 
6
6
  import UIKit
7
7
 
8
- /// `bottomSheet` mode: a page sheet driven by `UISheetPresentationController`.
9
- /// UIKit owns the pan gesture, detent snapping and swipe-to-dismiss, so all
10
- /// of it runs on the render server with no JS involvement.
11
- final class SheetViewController: UIViewController, ModalPresenting, UISheetPresentationControllerDelegate {
8
+ /// `bottomSheet` mode: an edge-attached sheet presented `.overFullScreen`.
9
+ ///
10
+ /// `UISheetPresentationController` is not used because iOS 26 always floats
11
+ /// sheets inset from the screen edges at partial detents, with no API to opt
12
+ /// out. Detents, dragging, swipe-to-dismiss and the hand-off to scroll views
13
+ /// inside the content are implemented here with a pan gesture instead.
14
+ final class SheetViewController: UIViewController, ModalPresenting, UIViewControllerTransitioningDelegate, UIGestureRecognizerDelegate {
12
15
  weak var presentationDelegate: ModalPresentationDelegate?
13
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
+
14
26
  private let contentView: NitroModalContentView
15
27
  private let backdrop = BackdropView()
28
+ private let sheetView = UIView()
29
+ private let grabber = UIView()
16
30
  private var config: ModalConfig
17
- private var detentIdentifiers: [UISheetPresentationController.Detent.Identifier] = []
18
- /// The latest `maximumDetentValue` UIKit handed to one of our detent resolvers.
19
- private var maximumDetentValue: CGFloat?
31
+ private var selectedIndex: Int
20
32
  private var keyboardHeight: CGFloat = 0
21
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
53
+ }
54
+
22
55
  init(contentView: NitroModalContentView, config: ModalConfig) {
23
56
  self.contentView = contentView
24
57
  self.config = config
58
+ selectedIndex = config.clampedInitialDetentIndex
25
59
  super.init(nibName: nil, bundle: nil)
26
- modalPresentationStyle = .pageSheet
60
+ modalPresentationStyle = .overFullScreen
61
+ transitioningDelegate = self
27
62
  backdrop.onTap = { [weak self] in
28
63
  self?.presentationDelegate?.modalPresentationDidTapBackdrop()
29
64
  }
30
- backdrop.configure(color: config.backdropColor, opacity: config.backdropOpacity, blurRadius: config.backdropBlurRadius)
31
- configureSheet(selectingDetentAt: config.clampedInitialDetentIndex)
32
65
 
33
66
  let center = NotificationCenter.default
34
67
  center.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
@@ -42,123 +75,122 @@ final class SheetViewController: UIViewController, ModalPresenting, UISheetPrese
42
75
 
43
76
  override func loadView() {
44
77
  let root = UIView()
45
- root.backgroundColor = config.contentBackgroundColor
46
- root.accessibilityViewIsModal = true
47
- contentView.frame = root.bounds
48
- contentView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
49
- root.addSubview(contentView)
50
- view = root
51
- }
78
+ root.backgroundColor = .clear
79
+ root.addSubview(backdrop)
52
80
 
53
- override func viewWillAppear(_ animated: Bool) {
54
- super.viewWillAppear(animated)
55
- installBackdrop()
56
- animateBackdrop(to: 1)
57
- }
81
+ sheetView.clipsToBounds = true
82
+ sheetView.layer.cornerCurve = .continuous
83
+ sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
84
+ sheetView.accessibilityViewIsModal = true
85
+ sheetView.addSubview(contentView)
58
86
 
59
- override func viewWillDisappear(_ animated: Bool) {
60
- super.viewWillDisappear(animated)
61
- if isBeingDismissed || presentingViewController?.isBeingDismissed == true {
62
- animateBackdrop(to: 0)
63
- }
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()
64
99
  }
65
100
 
66
101
  override func viewDidLayoutSubviews() {
67
102
  super.viewDidLayoutSubviews()
103
+ layoutSheet()
68
104
  reportContentArea()
69
105
  }
70
106
 
107
+ override func viewDidDisappear(_ animated: Bool) {
108
+ super.viewDidDisappear(animated)
109
+ unlockScroll()
110
+ }
111
+
112
+ override func accessibilityPerformEscape() -> Bool {
113
+ guard config.dismissOnSwipe else { return false }
114
+ presentationDelegate?.modalPresentationDidRequestSwipeDismiss()
115
+ return true
116
+ }
117
+
71
118
  // MARK: ModalPresenting
72
119
 
73
120
  func apply(_ newConfig: ModalConfig) {
74
- let selectedIndex = sheetPresentationController?.selectedDetentIdentifier
75
- .flatMap { detentIdentifiers.firstIndex(of: $0) }
76
121
  config = newConfig
77
- if isViewLoaded {
78
- view.backgroundColor = config.contentBackgroundColor
79
- }
80
- backdrop.configure(color: config.backdropColor, opacity: config.backdropOpacity, blurRadius: config.backdropBlurRadius)
81
- let index = min(selectedIndex ?? config.clampedInitialDetentIndex, max(config.detents.count - 1, 0))
82
- guard let sheet = sheetPresentationController else { return }
83
- sheet.animateChanges {
84
- configureSheet(selectingDetentAt: index)
85
- }
86
- reportContentArea()
122
+ selectedIndex = min(selectedIndex, max(detents.count - 1, 0))
123
+ guard isViewLoaded else { return }
124
+ applyAppearance()
125
+ animateToRest()
87
126
  }
88
127
 
89
128
  func contentSizeDidChange() {
90
- guard config.detents.contains(.fitcontent), let sheet = sheetPresentationController else { return }
91
- if #available(iOS 16.0, *) {
92
- sheet.animateChanges {
93
- sheet.invalidateDetents()
94
- }
95
- }
129
+ guard config.detents.contains(.fitcontent) else { return }
130
+ animateToRest()
96
131
  }
97
132
 
98
133
  func snapToDetent(at index: Int) {
99
- guard detentIdentifiers.indices.contains(index), let sheet = sheetPresentationController else { return }
100
- sheet.animateChanges {
101
- sheet.selectedDetentIdentifier = detentIdentifiers[index]
102
- }
103
- // UIKit only notifies the delegate for user-driven changes.
134
+ guard detents.indices.contains(index) else { return }
135
+ selectedIndex = index
136
+ animateToRest()
104
137
  presentationDelegate?.modalPresentationDidChangeDetent(index)
105
138
  }
106
139
 
107
- // MARK: UISheetPresentationControllerDelegate
140
+ // MARK: UIViewControllerTransitioningDelegate
108
141
 
109
- func sheetPresentationControllerDidChangeSelectedDetentIdentifier(_ sheet: UISheetPresentationController) {
110
- guard let identifier = sheet.selectedDetentIdentifier,
111
- let index = detentIdentifiers.firstIndex(of: identifier) else { return }
112
- presentationDelegate?.modalPresentationDidChangeDetent(index)
142
+ func animationController(
143
+ forPresented presented: UIViewController,
144
+ presenting: UIViewController,
145
+ source: UIViewController
146
+ ) -> UIViewControllerAnimatedTransitioning? {
147
+ SheetTransition(presenting: true)
113
148
  }
114
149
 
115
- func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
116
- presentationDelegate?.modalPresentationDidDismissInteractively()
150
+ func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
151
+ SheetTransition(presenting: false)
117
152
  }
118
153
 
119
- // MARK: Sheet configuration
154
+ /// Slides the sheet in or out; a swipe dismissal keeps its release velocity.
155
+ fileprivate func animateTransition(toOffscreen offscreen: Bool, animated: Bool, duration: TimeInterval, completion: @escaping () -> Void) {
156
+ let from = sheetView.frame.minY
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
+ }
120
175
 
121
- private func configureSheet(selectingDetentAt index: Int) {
122
- guard let sheet = sheetPresentationController else { return }
123
- sheet.delegate = self
124
- // Blocks swipe-to-dismiss while still allowing drags between detents.
125
- isModalInPresentation = !config.dismissOnSwipe
126
- sheet.prefersGrabberVisible = config.grabberVisible
127
- sheet.preferredCornerRadius = config.cornerRadius
128
- sheet.prefersScrollingExpandsWhenScrolledToEdge = true
129
- sheet.prefersEdgeAttachedInCompactHeight = true
176
+ // MARK: Layout
130
177
 
131
- let (detents, identifiers) = makeDetents()
132
- sheet.detents = detents
133
- detentIdentifiers = identifiers
134
- // `BackdropView` replaces the system dimming so color, opacity, blur and
135
- // tap-outside are configurable. Undimming up to `.large` turns it off.
136
- sheet.largestUndimmedDetentIdentifier = .large
137
- if identifiers.indices.contains(index) {
138
- sheet.selectedDetentIdentifier = identifiers[index]
139
- }
178
+ private var detents: [SheetDetent] {
179
+ config.detents.isEmpty ? [.fitcontent] : config.detents
140
180
  }
141
181
 
142
- private func makeDetents() -> ([UISheetPresentationController.Detent], [UISheetPresentationController.Detent.Identifier]) {
143
- let requested = config.detents.isEmpty ? [.fitcontent] : config.detents
144
- guard #available(iOS 16.0, *) else {
145
- // iOS 15 only has the two system detents, and `Detent.identifier` is iOS 16+.
146
- let identifiers: [UISheetPresentationController.Detent.Identifier] = requested.map { $0 == .large ? .large : .medium }
147
- return (identifiers.map { $0 == .large ? .large() : .medium() }, identifiers)
148
- }
149
- let detents = requested.enumerated().map { index, detent in
150
- UISheetPresentationController.Detent.custom(
151
- identifier: .init("nitro.\(index).\(detent.stringValue)")
152
- ) { [weak self] context in
153
- self?.resolve(detent, maximum: context.maximumDetentValue)
154
- }
155
- }
156
- return (detents, detents.map(\.identifier))
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)
157
190
  }
158
191
 
159
- /// Detent heights exclude the bottom safe area; UIKit adds it below.
160
- private func resolve(_ detent: SheetDetent, maximum: CGFloat) -> CGFloat {
161
- maximumDetentValue = maximum
192
+ private func height(of detent: SheetDetent) -> CGFloat {
193
+ let maximum = maximumDetentValue
162
194
  switch detent {
163
195
  case .small: return maximum * 0.25
164
196
  case .medium: return maximum * 0.5
@@ -167,43 +199,91 @@ final class SheetViewController: UIViewController, ModalPresenting, UISheetPrese
167
199
  }
168
200
  }
169
201
 
170
- // MARK: Backdrop
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
+ }
171
209
 
172
- private func installBackdrop() {
173
- guard let container = sheetPresentationController?.containerView, backdrop.superview !== container else { return }
174
- backdrop.frame = container.bounds
175
- backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight]
176
- backdrop.alpha = 0
177
- container.insertSubview(backdrop, at: 0)
210
+ private var detentTops: [CGFloat] {
211
+ detents.indices.map(restingTop(for:))
178
212
  }
179
213
 
180
- /// Rides the transition coordinator so the backdrop tracks interactive
181
- /// dismissals frame-by-frame and restores itself if the swipe is cancelled.
182
- private func animateBackdrop(to alpha: CGFloat) {
183
- guard let coordinator = transitionCoordinator else {
184
- backdrop.alpha = alpha
185
- return
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()
186
253
  }
187
- let initial = backdrop.alpha
188
- coordinator.animate(alongsideTransition: { [backdrop] _ in
189
- backdrop.alpha = alpha
190
- }, completion: { [backdrop] context in
191
- if context.isCancelled {
192
- backdrop.alpha = initial
193
- }
194
- })
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
+ )
195
275
  }
196
276
 
197
277
  // MARK: Content area
198
278
 
199
279
  private func reportContentArea() {
200
280
  guard isViewLoaded, view.bounds.width > 0 else { return }
201
- let maximum = maximumDetentValue ?? view.window.map(Self.estimatedMaximumDetentValue(in:)) ?? view.bounds.height
202
- var height = Self.contentHeight(for: config.detents, maximum: maximum)
281
+ var height = Self.contentHeight(for: config.detents, maximum: maximumDetentValue)
203
282
  if config.keyboardBehavior == .resize {
204
- height -= max(0, keyboardHeight - view.safeAreaInsets.bottom)
283
+ height -= keyboardLift
205
284
  }
206
- presentationDelegate?.modalPresentationDidChangeContentArea(CGSize(width: view.bounds.width, height: max(height, 0)))
285
+ let width = Self.width(for: view.bounds.width, sizeClass: traitCollection.horizontalSizeClass)
286
+ presentationDelegate?.modalPresentationDidChangeContentArea(CGSize(width: width, height: max(height, 0)))
207
287
  }
208
288
 
209
289
  /// Height the content may occupy at the largest of `detents`.
@@ -214,31 +294,255 @@ final class SheetViewController: UIViewController, ModalPresenting, UISheetPrese
214
294
  return detents.contains(.medium) ? maximum * 0.5 : maximum * 0.25
215
295
  }
216
296
 
217
- /// Best guess of UIKit's `maximumDetentValue` before the sheet is on screen.
297
+ /// Best guess of `maximumDetentValue` before the sheet is on screen.
218
298
  static func estimatedMaximumDetentValue(in window: UIWindow) -> CGFloat {
219
299
  let insets = window.safeAreaInsets
220
- return window.bounds.height - insets.top - insets.bottom - 10
300
+ return window.bounds.height - insets.top - insets.bottom - topGap
221
301
  }
222
302
 
223
303
  /// Best guess of the sheet width before it is on screen.
224
304
  static func estimatedWidth(in window: UIWindow) -> CGFloat {
225
- window.traitCollection.horizontalSizeClass == .regular ? min(window.bounds.width, 704) : window.bounds.width
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)
226
474
  }
227
475
 
228
476
  // MARK: Keyboard
229
477
 
230
- // UIKit already lifts sheets above the keyboard; we only track the height
231
- // so `resize` can shrink the area reported to the content.
232
478
  @objc private func keyboardWillChangeFrame(_ notification: Notification) {
233
479
  guard isViewLoaded, let window = view.window,
234
480
  let frame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }
235
481
  let keyboard = window.convert(frame, from: window.screen.coordinateSpace)
236
- keyboardHeight = max(0, window.bounds.maxY - keyboard.minY)
237
- reportContentArea()
482
+ updateKeyboardHeight(max(0, window.bounds.maxY - keyboard.minY), notification: notification)
238
483
  }
239
484
 
240
485
  @objc private func keyboardWillHide(_ notification: Notification) {
241
- keyboardHeight = 0
242
- reportContentArea()
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
+ }
497
+ }
498
+ }
499
+
500
+ /// Slide transition for `SheetViewController`.
501
+ private final class SheetTransition: NSObject, UIViewControllerAnimatedTransitioning {
502
+ private let presenting: Bool
503
+
504
+ init(presenting: Bool) {
505
+ self.presenting = presenting
506
+ }
507
+
508
+ func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
509
+ presenting ? 0.5 : 0.35
510
+ }
511
+
512
+ func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
513
+ let key: UITransitionContextViewControllerKey = presenting ? .to : .from
514
+ guard let sheet = transitionContext.viewController(forKey: key) as? SheetViewController else {
515
+ transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
516
+ return
517
+ }
518
+
519
+ if presenting {
520
+ sheet.view.frame = transitionContext.finalFrame(for: sheet)
521
+ transitionContext.containerView.addSubview(sheet.view)
522
+ sheet.view.layoutIfNeeded()
523
+ }
524
+
525
+ sheet.animateTransition(
526
+ toOffscreen: !presenting,
527
+ animated: transitionContext.isAnimated,
528
+ duration: transitionDuration(using: transitionContext)
529
+ ) {
530
+ transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
531
+ }
532
+ }
533
+ }
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
243
547
  }
244
548
  }
@@ -1,7 +1,7 @@
1
1
  import type { HybridView, HybridViewMethods, HybridViewProps } from 'react-native-nitro-modules';
2
2
  /**
3
3
  * How the modal is presented.
4
- * - `bottomSheet`: native sheet (iOS `UISheetPresentationController`,
4
+ * - `bottomSheet`: edge-attached native sheet (iOS custom presentation,
5
5
  * Android `BottomSheetBehavior`) with detents and swipe-to-dismiss.
6
6
  * - `popup`: centered dialog with a native fade/scale transition.
7
7
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-nitro-modal",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "react native native modal",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -6,7 +6,7 @@ import type {
6
6
 
7
7
  /**
8
8
  * How the modal is presented.
9
- * - `bottomSheet`: native sheet (iOS `UISheetPresentationController`,
9
+ * - `bottomSheet`: edge-attached native sheet (iOS custom presentation,
10
10
  * Android `BottomSheetBehavior`) with detents and swipe-to-dismiss.
11
11
  * - `popup`: centered dialog with a native fade/scale transition.
12
12
  */