react-native-nitro-modal 0.2.0 → 0.3.0-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 +48 -4
- package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +335 -34
- package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +63 -22
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +31 -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 +111 -20
- package/ios/ModalConfig.swift +44 -2
- package/ios/NitroModalHostView.swift +73 -8
- package/ios/SheetView.swift +575 -0
- package/ios/SheetViewController.swift +19 -450
- package/lib/module/NitroModal.js +38 -5
- package/lib/module/NitroModal.js.map +1 -1
- package/lib/module/NitroModalView.web.js +55 -25
- 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 +8 -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 +54 -29
- package/lib/module/web/SheetPresenter.js.map +1 -1
- package/lib/module/web/dom.js +13 -0
- package/lib/module/web/dom.js.map +1 -1
- package/lib/typescript/src/NitroModal.d.ts +33 -2
- package/lib/typescript/src/NitroModal.d.ts.map +1 -1
- package/lib/typescript/src/NitroModal.nitro.d.ts +29 -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 +6 -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 +9 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/SheetPresenter.d.ts +1 -1
- package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/dom.d.ts +2 -0
- package/lib/typescript/src/web/dom.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +60 -6
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +10 -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 +20 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +40 -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 +53 -8
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
- package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +32 -5
- package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +24 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +4 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +110 -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 +8 -0
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +14 -5
- package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +9 -1
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +15 -2
- package/nitrogen/generated/shared/json/NitroModalConfig.json +4 -0
- package/package.json +1 -1
- package/src/NitroModal.nitro.ts +30 -2
- package/src/NitroModal.tsx +68 -4
- package/src/NitroModalView.web.tsx +54 -12
- package/src/index.tsx +2 -0
- package/src/web/AnimatedValue.ts +4 -0
- package/src/web/ModalConfig.ts +13 -3
- package/src/web/ModalController.ts +35 -2
- package/src/web/ModalPresenter.ts +46 -11
- package/src/web/SheetPresenter.ts +98 -33
- package/src/web/dom.ts +17 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
//
|
|
2
|
+
// SheetView.swift
|
|
3
|
+
// NitroModal
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import UIKit
|
|
7
|
+
|
|
8
|
+
/// The `bottomSheet` surface: detents, dragging, swipe-to-dismiss, the
|
|
9
|
+
/// hand-off to scroll views inside the content, pull-to-refresh and keyboard
|
|
10
|
+
/// tracking, implemented with a pan gesture.
|
|
11
|
+
///
|
|
12
|
+
/// A modal sheet fills the screen as the view of `SheetViewController`. An
|
|
13
|
+
/// inline sheet fills `NitroModalHostView`, in the screen's own layout: it has
|
|
14
|
+
/// no backdrop, lets touches outside the sheet reach the views behind it,
|
|
15
|
+
/// ignores the keyboard and can't be dismissed by the user.
|
|
16
|
+
final class SheetView: UIView, UIGestureRecognizerDelegate {
|
|
17
|
+
weak var delegate: ModalPresentationDelegate?
|
|
18
|
+
/// Whether a modal present/dismiss transition is running; the sheet can't be
|
|
19
|
+
/// dragged meanwhile.
|
|
20
|
+
var isTransitioning: () -> Bool = { false }
|
|
21
|
+
|
|
22
|
+
/// Gap kept above a modal sheet at its tallest, like a system page sheet.
|
|
23
|
+
private static let topGap: CGFloat = 10
|
|
24
|
+
/// Extra sheet height below the screen so it stays attached to the bottom
|
|
25
|
+
/// edge while rubber-banding upward.
|
|
26
|
+
private static let overscroll: CGFloat = 400
|
|
27
|
+
private static let defaultCornerRadius: CGFloat = 16
|
|
28
|
+
private static let regularMaxWidth: CGFloat = 704
|
|
29
|
+
private static let grabberSize = CGSize(width: 36, height: 5)
|
|
30
|
+
/// How far below its lowest detent the sheet must be pulled to refresh.
|
|
31
|
+
private static let refreshThreshold: CGFloat = 56
|
|
32
|
+
private static let touchHandlerClass: AnyClass? = NSClassFromString("RCTSurfaceTouchHandler")
|
|
33
|
+
|
|
34
|
+
let isInline: Bool
|
|
35
|
+
private let contentView: NitroModalContentView
|
|
36
|
+
private let backdrop = BackdropView()
|
|
37
|
+
private let sheetView = UIView()
|
|
38
|
+
private let grabber = UIView()
|
|
39
|
+
private var config: ModalConfig
|
|
40
|
+
private var selectedIndex: Int
|
|
41
|
+
private var keyboardHeight: CGFloat = 0
|
|
42
|
+
|
|
43
|
+
/// The sheet sits below the bottom edge (before presenting / while dismissing).
|
|
44
|
+
private var isOffscreen = true
|
|
45
|
+
/// Top of the sheet while the user drags it; `nil` rests on `selectedIndex`.
|
|
46
|
+
private var dragTop: CGFloat?
|
|
47
|
+
private var drag: DragState?
|
|
48
|
+
/// Vertical velocity of the swipe that requested the dismissal.
|
|
49
|
+
private var releaseVelocity: CGFloat = 0
|
|
50
|
+
private var reportedRestingTop: CGFloat?
|
|
51
|
+
private var refreshFeedback: UIImpactFeedbackGenerator?
|
|
52
|
+
|
|
53
|
+
private var lockedScrollView: UIScrollView?
|
|
54
|
+
private var lockedOffsetY: CGFloat = 0
|
|
55
|
+
private var scrollLock: NSKeyValueObservation?
|
|
56
|
+
|
|
57
|
+
private struct DragState {
|
|
58
|
+
/// Unconstrained sheet top, before rubber-banding.
|
|
59
|
+
var rawTop: CGFloat
|
|
60
|
+
var lastTranslation: CGFloat = 0
|
|
61
|
+
var scrollView: UIScrollView?
|
|
62
|
+
/// Whether the last movement moved the sheet rather than the scroll view.
|
|
63
|
+
var sheetDrove = false
|
|
64
|
+
/// The scroll view moved during this gesture. It then keeps the downward
|
|
65
|
+
/// part too: scrolled content goes back to its top, and only a new
|
|
66
|
+
/// gesture moves the sheet.
|
|
67
|
+
var contentScrolled = false
|
|
68
|
+
/// Started with the sheet resting on its lowest detent, so pulling it
|
|
69
|
+
/// further down refreshes.
|
|
70
|
+
var canRefresh = false
|
|
71
|
+
/// Pulled far enough that letting go refreshes.
|
|
72
|
+
var refreshArmed = false
|
|
73
|
+
var cancelledReactTouches = false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
init(contentView: NitroModalContentView, config: ModalConfig, isInline: Bool) {
|
|
77
|
+
self.contentView = contentView
|
|
78
|
+
self.config = config
|
|
79
|
+
self.isInline = isInline
|
|
80
|
+
selectedIndex = config.clampedInitialDetentIndex
|
|
81
|
+
super.init(frame: .zero)
|
|
82
|
+
backgroundColor = .clear
|
|
83
|
+
|
|
84
|
+
if isInline {
|
|
85
|
+
// The sheet travels below the host's bottom edge; nothing may show there.
|
|
86
|
+
clipsToBounds = true
|
|
87
|
+
} else {
|
|
88
|
+
backdrop.onTap = { [weak self] in
|
|
89
|
+
self?.delegate?.modalPresentationDidTapBackdrop()
|
|
90
|
+
}
|
|
91
|
+
addSubview(backdrop)
|
|
92
|
+
let center = NotificationCenter.default
|
|
93
|
+
center.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
|
|
94
|
+
center.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
sheetView.clipsToBounds = true
|
|
98
|
+
sheetView.layer.cornerCurve = .continuous
|
|
99
|
+
sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
100
|
+
sheetView.accessibilityViewIsModal = !isInline
|
|
101
|
+
sheetView.addSubview(contentView)
|
|
102
|
+
|
|
103
|
+
grabber.isUserInteractionEnabled = false
|
|
104
|
+
grabber.backgroundColor = .tertiaryLabel
|
|
105
|
+
grabber.layer.cornerRadius = Self.grabberSize.height / 2
|
|
106
|
+
sheetView.addSubview(grabber)
|
|
107
|
+
addSubview(sheetView)
|
|
108
|
+
|
|
109
|
+
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
|
110
|
+
pan.delegate = self
|
|
111
|
+
sheetView.addGestureRecognizer(pan)
|
|
112
|
+
|
|
113
|
+
applyAppearance()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@available(*, unavailable)
|
|
117
|
+
required init?(coder: NSCoder) {
|
|
118
|
+
fatalError("init(coder:) is not supported")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
override func layoutSubviews() {
|
|
122
|
+
super.layoutSubviews()
|
|
123
|
+
layoutSheet()
|
|
124
|
+
reportContentArea()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
override func safeAreaInsetsDidChange() {
|
|
128
|
+
super.safeAreaInsetsDidChange()
|
|
129
|
+
setNeedsLayout()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
133
|
+
let hit = super.hitTest(point, with: event)
|
|
134
|
+
// Inline, only the sheet takes touches; the rest reach the screen behind.
|
|
135
|
+
return isInline && hit === self ? nil : hit
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// MARK: Presentation
|
|
139
|
+
|
|
140
|
+
func apply(_ newConfig: ModalConfig) {
|
|
141
|
+
config = newConfig
|
|
142
|
+
selectedIndex = min(selectedIndex, max(detents.count - 1, 0))
|
|
143
|
+
applyAppearance()
|
|
144
|
+
animateToRest()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func contentSizeDidChange() {
|
|
148
|
+
guard config.detents.contains(.fitContent) else { return }
|
|
149
|
+
animateToRest()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func snapToDetent(at index: Int) {
|
|
153
|
+
guard detents.indices.contains(index) else { return }
|
|
154
|
+
selectedIndex = index
|
|
155
|
+
animateToRest()
|
|
156
|
+
delegate?.modalPresentationDidChangeDetent(index)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// Escape gesture (VoiceOver), modal sheets only.
|
|
160
|
+
func performEscape() -> Bool {
|
|
161
|
+
guard !isInline, config.dismissOnSwipe else { return false }
|
|
162
|
+
delegate?.modalPresentationDidRequestSwipeDismiss()
|
|
163
|
+
return true
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Slides the sheet in or out; a swipe dismissal keeps its release velocity.
|
|
167
|
+
func animateTransition(toOffscreen offscreen: Bool, animated: Bool, duration: TimeInterval, completion: @escaping () -> Void) {
|
|
168
|
+
let from = sheetView.frame.minY
|
|
169
|
+
let velocity = offscreen ? releaseVelocity : 0
|
|
170
|
+
releaseVelocity = 0
|
|
171
|
+
isOffscreen = offscreen
|
|
172
|
+
dragTop = nil
|
|
173
|
+
guard animated else {
|
|
174
|
+
layoutSheet()
|
|
175
|
+
completion()
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
animate(
|
|
179
|
+
distance: currentTop - from,
|
|
180
|
+
velocity: velocity,
|
|
181
|
+
damping: offscreen ? 1 : 0.88,
|
|
182
|
+
duration: duration,
|
|
183
|
+
animations: { self.layoutSheet() },
|
|
184
|
+
completion: completion
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Releases the scroll view pinned by a drag.
|
|
189
|
+
func unlockScroll() {
|
|
190
|
+
guard let scrollView = lockedScrollView else { return }
|
|
191
|
+
scrollLock?.invalidate()
|
|
192
|
+
scrollLock = nil
|
|
193
|
+
lockedScrollView = nil
|
|
194
|
+
// Stops any deceleration left over from a fling that moved the sheet.
|
|
195
|
+
scrollView.setContentOffset(scrollView.contentOffset, animated: false)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// MARK: Layout
|
|
199
|
+
|
|
200
|
+
private var detents: [Detent] {
|
|
201
|
+
config.detents.isEmpty ? [.fitContent] : config.detents
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Tallest height the content may occupy (excludes the bottom safe area).
|
|
205
|
+
private var maximumDetentValue: CGFloat {
|
|
206
|
+
Self.maximumDetentValue(height: bounds.height, insets: safeAreaInsets, isInline: isInline)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private var keyboardLift: CGFloat {
|
|
210
|
+
isInline || config.keyboardBehavior == .none ? 0 : max(0, keyboardHeight - safeAreaInsets.bottom)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private var sheetWidth: CGFloat {
|
|
214
|
+
isInline ? bounds.width : Self.width(for: bounds.width, sizeClass: traitCollection.horizontalSizeClass)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/// Sheet top when resting on `detents[index]`. The bottom safe area is
|
|
218
|
+
/// added below the content.
|
|
219
|
+
private func restingTop(for index: Int) -> CGFloat {
|
|
220
|
+
let detent = detents[min(max(index, 0), detents.count - 1)]
|
|
221
|
+
let height = detent.height(maximum: maximumDetentValue, contentHeight: contentView.contentSize.height)
|
|
222
|
+
let top = bounds.height - safeAreaInsets.bottom - keyboardLift - height
|
|
223
|
+
return max(top, safeAreaInsets.top + (isInline ? 0 : Self.topGap))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private var detentTops: [CGFloat] {
|
|
227
|
+
detents.indices.map(restingTop(for:))
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private var currentTop: CGFloat {
|
|
231
|
+
if isOffscreen { return bounds.height }
|
|
232
|
+
return dragTop ?? restingTop(for: selectedIndex)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private func layoutSheet() {
|
|
236
|
+
let width = sheetWidth
|
|
237
|
+
let top = currentTop
|
|
238
|
+
backdrop.frame = bounds
|
|
239
|
+
sheetView.frame = CGRect(x: (bounds.width - width) / 2, y: top, width: width, height: bounds.height + Self.overscroll)
|
|
240
|
+
contentView.frame = CGRect(x: 0, y: 0, width: width, height: maximumDetentValue)
|
|
241
|
+
grabber.frame = CGRect(
|
|
242
|
+
origin: CGPoint(x: (width - Self.grabberSize.width) / 2, y: 5),
|
|
243
|
+
size: Self.grabberSize
|
|
244
|
+
)
|
|
245
|
+
backdrop.alpha = backdropAlpha(forTop: top)
|
|
246
|
+
reportRestingTop()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// Fully dimmed at every detent; fades out below the lowest one.
|
|
250
|
+
private func backdropAlpha(forTop top: CGFloat) -> CGFloat {
|
|
251
|
+
let bottom = bounds.height
|
|
252
|
+
guard let lowest = detentTops.max(), bottom > lowest else { return top >= bottom ? 0 : 1 }
|
|
253
|
+
return min(max((bottom - top) / (bottom - lowest), 0), 1)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private func applyAppearance() {
|
|
257
|
+
backdrop.isHidden = isInline
|
|
258
|
+
backdrop.configure(color: config.backdropColor, opacity: config.backdropOpacity, blurRadius: config.backdropBlurRadius)
|
|
259
|
+
sheetView.backgroundColor = config.contentBackgroundColor
|
|
260
|
+
sheetView.layer.cornerRadius = config.cornerRadius ?? Self.defaultCornerRadius
|
|
261
|
+
grabber.isHidden = !config.grabberVisible
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private func animateToRest() {
|
|
265
|
+
guard drag == nil, !isOffscreen else { return }
|
|
266
|
+
dragTop = nil
|
|
267
|
+
UIView.animate(withDuration: 0.3, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction]) {
|
|
268
|
+
self.setNeedsLayout()
|
|
269
|
+
self.layoutIfNeeded()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private func animate(
|
|
274
|
+
distance: CGFloat,
|
|
275
|
+
velocity: CGFloat,
|
|
276
|
+
damping: CGFloat = 0.9,
|
|
277
|
+
duration: TimeInterval = 0.45,
|
|
278
|
+
animations: @escaping () -> Void,
|
|
279
|
+
completion: (() -> Void)? = nil
|
|
280
|
+
) {
|
|
281
|
+
// UIKit expects the initial velocity relative to the distance travelled.
|
|
282
|
+
let relativeVelocity = abs(distance) > 1 ? min(max(velocity / distance, -30), 30) : 0
|
|
283
|
+
UIView.animate(
|
|
284
|
+
withDuration: duration,
|
|
285
|
+
delay: 0,
|
|
286
|
+
usingSpringWithDamping: damping,
|
|
287
|
+
initialSpringVelocity: relativeVelocity,
|
|
288
|
+
options: [.allowUserInteraction, .beginFromCurrentState],
|
|
289
|
+
animations: animations,
|
|
290
|
+
completion: { _ in completion?() }
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// MARK: Content area
|
|
295
|
+
|
|
296
|
+
private func reportContentArea() {
|
|
297
|
+
guard bounds.width > 0 else { return }
|
|
298
|
+
var height = Self.contentHeight(for: config.detents, maximum: maximumDetentValue)
|
|
299
|
+
if config.keyboardBehavior == .resize {
|
|
300
|
+
height -= keyboardLift
|
|
301
|
+
}
|
|
302
|
+
delegate?.modalPresentationDidChangeContentArea(CGSize(width: sheetWidth, height: max(height, 0)))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/// Inline: React lays the content out at the sheet's resting top, so what
|
|
306
|
+
/// `measure()` reports (and presses rely on) matches the screen. Reported
|
|
307
|
+
/// as soon as the target detent is known, before the sheet settles.
|
|
308
|
+
private func reportRestingTop() {
|
|
309
|
+
guard isInline, bounds.height > 0 else { return }
|
|
310
|
+
let top = restingTop(for: selectedIndex).rounded()
|
|
311
|
+
guard top != reportedRestingTop else { return }
|
|
312
|
+
reportedRestingTop = top
|
|
313
|
+
delegate?.modalPresentationDidChangeRestingTop(top)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/// Height the content may occupy at the largest of `detents`.
|
|
317
|
+
static func contentHeight(for detents: [Detent], maximum: CGFloat) -> CGFloat {
|
|
318
|
+
guard !detents.isEmpty else { return maximum }
|
|
319
|
+
// `fitContent` content can grow up to the maximum.
|
|
320
|
+
return detents.map { $0.height(maximum: maximum, contentHeight: maximum) }.max() ?? maximum
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// Tallest height the content may occupy in an area `height` tall.
|
|
324
|
+
static func maximumDetentValue(height: CGFloat, insets: UIEdgeInsets, isInline: Bool) -> CGFloat {
|
|
325
|
+
max(0, height - insets.top - insets.bottom - (isInline ? 0 : topGap))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/// Best guess of the sheet width before a modal sheet is on screen.
|
|
329
|
+
static func estimatedWidth(in window: UIWindow) -> CGFloat {
|
|
330
|
+
width(for: window.bounds.width, sizeClass: window.traitCollection.horizontalSizeClass)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private static func width(for available: CGFloat, sizeClass: UIUserInterfaceSizeClass) -> CGFloat {
|
|
334
|
+
sizeClass == .regular ? min(available, regularMaxWidth) : available
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// MARK: Dragging
|
|
338
|
+
|
|
339
|
+
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
340
|
+
guard let pan = gestureRecognizer as? UIPanGestureRecognizer, pan.view === sheetView else {
|
|
341
|
+
return super.gestureRecognizerShouldBegin(gestureRecognizer)
|
|
342
|
+
}
|
|
343
|
+
guard !isTransitioning(), !isOffscreen else { return false }
|
|
344
|
+
let velocity = pan.velocity(in: self)
|
|
345
|
+
return abs(velocity.y) > abs(velocity.x)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// Scroll views inside the content keep scrolling; `handlePan` decides
|
|
349
|
+
/// frame by frame whether the sheet or the scroll view moves.
|
|
350
|
+
func gestureRecognizer(
|
|
351
|
+
_ gestureRecognizer: UIGestureRecognizer,
|
|
352
|
+
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
|
|
353
|
+
) -> Bool {
|
|
354
|
+
guard let scrollView = other.view as? UIScrollView else { return false }
|
|
355
|
+
return other === scrollView.panGestureRecognizer && scrollView.isDescendant(of: sheetView)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
@objc private func handlePan(_ pan: UIPanGestureRecognizer) {
|
|
359
|
+
switch pan.state {
|
|
360
|
+
case .began:
|
|
361
|
+
// Grab the sheet where it is, even mid-animation.
|
|
362
|
+
let isSettled = sheetView.layer.animationKeys()?.isEmpty ?? true
|
|
363
|
+
let top = sheetView.layer.presentation()?.frame.minY ?? sheetView.frame.minY
|
|
364
|
+
sheetView.layer.removeAllAnimations()
|
|
365
|
+
backdrop.layer.removeAllAnimations()
|
|
366
|
+
let translation = pan.translation(in: sheetView)
|
|
367
|
+
let location = pan.location(in: sheetView)
|
|
368
|
+
let start = CGPoint(x: location.x - translation.x, y: location.y - translation.y)
|
|
369
|
+
var state = DragState(rawTop: top)
|
|
370
|
+
state.lastTranslation = pan.translation(in: self).y
|
|
371
|
+
state.scrollView = verticalScrollView(at: start)
|
|
372
|
+
if let scrollView = state.scrollView {
|
|
373
|
+
lockedOffsetY = scrollView.contentOffset.y
|
|
374
|
+
}
|
|
375
|
+
if config.pullToRefreshEnabled, !config.dismissOnSwipe, isSettled,
|
|
376
|
+
let lowest = detentTops.max(), abs(top - lowest) < 1 {
|
|
377
|
+
state.canRefresh = true
|
|
378
|
+
refreshFeedback = UIImpactFeedbackGenerator(style: .medium)
|
|
379
|
+
refreshFeedback?.prepare()
|
|
380
|
+
}
|
|
381
|
+
drag = state
|
|
382
|
+
dragTop = top
|
|
383
|
+
layoutSheet()
|
|
384
|
+
|
|
385
|
+
case .changed:
|
|
386
|
+
guard var state = drag, let minTop = detentTops.min() else { return }
|
|
387
|
+
let translation = pan.translation(in: self).y
|
|
388
|
+
let delta = translation - state.lastTranslation
|
|
389
|
+
state.lastTranslation = translation
|
|
390
|
+
|
|
391
|
+
if let scrollView = state.scrollView {
|
|
392
|
+
// Up: expand the sheet before scrolling. Down: scroll back to the top
|
|
393
|
+
// before collapsing the sheet, in a separate gesture.
|
|
394
|
+
let drive = delta < 0
|
|
395
|
+
? state.rawTop > minTop + 0.5
|
|
396
|
+
: !state.contentScrolled && scrollView.isScrolledToTop
|
|
397
|
+
if drive {
|
|
398
|
+
state.rawTop = max(state.rawTop + delta, minTop)
|
|
399
|
+
lockScroll(scrollView, at: delta < 0 ? lockedOffsetY : scrollView.topOffsetY)
|
|
400
|
+
} else {
|
|
401
|
+
unlockScroll()
|
|
402
|
+
lockedOffsetY = scrollView.contentOffset.y
|
|
403
|
+
if delta != 0 {
|
|
404
|
+
state.contentScrolled = true
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
state.sheetDrove = drive
|
|
408
|
+
} else {
|
|
409
|
+
state.rawTop += delta
|
|
410
|
+
state.sheetDrove = true
|
|
411
|
+
}
|
|
412
|
+
let top = constrained(state.rawTop)
|
|
413
|
+
if state.sheetDrove, isInline, !state.cancelledReactTouches {
|
|
414
|
+
state.cancelledReactTouches = true
|
|
415
|
+
cancelReactTouches()
|
|
416
|
+
}
|
|
417
|
+
if state.canRefresh, let lowest = detentTops.max() {
|
|
418
|
+
let armed = top - lowest >= Self.refreshThreshold
|
|
419
|
+
if armed, !state.refreshArmed {
|
|
420
|
+
refreshFeedback?.impactOccurred()
|
|
421
|
+
}
|
|
422
|
+
state.refreshArmed = armed
|
|
423
|
+
}
|
|
424
|
+
drag = state
|
|
425
|
+
dragTop = top
|
|
426
|
+
layoutSheet()
|
|
427
|
+
|
|
428
|
+
case .ended, .cancelled, .failed:
|
|
429
|
+
guard let state = drag else { return }
|
|
430
|
+
drag = nil
|
|
431
|
+
refreshFeedback = nil
|
|
432
|
+
let velocity = state.sheetDrove ? pan.velocity(in: self).y : 0
|
|
433
|
+
endDrag(at: dragTop ?? currentTop, velocity: velocity, holdingScroll: state.sheetDrove)
|
|
434
|
+
if state.refreshArmed, pan.state == .ended {
|
|
435
|
+
delegate?.modalPresentationDidPullToRefresh()
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
default:
|
|
439
|
+
break
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private func endDrag(at top: CGFloat, velocity: CGFloat, holdingScroll: Bool) {
|
|
444
|
+
if !holdingScroll {
|
|
445
|
+
unlockScroll()
|
|
446
|
+
}
|
|
447
|
+
let tops = detentTops
|
|
448
|
+
guard let lowest = tops.max() else { return }
|
|
449
|
+
// Where a fling of this velocity would come to rest.
|
|
450
|
+
let projected = top + velocity * 0.2
|
|
451
|
+
|
|
452
|
+
if config.dismissOnSwipe, velocity >= 0, projected > lowest + (bounds.height - lowest) / 2 {
|
|
453
|
+
releaseVelocity = velocity
|
|
454
|
+
delegate?.modalPresentationDidRequestSwipeDismiss()
|
|
455
|
+
if isOffscreen || isTransitioning() {
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
releaseVelocity = 0
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let index = tops.indices.min { abs(tops[$0] - projected) < abs(tops[$1] - projected) } ?? selectedIndex
|
|
462
|
+
let changed = index != selectedIndex
|
|
463
|
+
selectedIndex = index
|
|
464
|
+
dragTop = nil
|
|
465
|
+
if changed {
|
|
466
|
+
delegate?.modalPresentationDidChangeDetent(index)
|
|
467
|
+
}
|
|
468
|
+
animate(distance: restingTop(for: index) - top, velocity: velocity, animations: { self.layoutSheet() }) { [weak self] in
|
|
469
|
+
guard let self, self.drag == nil else { return }
|
|
470
|
+
self.unlockScroll()
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/// Rubber-bands above the tallest detent, and below the lowest one when
|
|
475
|
+
/// swiping cannot dismiss.
|
|
476
|
+
private func constrained(_ top: CGFloat) -> CGFloat {
|
|
477
|
+
let tops = detentTops
|
|
478
|
+
guard let minTop = tops.min(), let maxTop = tops.max() else { return top }
|
|
479
|
+
let dimension = max(bounds.height, 1)
|
|
480
|
+
if top < minTop {
|
|
481
|
+
return minTop - Self.rubberBand(minTop - top, dimension: dimension)
|
|
482
|
+
}
|
|
483
|
+
if !config.dismissOnSwipe, top > maxTop {
|
|
484
|
+
return maxTop + Self.rubberBand(top - maxTop, dimension: dimension)
|
|
485
|
+
}
|
|
486
|
+
return top
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private static func rubberBand(_ offset: CGFloat, dimension: CGFloat) -> CGFloat {
|
|
490
|
+
(1 - 1 / (offset * 0.55 / dimension + 1)) * dimension
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/// The innermost vertically scrollable view under `point` (in `sheetView`).
|
|
494
|
+
private func verticalScrollView(at point: CGPoint) -> UIScrollView? {
|
|
495
|
+
var candidate = sheetView.hitTest(point, with: nil)
|
|
496
|
+
while let current = candidate, current !== sheetView {
|
|
497
|
+
if let scrollView = current as? UIScrollView, scrollView.isScrollEnabled, scrollView.canScrollVertically {
|
|
498
|
+
return scrollView
|
|
499
|
+
}
|
|
500
|
+
candidate = current.superview
|
|
501
|
+
}
|
|
502
|
+
return nil
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/// Pins the scroll view's offset while the sheet moves, so content does not
|
|
506
|
+
/// scroll (or bounce) at the same time.
|
|
507
|
+
private func lockScroll(_ scrollView: UIScrollView, at offsetY: CGFloat) {
|
|
508
|
+
if lockedScrollView !== scrollView {
|
|
509
|
+
unlockScroll()
|
|
510
|
+
lockedScrollView = scrollView
|
|
511
|
+
}
|
|
512
|
+
lockedOffsetY = offsetY
|
|
513
|
+
if scrollView.contentOffset.y != offsetY {
|
|
514
|
+
scrollView.contentOffset.y = offsetY
|
|
515
|
+
}
|
|
516
|
+
guard scrollLock == nil else { return }
|
|
517
|
+
scrollLock = scrollView.observe(\.contentOffset) { [weak self] scrollView, _ in
|
|
518
|
+
guard let self, scrollView.contentOffset.y != self.lockedOffsetY else { return }
|
|
519
|
+
scrollView.contentOffset.y = self.lockedOffsetY
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/// An inline sheet lives inside the React root view, whose touch handler
|
|
524
|
+
/// only yields to gestures outside of it. Cancel its touches when the sheet
|
|
525
|
+
/// starts moving, so the press under the finger doesn't fire on release.
|
|
526
|
+
private func cancelReactTouches() {
|
|
527
|
+
guard let handlerClass = Self.touchHandlerClass else { return }
|
|
528
|
+
var current = superview
|
|
529
|
+
while let view = current {
|
|
530
|
+
for recognizer in view.gestureRecognizers ?? [] where recognizer.isEnabled && recognizer.isKind(of: handlerClass) {
|
|
531
|
+
recognizer.isEnabled = false
|
|
532
|
+
recognizer.isEnabled = true
|
|
533
|
+
}
|
|
534
|
+
current = view.superview
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// MARK: Keyboard (modal only)
|
|
539
|
+
|
|
540
|
+
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
|
|
541
|
+
guard let window,
|
|
542
|
+
let frame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }
|
|
543
|
+
let keyboard = window.convert(frame, from: window.screen.coordinateSpace)
|
|
544
|
+
updateKeyboardHeight(max(0, window.bounds.maxY - keyboard.minY), notification: notification)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
@objc private func keyboardWillHide(_ notification: Notification) {
|
|
548
|
+
updateKeyboardHeight(0, notification: notification)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private func updateKeyboardHeight(_ height: CGFloat, notification: Notification) {
|
|
552
|
+
guard height != keyboardHeight else { return }
|
|
553
|
+
keyboardHeight = height
|
|
554
|
+
guard drag == nil else { return }
|
|
555
|
+
notification.animateAlongsideKeyboard {
|
|
556
|
+
self.setNeedsLayout()
|
|
557
|
+
self.layoutIfNeeded()
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private extension UIScrollView {
|
|
563
|
+
var topOffsetY: CGFloat {
|
|
564
|
+
-adjustedContentInset.top
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
var isScrolledToTop: Bool {
|
|
568
|
+
contentOffset.y <= topOffsetY + 0.5
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
var canScrollVertically: Bool {
|
|
572
|
+
alwaysBounceVertical
|
|
573
|
+
|| contentSize.height + adjustedContentInset.top + adjustedContentInset.bottom > bounds.height + 0.5
|
|
574
|
+
}
|
|
575
|
+
}
|