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 +2 -3
- package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +3 -1
- package/ios/HybridNitroModal.swift +4 -5
- package/ios/ModalConfig.swift +2 -2
- package/ios/SheetViewController.swift +432 -128
- package/lib/typescript/src/NitroModal.nitro.d.ts +1 -1
- package/package.json +1 -1
- package/src/NitroModal.nitro.ts +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
`react-native-nitro-modal` renders your React content inside
|
|
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
|
|
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
|
|
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
|
|
316
|
-
guard phase == .presented else { return }
|
|
317
|
-
|
|
318
|
-
|
|
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) {
|
package/ios/ModalConfig.swift
CHANGED
|
@@ -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
|
|
34
|
-
func
|
|
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:
|
|
9
|
-
///
|
|
10
|
-
///
|
|
11
|
-
|
|
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
|
|
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 = .
|
|
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 =
|
|
46
|
-
root.
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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)
|
|
91
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
140
|
+
// MARK: UIViewControllerTransitioningDelegate
|
|
108
141
|
|
|
109
|
-
func
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
116
|
-
|
|
150
|
+
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
|
151
|
+
SheetTransition(presenting: false)
|
|
117
152
|
}
|
|
118
153
|
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
173
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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 -=
|
|
283
|
+
height -= keyboardLift
|
|
205
284
|
}
|
|
206
|
-
|
|
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
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
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
package/src/NitroModal.nitro.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* How the modal is presented.
|
|
9
|
-
* - `bottomSheet`: native sheet (iOS
|
|
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
|
*/
|