react-native-nitro-modal 0.1.5 → 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 +65 -11
- 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 +39 -7
- package/lib/module/NitroModal.js.map +1 -1
- package/lib/module/NitroModalView.js +4 -3
- package/lib/module/NitroModalView.js.map +1 -1
- package/lib/module/NitroModalView.native.js +3 -0
- package/lib/module/NitroModalView.native.js.map +1 -1
- package/lib/module/NitroModalView.web.js +159 -0
- package/lib/module/NitroModalView.web.js.map +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/web/AnimatedValue.js +126 -0
- package/lib/module/web/AnimatedValue.js.map +1 -0
- package/lib/module/web/ModalConfig.js +30 -0
- package/lib/module/web/ModalConfig.js.map +1 -0
- package/lib/module/web/ModalController.js +306 -0
- package/lib/module/web/ModalController.js.map +1 -0
- package/lib/module/web/ModalPresenter.js +280 -0
- package/lib/module/web/ModalPresenter.js.map +1 -0
- package/lib/module/web/PopupPresenter.js +117 -0
- package/lib/module/web/PopupPresenter.js.map +1 -0
- package/lib/module/web/SheetPresenter.js +494 -0
- package/lib/module/web/SheetPresenter.js.map +1 -0
- package/lib/module/web/dom.js +138 -0
- package/lib/module/web/dom.js.map +1 -0
- package/lib/typescript/src/NitroModal.d.ts +35 -4
- 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.d.ts +2 -1
- package/lib/typescript/src/NitroModalView.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.native.d.ts +1 -0
- package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.web.d.ts +6 -0
- package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -0
- 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 +49 -0
- package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalConfig.d.ts +30 -0
- package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalController.d.ts +70 -0
- package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts +107 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -0
- package/lib/typescript/src/web/PopupPresenter.d.ts +20 -0
- package/lib/typescript/src/web/PopupPresenter.d.ts.map +1 -0
- package/lib/typescript/src/web/SheetPresenter.d.ts +65 -0
- package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -0
- package/lib/typescript/src/web/dom.d.ts +42 -0
- package/lib/typescript/src/web/dom.d.ts.map +1 -0
- 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 +3 -2
- package/src/NitroModal.nitro.ts +30 -2
- package/src/NitroModal.tsx +71 -8
- package/src/NitroModalView.native.tsx +4 -0
- package/src/NitroModalView.tsx +9 -4
- package/src/NitroModalView.web.tsx +180 -0
- package/src/index.tsx +2 -0
- package/src/web/AnimatedValue.ts +193 -0
- package/src/web/ModalConfig.ts +67 -0
- package/src/web/ModalController.ts +338 -0
- package/src/web/ModalPresenter.ts +333 -0
- package/src/web/PopupPresenter.ts +129 -0
- package/src/web/SheetPresenter.ts +647 -0
- package/src/web/dom.ts +171 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import type { SheetDetent } from '../NitroModal.nitro';
|
|
4
|
+
import { AnimatedValue, INSTANT, type Motion } from './AnimatedValue';
|
|
5
|
+
import {
|
|
6
|
+
findVerticalScroller,
|
|
7
|
+
isTextInput,
|
|
8
|
+
prefersReducedMotion,
|
|
9
|
+
suppressNextClick,
|
|
10
|
+
type Insets,
|
|
11
|
+
type Size,
|
|
12
|
+
} from './dom';
|
|
13
|
+
import type { ModalConfig } from './ModalConfig';
|
|
14
|
+
import { ModalPresenter, type PresenterListener } from './ModalPresenter';
|
|
15
|
+
|
|
16
|
+
/** Gap kept above a modal sheet at its tallest. */
|
|
17
|
+
const TOP_GAP = 10;
|
|
18
|
+
/** How far below its lowest detent the sheet must be pulled to refresh. */
|
|
19
|
+
const REFRESH_THRESHOLD = 56;
|
|
20
|
+
/**
|
|
21
|
+
* Extra sheet height below the viewport so it stays attached to the bottom
|
|
22
|
+
* edge while rubber-banding upward.
|
|
23
|
+
*/
|
|
24
|
+
const OVERSCROLL = 400;
|
|
25
|
+
const DEFAULT_CORNER_RADIUS = 16;
|
|
26
|
+
const MAX_WIDTH = 640;
|
|
27
|
+
/** Mouse movement before a press turns into a drag. */
|
|
28
|
+
const POINTER_SLOP = 6;
|
|
29
|
+
/** Pointer samples used for the release velocity. */
|
|
30
|
+
const VELOCITY_WINDOW_MS = 100;
|
|
31
|
+
|
|
32
|
+
const ENTER: Motion = { type: 'spring', response: 0.4, dampingRatio: 0.9 };
|
|
33
|
+
const SETTLE: Motion = { type: 'spring', response: 0.4, dampingRatio: 0.9 };
|
|
34
|
+
/** Underdamped and stopped at the bottom edge, so the exit ends on a fast frame instead of a slow tail. */
|
|
35
|
+
const EXIT: Motion = { type: 'spring', response: 0.45, dampingRatio: 0.8 };
|
|
36
|
+
|
|
37
|
+
interface Drag {
|
|
38
|
+
input: 'touch' | 'pointer';
|
|
39
|
+
/** Touch identifier or pointer id. */
|
|
40
|
+
id: number;
|
|
41
|
+
startX: number;
|
|
42
|
+
startY: number;
|
|
43
|
+
lastY: number;
|
|
44
|
+
/** The innermost vertical scroll container under the start point. */
|
|
45
|
+
scroller: HTMLElement | null;
|
|
46
|
+
/** The sheet claimed the gesture and follows it. */
|
|
47
|
+
active: boolean;
|
|
48
|
+
/** Unconstrained sheet top, before rubber-banding. */
|
|
49
|
+
rawTop: number;
|
|
50
|
+
/** Whether the last movement moved the sheet rather than the scroller. */
|
|
51
|
+
sheetDrove: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* The scroller moved during this gesture. It then keeps the downward part
|
|
54
|
+
* too: scrolled content goes back to its top, and only a new gesture moves
|
|
55
|
+
* the sheet.
|
|
56
|
+
*/
|
|
57
|
+
contentScrolled: boolean;
|
|
58
|
+
/** Started with the sheet resting on its lowest detent, so pulling it further down refreshes. */
|
|
59
|
+
canRefresh: boolean;
|
|
60
|
+
/** Pulled far enough that letting go refreshes. */
|
|
61
|
+
refreshArmed: boolean;
|
|
62
|
+
samples: { time: number; y: number }[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sheetWidth(available: number, isInline: boolean): number {
|
|
66
|
+
return isInline ? available : Math.min(available, MAX_WIDTH);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Tallest height the content may occupy (excludes the bottom safe area). */
|
|
70
|
+
function maximumDetentValue(
|
|
71
|
+
viewportHeight: number,
|
|
72
|
+
insets: Insets,
|
|
73
|
+
isInline: boolean
|
|
74
|
+
): number {
|
|
75
|
+
const gap = isInline ? 0 : TOP_GAP;
|
|
76
|
+
return Math.max(0, viewportHeight - insets.top - insets.bottom - gap);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Content height at `detent`; `fitContent` uses the measured `fitting` height. */
|
|
80
|
+
function detentHeight(
|
|
81
|
+
detent: SheetDetent,
|
|
82
|
+
maximum: number,
|
|
83
|
+
fitting: number
|
|
84
|
+
): number {
|
|
85
|
+
switch (detent) {
|
|
86
|
+
case 'small':
|
|
87
|
+
return maximum * 0.25;
|
|
88
|
+
case 'medium':
|
|
89
|
+
return maximum * 0.5;
|
|
90
|
+
case 'large':
|
|
91
|
+
return maximum;
|
|
92
|
+
case 'fitContent':
|
|
93
|
+
return Math.min(Math.max(fitting, 1), maximum);
|
|
94
|
+
default:
|
|
95
|
+
return Number.isFinite(detent)
|
|
96
|
+
? Math.min(Math.max(detent, 1), maximum)
|
|
97
|
+
: maximum;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Height the content may occupy at the largest of `detents`. */
|
|
102
|
+
function contentHeight(detents: SheetDetent[], maximum: number): number {
|
|
103
|
+
if (detents.length === 0) return maximum;
|
|
104
|
+
// `fitContent` content can grow up to the maximum.
|
|
105
|
+
return Math.max(
|
|
106
|
+
...detents.map((detent) => detentHeight(detent, maximum, maximum))
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Best guess of the content area before the sheet is on screen. */
|
|
111
|
+
export function sheetContentArea(
|
|
112
|
+
detents: SheetDetent[],
|
|
113
|
+
viewport: Size,
|
|
114
|
+
insets: Insets,
|
|
115
|
+
isInline: boolean
|
|
116
|
+
): Size {
|
|
117
|
+
return {
|
|
118
|
+
width: sheetWidth(viewport.width, isInline),
|
|
119
|
+
height: contentHeight(
|
|
120
|
+
detents,
|
|
121
|
+
maximumDetentValue(viewport.height, insets, isInline)
|
|
122
|
+
),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function rubberBand(offset: number, dimension: number): number {
|
|
127
|
+
return (1 - 1 / ((offset * 0.55) / dimension + 1)) * dimension;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function findTouch(touches: TouchList, id: number): Touch | undefined {
|
|
131
|
+
for (let i = 0; i < touches.length; i++) {
|
|
132
|
+
const touch = touches.item(i);
|
|
133
|
+
if (touch?.identifier === id) return touch;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `bottomSheet` mode: an edge-attached sheet with detents, dragging,
|
|
140
|
+
* swipe-to-dismiss and hand-off to scrollable content, like the iOS
|
|
141
|
+
* implementation.
|
|
142
|
+
*/
|
|
143
|
+
export class SheetPresenter extends ModalPresenter {
|
|
144
|
+
private selectedIndex = 0;
|
|
145
|
+
/** The sheet sits below the viewport (before presenting / while dismissing). */
|
|
146
|
+
private isOffscreen = true;
|
|
147
|
+
private drag: Drag | null = null;
|
|
148
|
+
/** Vertical velocity of the swipe that requested the dismissal. */
|
|
149
|
+
private releaseVelocity = 0;
|
|
150
|
+
/** The sheet's top edge in the viewport. */
|
|
151
|
+
private readonly top = new AnimatedValue(0, 0.5, (value) =>
|
|
152
|
+
this.render(value)
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
constructor(config: ModalConfig, listener: PresenterListener) {
|
|
156
|
+
super(config, listener);
|
|
157
|
+
this.selectedIndex = Math.min(
|
|
158
|
+
Math.max(config.initialDetentIndex, 0),
|
|
159
|
+
this.detents.length - 1
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
snapToDetent(index: number) {
|
|
164
|
+
if (index < 0 || index >= this.detents.length) return;
|
|
165
|
+
this.selectedIndex = index;
|
|
166
|
+
this.animateToRest();
|
|
167
|
+
this.listener.onDetentChange(index);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
protected didShow() {
|
|
171
|
+
const { surface } = this.elements!;
|
|
172
|
+
surface.addEventListener('touchstart', this.handleTouchStart, {
|
|
173
|
+
passive: true,
|
|
174
|
+
});
|
|
175
|
+
surface.addEventListener('touchmove', this.handleTouchMove, {
|
|
176
|
+
passive: false,
|
|
177
|
+
});
|
|
178
|
+
surface.addEventListener('touchend', this.handleTouchEnd);
|
|
179
|
+
surface.addEventListener('touchcancel', this.handleTouchEnd);
|
|
180
|
+
surface.addEventListener('pointerdown', this.handlePointerDown);
|
|
181
|
+
this.layout();
|
|
182
|
+
this.top.set(this.viewport.height);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
protected animateIn() {
|
|
186
|
+
this.isOffscreen = false;
|
|
187
|
+
this.settle(ENTER);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
protected animateOut(onEnd: () => void) {
|
|
191
|
+
this.cancelDrag();
|
|
192
|
+
this.isOffscreen = true;
|
|
193
|
+
const velocity = this.releaseVelocity;
|
|
194
|
+
this.releaseVelocity = 0;
|
|
195
|
+
this.top.animateTo(this.viewport.height, this.motion(EXIT), {
|
|
196
|
+
velocity: velocity > 0 ? velocity : undefined,
|
|
197
|
+
stopAtTarget: true,
|
|
198
|
+
onEnd,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
protected configDidChange() {
|
|
203
|
+
this.selectedIndex = Math.min(this.selectedIndex, this.detents.length - 1);
|
|
204
|
+
this.layout();
|
|
205
|
+
this.animateToRest();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
protected viewportDidChange() {
|
|
209
|
+
this.layout();
|
|
210
|
+
this.animateToRest();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
protected contentSizeDidChange() {
|
|
214
|
+
if (this.config.detents.includes('fitContent')) {
|
|
215
|
+
this.animateToRest();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
protected stopAnimations() {
|
|
220
|
+
this.top.stop();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
protected teardown() {
|
|
224
|
+
super.teardown();
|
|
225
|
+
this.cancelDrag();
|
|
226
|
+
const surface = this.elements?.surface;
|
|
227
|
+
surface?.removeEventListener('touchstart', this.handleTouchStart);
|
|
228
|
+
surface?.removeEventListener('touchmove', this.handleTouchMove);
|
|
229
|
+
surface?.removeEventListener('touchend', this.handleTouchEnd);
|
|
230
|
+
surface?.removeEventListener('touchcancel', this.handleTouchEnd);
|
|
231
|
+
surface?.removeEventListener('pointerdown', this.handlePointerDown);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Layout
|
|
235
|
+
|
|
236
|
+
private get detents(): SheetDetent[] {
|
|
237
|
+
return this.config.detents.length > 0
|
|
238
|
+
? this.config.detents
|
|
239
|
+
: ['fitContent'];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private get maximumDetentValue(): number {
|
|
243
|
+
return maximumDetentValue(
|
|
244
|
+
this.viewport.height,
|
|
245
|
+
this.insets,
|
|
246
|
+
this.config.isInline
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private get keyboardLift(): number {
|
|
251
|
+
return this.config.keyboardBehavior === 'none'
|
|
252
|
+
? 0
|
|
253
|
+
: Math.max(0, this.keyboardHeight - this.insets.bottom);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private height(detent: SheetDetent): number {
|
|
257
|
+
return detentHeight(
|
|
258
|
+
detent,
|
|
259
|
+
this.maximumDetentValue,
|
|
260
|
+
this.contentSize.height
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Sheet top when resting on `detents[index]`. The bottom safe area is added below the content. */
|
|
265
|
+
private restingTop(index: number): number {
|
|
266
|
+
const { detents } = this;
|
|
267
|
+
const detent =
|
|
268
|
+
detents[Math.min(Math.max(index, 0), detents.length - 1)] ?? 'fitContent';
|
|
269
|
+
const top =
|
|
270
|
+
this.viewport.height -
|
|
271
|
+
this.insets.bottom -
|
|
272
|
+
this.keyboardLift -
|
|
273
|
+
this.height(detent);
|
|
274
|
+
return Math.max(
|
|
275
|
+
top,
|
|
276
|
+
this.insets.top + (this.config.isInline ? 0 : TOP_GAP)
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private get detentTops(): number[] {
|
|
281
|
+
return this.detents.map((_, index) => this.restingTop(index));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private layout() {
|
|
285
|
+
const { surface, content, grabber } = this.elements!;
|
|
286
|
+
const width = sheetWidth(this.viewport.width, this.config.isInline);
|
|
287
|
+
const radius = `${this.config.cornerRadius ?? DEFAULT_CORNER_RADIUS}px`;
|
|
288
|
+
Object.assign(surface.style, {
|
|
289
|
+
left: `${(this.viewport.width - width) / 2}px`,
|
|
290
|
+
top: '0px',
|
|
291
|
+
width: `${width}px`,
|
|
292
|
+
height: `${this.viewport.height + OVERSCROLL}px`,
|
|
293
|
+
borderTopLeftRadius: radius,
|
|
294
|
+
borderTopRightRadius: radius,
|
|
295
|
+
borderBottomLeftRadius: '0px',
|
|
296
|
+
borderBottomRightRadius: '0px',
|
|
297
|
+
});
|
|
298
|
+
content.style.width = `${width}px`;
|
|
299
|
+
content.style.height = `${this.maximumDetentValue}px`;
|
|
300
|
+
grabber.style.display = this.config.grabberVisible ? '' : 'none';
|
|
301
|
+
|
|
302
|
+
let height = contentHeight(this.config.detents, this.maximumDetentValue);
|
|
303
|
+
if (this.config.keyboardBehavior === 'resize') {
|
|
304
|
+
height -= this.keyboardLift;
|
|
305
|
+
}
|
|
306
|
+
this.reportContentArea(width, Math.max(height, 0));
|
|
307
|
+
this.render(this.top.value);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private render(top: number) {
|
|
311
|
+
if (!this.elements) return;
|
|
312
|
+
this.elements.surface.style.transform = `translate3d(0, ${top}px, 0)`;
|
|
313
|
+
this.elements.backdrop.style.opacity = String(this.backdropAlpha(top));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Fully dimmed at every detent; fades out below the lowest one. */
|
|
317
|
+
private backdropAlpha(top: number): number {
|
|
318
|
+
const bottom = this.viewport.height;
|
|
319
|
+
const lowest = Math.max(...this.detentTops);
|
|
320
|
+
if (!(bottom > lowest)) return top >= bottom ? 0 : 1;
|
|
321
|
+
return Math.min(Math.max((bottom - top) / (bottom - lowest), 0), 1);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private motion(motion: Motion): Motion {
|
|
325
|
+
return prefersReducedMotion() ? INSTANT : motion;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private settle(motion: Motion, velocity?: number) {
|
|
329
|
+
this.top.animateTo(
|
|
330
|
+
this.restingTop(this.selectedIndex),
|
|
331
|
+
this.motion(motion),
|
|
332
|
+
{
|
|
333
|
+
velocity,
|
|
334
|
+
// Keeps the enter's completion when a re-layout takes it over.
|
|
335
|
+
onEnd: () => this.notifyPresented(),
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private animateToRest() {
|
|
341
|
+
if (!this.elements || this.drag?.active || this.isOffscreen) return;
|
|
342
|
+
this.settle(SETTLE);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Dragging
|
|
346
|
+
|
|
347
|
+
private canDrag(): boolean {
|
|
348
|
+
return !this.entering && !this.isDismissing && !this.isOffscreen;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private beginDrag(
|
|
352
|
+
input: Drag['input'],
|
|
353
|
+
id: number,
|
|
354
|
+
x: number,
|
|
355
|
+
y: number,
|
|
356
|
+
target: EventTarget | null
|
|
357
|
+
) {
|
|
358
|
+
this.drag = {
|
|
359
|
+
input,
|
|
360
|
+
id,
|
|
361
|
+
startX: x,
|
|
362
|
+
startY: y,
|
|
363
|
+
lastY: y,
|
|
364
|
+
scroller: findVerticalScroller(target, this.elements!.surface),
|
|
365
|
+
active: false,
|
|
366
|
+
rawTop: 0,
|
|
367
|
+
sheetDrove: false,
|
|
368
|
+
contentScrolled: false,
|
|
369
|
+
canRefresh: false,
|
|
370
|
+
refreshArmed: false,
|
|
371
|
+
samples: [],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Decides who handles a gesture once it moves: the sheet, or the content (scrolling, horizontal swipes). */
|
|
376
|
+
private claim(
|
|
377
|
+
drag: Drag,
|
|
378
|
+
x: number,
|
|
379
|
+
y: number,
|
|
380
|
+
slop: number
|
|
381
|
+
): 'pending' | 'sheet' | 'content' {
|
|
382
|
+
const dx = x - drag.startX;
|
|
383
|
+
const dy = y - drag.startY;
|
|
384
|
+
if (Math.abs(dx) <= slop && Math.abs(dy) <= slop) return 'pending';
|
|
385
|
+
if (Math.abs(dx) > Math.abs(dy)) return 'content';
|
|
386
|
+
const { scroller } = drag;
|
|
387
|
+
if (!scroller) return 'sheet';
|
|
388
|
+
// Down: scroll back to the top before collapsing the sheet. Up: expand
|
|
389
|
+
// the sheet before scrolling.
|
|
390
|
+
if (dy > 0) return scroller.scrollTop <= 0 ? 'sheet' : 'content';
|
|
391
|
+
return this.top.value > Math.min(...this.detentTops) + 0.5
|
|
392
|
+
? 'sheet'
|
|
393
|
+
: 'content';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private activate(drag: Drag) {
|
|
397
|
+
drag.active = true;
|
|
398
|
+
const isSettled = !this.top.isAnimating;
|
|
399
|
+
this.top.stop();
|
|
400
|
+
drag.rawTop = this.top.value;
|
|
401
|
+
drag.canRefresh =
|
|
402
|
+
this.config.pullToRefreshEnabled &&
|
|
403
|
+
!this.config.dismissOnSwipe &&
|
|
404
|
+
isSettled &&
|
|
405
|
+
Math.abs(drag.rawTop - Math.max(...this.detentTops)) < 1;
|
|
406
|
+
const { surface } = this.elements!;
|
|
407
|
+
// A native scroll on an ancestor makes React Native Web cancel the press
|
|
408
|
+
// under the pointer, so dragging from a button doesn't leave it pressed.
|
|
409
|
+
surface.dispatchEvent(new Event('scroll'));
|
|
410
|
+
if (drag.input === 'pointer') {
|
|
411
|
+
surface.style.setProperty('user-select', 'none');
|
|
412
|
+
surface.style.setProperty('-webkit-user-select', 'none');
|
|
413
|
+
window.getSelection()?.removeAllRanges();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private moveDrag(drag: Drag, y: number) {
|
|
418
|
+
const delta = y - drag.lastY;
|
|
419
|
+
drag.lastY = y;
|
|
420
|
+
const time = performance.now();
|
|
421
|
+
drag.samples.push({ time, y });
|
|
422
|
+
while (
|
|
423
|
+
drag.samples.length > 2 &&
|
|
424
|
+
time - drag.samples[0]!.time > VELOCITY_WINDOW_MS
|
|
425
|
+
) {
|
|
426
|
+
drag.samples.shift();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const { scroller } = drag;
|
|
430
|
+
if (scroller) {
|
|
431
|
+
// The browser's own scrolling is prevented for this gesture, so the
|
|
432
|
+
// content is scrolled here whenever the sheet can't move. Up: expand
|
|
433
|
+
// the sheet before scrolling. Down: scroll back to the top before
|
|
434
|
+
// collapsing the sheet, in a separate gesture.
|
|
435
|
+
const minTop = Math.min(...this.detentTops);
|
|
436
|
+
const drive =
|
|
437
|
+
delta < 0
|
|
438
|
+
? drag.rawTop > minTop + 0.5
|
|
439
|
+
: !drag.contentScrolled && scroller.scrollTop <= 0;
|
|
440
|
+
if (drive) {
|
|
441
|
+
drag.rawTop = Math.max(drag.rawTop + delta, minTop);
|
|
442
|
+
} else {
|
|
443
|
+
scroller.scrollTop -= delta;
|
|
444
|
+
if (delta !== 0) {
|
|
445
|
+
drag.contentScrolled = true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
drag.sheetDrove = drive;
|
|
449
|
+
} else {
|
|
450
|
+
drag.rawTop += delta;
|
|
451
|
+
drag.sheetDrove = true;
|
|
452
|
+
}
|
|
453
|
+
const top = this.constrained(drag.rawTop);
|
|
454
|
+
if (drag.canRefresh) {
|
|
455
|
+
drag.refreshArmed =
|
|
456
|
+
top - Math.max(...this.detentTops) >= REFRESH_THRESHOLD;
|
|
457
|
+
}
|
|
458
|
+
this.top.set(top);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private release(drag: Drag) {
|
|
462
|
+
this.stopTracking();
|
|
463
|
+
this.drag = null;
|
|
464
|
+
if (!drag.active) return;
|
|
465
|
+
if (drag.input === 'pointer') {
|
|
466
|
+
suppressNextClick();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const velocity = drag.sheetDrove ? this.velocity(drag) : 0;
|
|
470
|
+
const top = this.top.value;
|
|
471
|
+
const tops = this.detentTops;
|
|
472
|
+
const lowest = Math.max(...tops);
|
|
473
|
+
// Where a fling of this velocity would come to rest.
|
|
474
|
+
const projected = top + velocity * 0.2;
|
|
475
|
+
|
|
476
|
+
if (
|
|
477
|
+
this.config.dismissOnSwipe &&
|
|
478
|
+
velocity >= 0 &&
|
|
479
|
+
projected > lowest + (this.viewport.height - lowest) / 2
|
|
480
|
+
) {
|
|
481
|
+
this.releaseVelocity = velocity;
|
|
482
|
+
this.listener.onSwipeDismiss();
|
|
483
|
+
if (this.isDismissing) return;
|
|
484
|
+
this.releaseVelocity = 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const index = tops.reduce(
|
|
488
|
+
(best, candidate, i) =>
|
|
489
|
+
Math.abs(candidate - projected) < Math.abs(tops[best]! - projected)
|
|
490
|
+
? i
|
|
491
|
+
: best,
|
|
492
|
+
0
|
|
493
|
+
);
|
|
494
|
+
if (index !== this.selectedIndex) {
|
|
495
|
+
this.selectedIndex = index;
|
|
496
|
+
this.listener.onDetentChange(index);
|
|
497
|
+
}
|
|
498
|
+
this.settle(SETTLE, velocity);
|
|
499
|
+
if (drag.refreshArmed) {
|
|
500
|
+
this.listener.onPullToRefresh();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Abandons the gesture without settling (the exit or the content takes over). */
|
|
505
|
+
private cancelDrag() {
|
|
506
|
+
this.stopTracking();
|
|
507
|
+
this.drag = null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private stopTracking() {
|
|
511
|
+
window.removeEventListener('pointermove', this.handlePointerMove);
|
|
512
|
+
window.removeEventListener('pointerup', this.handlePointerUp);
|
|
513
|
+
window.removeEventListener('pointercancel', this.handlePointerUp);
|
|
514
|
+
const surface = this.elements?.surface;
|
|
515
|
+
surface?.style.removeProperty('user-select');
|
|
516
|
+
surface?.style.removeProperty('-webkit-user-select');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private velocity(drag: Drag): number {
|
|
520
|
+
const now = performance.now();
|
|
521
|
+
const samples = drag.samples.filter(
|
|
522
|
+
(sample) => now - sample.time <= VELOCITY_WINDOW_MS
|
|
523
|
+
);
|
|
524
|
+
const first = samples[0];
|
|
525
|
+
const last = samples[samples.length - 1];
|
|
526
|
+
if (!first || !last || last.time <= first.time) return 0;
|
|
527
|
+
return ((last.y - first.y) / (last.time - first.time)) * 1000;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Rubber-bands above the tallest detent, and below the lowest one when swiping can't dismiss. */
|
|
531
|
+
private constrained(top: number): number {
|
|
532
|
+
const tops = this.detentTops;
|
|
533
|
+
const minTop = Math.min(...tops);
|
|
534
|
+
const maxTop = Math.max(...tops);
|
|
535
|
+
const dimension = Math.max(this.viewport.height, 1);
|
|
536
|
+
if (top < minTop) {
|
|
537
|
+
return minTop - rubberBand(minTop - top, dimension);
|
|
538
|
+
}
|
|
539
|
+
if (!this.config.dismissOnSwipe && top > maxTop) {
|
|
540
|
+
return maxTop + rubberBand(top - maxTop, dimension);
|
|
541
|
+
}
|
|
542
|
+
return top;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Touch input. Touch events (rather than pointer events) let the sheet
|
|
546
|
+
// cancel the browser's scrolling when it claims the gesture.
|
|
547
|
+
|
|
548
|
+
private handleTouchStart = (event: TouchEvent) => {
|
|
549
|
+
if (this.drag) {
|
|
550
|
+
// A second finger before the sheet moved: leave it to the browser.
|
|
551
|
+
if (this.drag.input === 'touch' && !this.drag.active) {
|
|
552
|
+
this.drag = null;
|
|
553
|
+
}
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const touch = event.touches.length === 1 ? event.touches[0] : undefined;
|
|
557
|
+
if (!touch || !this.canDrag()) return;
|
|
558
|
+
this.beginDrag(
|
|
559
|
+
'touch',
|
|
560
|
+
touch.identifier,
|
|
561
|
+
touch.clientX,
|
|
562
|
+
touch.clientY,
|
|
563
|
+
event.target
|
|
564
|
+
);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
private handleTouchMove = (event: TouchEvent) => {
|
|
568
|
+
const drag = this.drag;
|
|
569
|
+
if (drag?.input !== 'touch') return;
|
|
570
|
+
const touch = findTouch(event.changedTouches, drag.id);
|
|
571
|
+
if (!touch) return;
|
|
572
|
+
if (!drag.active) {
|
|
573
|
+
// Once the browser has started scrolling, the gesture is its.
|
|
574
|
+
const owner = event.cancelable
|
|
575
|
+
? this.claim(drag, touch.clientX, touch.clientY, 0)
|
|
576
|
+
: 'content';
|
|
577
|
+
if (owner === 'pending') return;
|
|
578
|
+
if (owner === 'content') {
|
|
579
|
+
this.drag = null;
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
this.activate(drag);
|
|
583
|
+
}
|
|
584
|
+
if (event.cancelable) {
|
|
585
|
+
event.preventDefault();
|
|
586
|
+
}
|
|
587
|
+
this.moveDrag(drag, touch.clientY);
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
private handleTouchEnd = (event: TouchEvent) => {
|
|
591
|
+
const drag = this.drag;
|
|
592
|
+
if (drag?.input !== 'touch' || !findTouch(event.changedTouches, drag.id))
|
|
593
|
+
return;
|
|
594
|
+
this.release(drag);
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// Mouse and pen input.
|
|
598
|
+
|
|
599
|
+
private handlePointerDown = (event: PointerEvent) => {
|
|
600
|
+
if (
|
|
601
|
+
event.pointerType === 'touch' ||
|
|
602
|
+
event.button !== 0 ||
|
|
603
|
+
this.drag ||
|
|
604
|
+
!this.canDrag() ||
|
|
605
|
+
// Leave text selection in inputs alone.
|
|
606
|
+
isTextInput(event.target)
|
|
607
|
+
) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
this.beginDrag(
|
|
611
|
+
'pointer',
|
|
612
|
+
event.pointerId,
|
|
613
|
+
event.clientX,
|
|
614
|
+
event.clientY,
|
|
615
|
+
event.target
|
|
616
|
+
);
|
|
617
|
+
window.addEventListener('pointermove', this.handlePointerMove);
|
|
618
|
+
window.addEventListener('pointerup', this.handlePointerUp);
|
|
619
|
+
window.addEventListener('pointercancel', this.handlePointerUp);
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
private handlePointerMove = (event: PointerEvent) => {
|
|
623
|
+
const drag = this.drag;
|
|
624
|
+
if (drag?.input !== 'pointer' || event.pointerId !== drag.id) return;
|
|
625
|
+
if (!drag.active) {
|
|
626
|
+
const owner = this.claim(
|
|
627
|
+
drag,
|
|
628
|
+
event.clientX,
|
|
629
|
+
event.clientY,
|
|
630
|
+
POINTER_SLOP
|
|
631
|
+
);
|
|
632
|
+
if (owner === 'pending') return;
|
|
633
|
+
if (owner === 'content') {
|
|
634
|
+
this.cancelDrag();
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
this.activate(drag);
|
|
638
|
+
}
|
|
639
|
+
this.moveDrag(drag, event.clientY);
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
private handlePointerUp = (event: PointerEvent) => {
|
|
643
|
+
const drag = this.drag;
|
|
644
|
+
if (drag?.input !== 'pointer' || event.pointerId !== drag.id) return;
|
|
645
|
+
this.release(drag);
|
|
646
|
+
};
|
|
647
|
+
}
|