react-native-nitro-modal 0.2.0 → 0.3.1-rc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -4
- package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +399 -34
- package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +67 -22
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +35 -1
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +48 -5
- package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +63 -4
- package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +68 -8
- package/ios/HybridNitroModal.swift +115 -20
- package/ios/ModalConfig.swift +48 -2
- package/ios/NitroModalHostView.swift +73 -8
- package/ios/SheetView.swift +647 -0
- package/ios/SheetViewController.swift +19 -450
- package/lib/module/NitroModal.js +54 -6
- package/lib/module/NitroModal.js.map +1 -1
- package/lib/module/NitroModalView.web.js +86 -26
- package/lib/module/NitroModalView.web.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/web/AnimatedValue.js +3 -0
- package/lib/module/web/AnimatedValue.js.map +1 -1
- package/lib/module/web/ModalConfig.js +10 -4
- package/lib/module/web/ModalConfig.js.map +1 -1
- package/lib/module/web/ModalController.js +29 -2
- package/lib/module/web/ModalController.js.map +1 -1
- package/lib/module/web/ModalPresenter.js +47 -12
- package/lib/module/web/ModalPresenter.js.map +1 -1
- package/lib/module/web/SheetPresenter.js +118 -34
- package/lib/module/web/SheetPresenter.js.map +1 -1
- package/lib/module/web/dom.js +16 -0
- package/lib/module/web/dom.js.map +1 -1
- package/lib/typescript/src/NitroModal.d.ts +42 -2
- package/lib/typescript/src/NitroModal.d.ts.map +1 -1
- package/lib/typescript/src/NitroModal.nitro.d.ts +37 -2
- package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
- package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/web/AnimatedValue.d.ts +1 -0
- package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -1
- package/lib/typescript/src/web/ModalConfig.d.ts +10 -0
- package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -1
- package/lib/typescript/src/web/ModalController.d.ts +5 -0
- package/lib/typescript/src/web/ModalController.d.ts.map +1 -1
- package/lib/typescript/src/web/ModalPresenter.d.ts +13 -0
- package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/SheetPresenter.d.ts +13 -2
- package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -1
- package/lib/typescript/src/web/dom.d.ts +4 -0
- package/lib/typescript/src/web/dom.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +78 -6
- package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +14 -2
- package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
- package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
- package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
- package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +30 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +52 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
- package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +68 -8
- package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
- package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +46 -5
- package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +36 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +6 -0
- package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +158 -5
- package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
- package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +12 -0
- package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +18 -5
- package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +13 -1
- package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +21 -2
- package/nitrogen/generated/shared/json/NitroModalConfig.json +6 -0
- package/package.json +1 -1
- package/src/NitroModal.nitro.ts +38 -2
- package/src/NitroModal.tsx +93 -4
- package/src/NitroModalView.web.tsx +79 -12
- package/src/index.tsx +2 -0
- package/src/web/AnimatedValue.ts +4 -0
- package/src/web/ModalConfig.ts +19 -3
- package/src/web/ModalController.ts +35 -2
- package/src/web/ModalPresenter.ts +50 -11
- package/src/web/SheetPresenter.ts +169 -37
- package/src/web/dom.ts +20 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
canScrollWithin,
|
|
5
5
|
clipOverflow,
|
|
6
|
+
inlineInsets,
|
|
6
7
|
readKeyboardHeight,
|
|
7
8
|
readSafeAreaInsets,
|
|
8
9
|
toCSSColor,
|
|
@@ -22,6 +23,10 @@ export interface ModalElements {
|
|
|
22
23
|
/** Hosts the React content. */
|
|
23
24
|
content: HTMLElement;
|
|
24
25
|
grabber: HTMLElement;
|
|
26
|
+
/** Behind the sheet, uncovered as it's pulled down to refresh. */
|
|
27
|
+
refreshIndicator: HTMLElement;
|
|
28
|
+
/** The spinning ring inside `refreshIndicator`. */
|
|
29
|
+
refreshSpinner: HTMLElement;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export interface PresenterListener {
|
|
@@ -34,6 +39,8 @@ export interface PresenterListener {
|
|
|
34
39
|
onSwipeDismiss(): void;
|
|
35
40
|
onDetentChange(index: number): void;
|
|
36
41
|
onContentAreaChange(width: number, height: number): void;
|
|
42
|
+
/** The user pulled the sheet past its lowest detent and let go. */
|
|
43
|
+
onPullToRefresh(): void;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/**
|
|
@@ -45,6 +52,9 @@ export interface PresenterListener {
|
|
|
45
52
|
* Transitions are interruptible: dismissing mid-enter turns the enter around,
|
|
46
53
|
* and `cancelDismiss` turns an exit back around. Once an exit starts the modal
|
|
47
54
|
* stops taking input.
|
|
55
|
+
*
|
|
56
|
+
* Inline, the elements fill the host view in place of a full-window layer:
|
|
57
|
+
* only the surface takes input, and the keyboard is left to the page.
|
|
48
58
|
*/
|
|
49
59
|
export abstract class ModalPresenter {
|
|
50
60
|
protected elements: ModalElements | null = null;
|
|
@@ -59,6 +69,8 @@ export abstract class ModalPresenter {
|
|
|
59
69
|
private finished = false;
|
|
60
70
|
private observedContent: HTMLElement | null = null;
|
|
61
71
|
private resizeObserver: ResizeObserver | null = null;
|
|
72
|
+
/** Inline: follows the host's size, which can change without a window resize. */
|
|
73
|
+
private hostObserver: ResizeObserver | null = null;
|
|
62
74
|
|
|
63
75
|
constructor(
|
|
64
76
|
protected config: ModalConfig,
|
|
@@ -74,18 +86,26 @@ export abstract class ModalPresenter {
|
|
|
74
86
|
if (this.elements || this.finished) return;
|
|
75
87
|
this.elements = elements;
|
|
76
88
|
// The elements can outlive a previous presentation (an immediate re-present).
|
|
77
|
-
|
|
89
|
+
this.setInteractive(true);
|
|
78
90
|
elements.surface.style.overflow = clipOverflow();
|
|
79
91
|
elements.surface.style.opacity = '';
|
|
80
92
|
elements.surface.style.transform = '';
|
|
93
|
+
elements.backdrop.style.display = this.config.isInline ? 'none' : '';
|
|
81
94
|
|
|
82
95
|
window.addEventListener('resize', this.handleResize);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
96
|
+
if (this.config.isInline) {
|
|
97
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
98
|
+
this.hostObserver = new ResizeObserver(this.handleResize);
|
|
99
|
+
this.hostObserver.observe(elements.overlay);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
window.visualViewport?.addEventListener('resize', this.handleKeyboard);
|
|
103
|
+
window.visualViewport?.addEventListener('scroll', this.handleKeyboard);
|
|
104
|
+
elements.backdrop.addEventListener('click', this.handleBackdropClick);
|
|
105
|
+
elements.overlay.addEventListener('wheel', this.handleWheel, {
|
|
106
|
+
passive: false,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
89
109
|
if (typeof ResizeObserver !== 'undefined') {
|
|
90
110
|
this.resizeObserver = new ResizeObserver(this.handleContentResize);
|
|
91
111
|
}
|
|
@@ -122,7 +142,7 @@ export abstract class ModalPresenter {
|
|
|
122
142
|
if (!elements || this.isDismissing || this.finished) return;
|
|
123
143
|
this.isDismissing = true;
|
|
124
144
|
this.entering = false;
|
|
125
|
-
|
|
145
|
+
this.setInteractive(false);
|
|
126
146
|
// Also hides the on-screen keyboard.
|
|
127
147
|
const focused = document.activeElement;
|
|
128
148
|
if (focused instanceof HTMLElement && elements.overlay.contains(focused)) {
|
|
@@ -138,7 +158,7 @@ export abstract class ModalPresenter {
|
|
|
138
158
|
cancelDismiss(): boolean {
|
|
139
159
|
if (!this.elements || !this.isDismissing || this.finished) return false;
|
|
140
160
|
this.isDismissing = false;
|
|
141
|
-
this.
|
|
161
|
+
this.setInteractive(true);
|
|
142
162
|
this.entering = true;
|
|
143
163
|
this.animateIn();
|
|
144
164
|
return true;
|
|
@@ -196,6 +216,8 @@ export abstract class ModalPresenter {
|
|
|
196
216
|
this.elements?.overlay.removeEventListener('wheel', this.handleWheel);
|
|
197
217
|
this.resizeObserver?.disconnect();
|
|
198
218
|
this.resizeObserver = null;
|
|
219
|
+
this.hostObserver?.disconnect();
|
|
220
|
+
this.hostObserver = null;
|
|
199
221
|
this.observedContent = null;
|
|
200
222
|
}
|
|
201
223
|
|
|
@@ -226,14 +248,30 @@ export abstract class ModalPresenter {
|
|
|
226
248
|
: 'Canvas';
|
|
227
249
|
}
|
|
228
250
|
|
|
251
|
+
/** Inline, only the surface takes input; the rest reaches the page. */
|
|
252
|
+
private setInteractive(interactive: boolean) {
|
|
253
|
+
const { overlay, surface } = this.elements!;
|
|
254
|
+
if (this.config.isInline) {
|
|
255
|
+
overlay.style.pointerEvents = 'none';
|
|
256
|
+
surface.style.pointerEvents = interactive ? 'auto' : 'none';
|
|
257
|
+
} else {
|
|
258
|
+
overlay.style.pointerEvents = interactive ? '' : 'none';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
229
262
|
private measureViewport() {
|
|
230
263
|
const { overlay } = this.elements!;
|
|
231
264
|
this.viewport = {
|
|
232
265
|
width: overlay.clientWidth,
|
|
233
266
|
height: overlay.clientHeight,
|
|
234
267
|
};
|
|
235
|
-
this.
|
|
236
|
-
|
|
268
|
+
if (this.config.isInline) {
|
|
269
|
+
this.insets = inlineInsets(overlay);
|
|
270
|
+
this.keyboardHeight = 0;
|
|
271
|
+
} else {
|
|
272
|
+
this.insets = readSafeAreaInsets();
|
|
273
|
+
this.keyboardHeight = readKeyboardHeight(this.viewport.height);
|
|
274
|
+
}
|
|
237
275
|
}
|
|
238
276
|
|
|
239
277
|
/** Re-targets the observer at the content's root element. Returns whether its size changed. */
|
|
@@ -275,6 +313,7 @@ export abstract class ModalPresenter {
|
|
|
275
313
|
};
|
|
276
314
|
|
|
277
315
|
private handleKeyboard = () => {
|
|
316
|
+
if (this.config.isInline) return;
|
|
278
317
|
const height = readKeyboardHeight(this.viewport.height);
|
|
279
318
|
if (height === this.keyboardHeight) return;
|
|
280
319
|
this.keyboardHeight = height;
|
|
@@ -6,15 +6,22 @@ import {
|
|
|
6
6
|
findVerticalScroller,
|
|
7
7
|
isTextInput,
|
|
8
8
|
prefersReducedMotion,
|
|
9
|
+
REFRESH_INDICATOR_SIZE,
|
|
9
10
|
suppressNextClick,
|
|
11
|
+
toCSSColor,
|
|
10
12
|
type Insets,
|
|
11
13
|
type Size,
|
|
12
14
|
} from './dom';
|
|
13
15
|
import type { ModalConfig } from './ModalConfig';
|
|
14
16
|
import { ModalPresenter, type PresenterListener } from './ModalPresenter';
|
|
15
17
|
|
|
16
|
-
/** Gap kept above
|
|
18
|
+
/** Gap kept above a modal sheet at its tallest. */
|
|
17
19
|
const TOP_GAP = 10;
|
|
20
|
+
/**
|
|
21
|
+
* How far below its lowest detent the sheet must be pulled to refresh, and
|
|
22
|
+
* where it rests while refreshing.
|
|
23
|
+
*/
|
|
24
|
+
const REFRESH_THRESHOLD = 56;
|
|
18
25
|
/**
|
|
19
26
|
* Extra sheet height below the viewport so it stays attached to the bottom
|
|
20
27
|
* edge while rubber-banding upward.
|
|
@@ -47,39 +54,77 @@ interface Drag {
|
|
|
47
54
|
rawTop: number;
|
|
48
55
|
/** Whether the last movement moved the sheet rather than the scroller. */
|
|
49
56
|
sheetDrove: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* The scroller moved during this gesture. It then keeps the downward part
|
|
59
|
+
* too: scrolled content goes back to its top, and only a new gesture moves
|
|
60
|
+
* the sheet.
|
|
61
|
+
*/
|
|
62
|
+
contentScrolled: boolean;
|
|
63
|
+
/** Started with the sheet resting on its lowest detent, so pulling it further down refreshes. */
|
|
64
|
+
canRefresh: boolean;
|
|
65
|
+
/** Pulled far enough that letting go refreshes. */
|
|
66
|
+
refreshArmed: boolean;
|
|
50
67
|
samples: { time: number; y: number }[];
|
|
51
68
|
}
|
|
52
69
|
|
|
53
|
-
function sheetWidth(available: number): number {
|
|
54
|
-
return Math.min(available, MAX_WIDTH);
|
|
70
|
+
function sheetWidth(available: number, isInline: boolean): number {
|
|
71
|
+
return isInline ? available : Math.min(available, MAX_WIDTH);
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
/** Tallest height the content may occupy (excludes the bottom safe area). */
|
|
58
|
-
function maximumDetentValue(
|
|
59
|
-
|
|
75
|
+
function maximumDetentValue(
|
|
76
|
+
viewportHeight: number,
|
|
77
|
+
insets: Insets,
|
|
78
|
+
isInline: boolean
|
|
79
|
+
): number {
|
|
80
|
+
const gap = isInline ? 0 : TOP_GAP;
|
|
81
|
+
return Math.max(0, viewportHeight - insets.top - insets.bottom - gap);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Content height at `detent`; `fitContent` uses the measured `fitting` height. */
|
|
85
|
+
function detentHeight(
|
|
86
|
+
detent: SheetDetent,
|
|
87
|
+
maximum: number,
|
|
88
|
+
fitting: number
|
|
89
|
+
): number {
|
|
90
|
+
switch (detent) {
|
|
91
|
+
case 'small':
|
|
92
|
+
return maximum * 0.25;
|
|
93
|
+
case 'medium':
|
|
94
|
+
return maximum * 0.5;
|
|
95
|
+
case 'large':
|
|
96
|
+
return maximum;
|
|
97
|
+
case 'fitContent':
|
|
98
|
+
return Math.min(Math.max(fitting, 1), maximum);
|
|
99
|
+
default:
|
|
100
|
+
return Number.isFinite(detent)
|
|
101
|
+
? Math.min(Math.max(detent, 1), maximum)
|
|
102
|
+
: maximum;
|
|
103
|
+
}
|
|
60
104
|
}
|
|
61
105
|
|
|
62
106
|
/** Height the content may occupy at the largest of `detents`. */
|
|
63
107
|
function contentHeight(detents: SheetDetent[], maximum: number): number {
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
detents.
|
|
68
|
-
)
|
|
69
|
-
return maximum;
|
|
70
|
-
}
|
|
71
|
-
return detents.includes('medium') ? maximum * 0.5 : maximum * 0.25;
|
|
108
|
+
if (detents.length === 0) return maximum;
|
|
109
|
+
// `fitContent` content can grow up to the maximum.
|
|
110
|
+
return Math.max(
|
|
111
|
+
...detents.map((detent) => detentHeight(detent, maximum, maximum))
|
|
112
|
+
);
|
|
72
113
|
}
|
|
73
114
|
|
|
74
115
|
/** Best guess of the content area before the sheet is on screen. */
|
|
75
116
|
export function sheetContentArea(
|
|
76
117
|
detents: SheetDetent[],
|
|
77
118
|
viewport: Size,
|
|
78
|
-
insets: Insets
|
|
119
|
+
insets: Insets,
|
|
120
|
+
isInline: boolean
|
|
79
121
|
): Size {
|
|
80
122
|
return {
|
|
81
|
-
width: sheetWidth(viewport.width),
|
|
82
|
-
height: contentHeight(
|
|
123
|
+
width: sheetWidth(viewport.width, isInline),
|
|
124
|
+
height: contentHeight(
|
|
125
|
+
detents,
|
|
126
|
+
maximumDetentValue(viewport.height, insets, isInline)
|
|
127
|
+
),
|
|
83
128
|
};
|
|
84
129
|
}
|
|
85
130
|
|
|
@@ -107,6 +152,9 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
107
152
|
private drag: Drag | null = null;
|
|
108
153
|
/** Vertical velocity of the swipe that requested the dismissal. */
|
|
109
154
|
private releaseVelocity = 0;
|
|
155
|
+
/** The sheet rests below its lowest detent, the indicator spinning above it. */
|
|
156
|
+
private refreshing = false;
|
|
157
|
+
private refreshSpin: Animation | null = null;
|
|
110
158
|
/** The sheet's top edge in the viewport. */
|
|
111
159
|
private readonly top = new AnimatedValue(0, 0.5, (value) =>
|
|
112
160
|
this.render(value)
|
|
@@ -118,6 +166,7 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
118
166
|
Math.max(config.initialDetentIndex, 0),
|
|
119
167
|
this.detents.length - 1
|
|
120
168
|
);
|
|
169
|
+
this.refreshing = config.refreshing ?? false;
|
|
121
170
|
}
|
|
122
171
|
|
|
123
172
|
snapToDetent(index: number) {
|
|
@@ -159,7 +208,10 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
159
208
|
});
|
|
160
209
|
}
|
|
161
210
|
|
|
162
|
-
protected configDidChange() {
|
|
211
|
+
protected configDidChange(previous: ModalConfig) {
|
|
212
|
+
if (previous.refreshing !== this.config.refreshing) {
|
|
213
|
+
this.refreshing = this.config.refreshing ?? false;
|
|
214
|
+
}
|
|
163
215
|
this.selectedIndex = Math.min(this.selectedIndex, this.detents.length - 1);
|
|
164
216
|
this.layout();
|
|
165
217
|
this.animateToRest();
|
|
@@ -183,6 +235,8 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
183
235
|
protected teardown() {
|
|
184
236
|
super.teardown();
|
|
185
237
|
this.cancelDrag();
|
|
238
|
+
this.refreshSpin?.cancel();
|
|
239
|
+
this.refreshSpin = null;
|
|
186
240
|
const surface = this.elements?.surface;
|
|
187
241
|
surface?.removeEventListener('touchstart', this.handleTouchStart);
|
|
188
242
|
surface?.removeEventListener('touchmove', this.handleTouchMove);
|
|
@@ -200,7 +254,11 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
200
254
|
}
|
|
201
255
|
|
|
202
256
|
private get maximumDetentValue(): number {
|
|
203
|
-
return maximumDetentValue(
|
|
257
|
+
return maximumDetentValue(
|
|
258
|
+
this.viewport.height,
|
|
259
|
+
this.insets,
|
|
260
|
+
this.config.isInline
|
|
261
|
+
);
|
|
204
262
|
}
|
|
205
263
|
|
|
206
264
|
private get keyboardLift(): number {
|
|
@@ -210,17 +268,11 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
210
268
|
}
|
|
211
269
|
|
|
212
270
|
private height(detent: SheetDetent): number {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
return maximum * 0.5;
|
|
219
|
-
case 'large':
|
|
220
|
-
return maximum;
|
|
221
|
-
case 'fitContent':
|
|
222
|
-
return Math.min(Math.max(this.contentSize.height, 1), maximum);
|
|
223
|
-
}
|
|
271
|
+
return detentHeight(
|
|
272
|
+
detent,
|
|
273
|
+
this.maximumDetentValue,
|
|
274
|
+
this.contentSize.height
|
|
275
|
+
);
|
|
224
276
|
}
|
|
225
277
|
|
|
226
278
|
/** Sheet top when resting on `detents[index]`. The bottom safe area is added below the content. */
|
|
@@ -233,16 +285,31 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
233
285
|
this.insets.bottom -
|
|
234
286
|
this.keyboardLift -
|
|
235
287
|
this.height(detent);
|
|
236
|
-
return Math.max(
|
|
288
|
+
return Math.max(
|
|
289
|
+
top,
|
|
290
|
+
this.insets.top + (this.config.isInline ? 0 : TOP_GAP)
|
|
291
|
+
);
|
|
237
292
|
}
|
|
238
293
|
|
|
239
294
|
private get detentTops(): number[] {
|
|
240
295
|
return this.detents.map((_, index) => this.restingTop(index));
|
|
241
296
|
}
|
|
242
297
|
|
|
298
|
+
/** Where the sheet settles on `detents[index]`: held below the lowest detent while refreshing. */
|
|
299
|
+
private settledTop(index: number): number {
|
|
300
|
+
const top = this.restingTop(index);
|
|
301
|
+
if (!this.refreshing || Math.abs(top - Math.max(...this.detentTops)) >= 1)
|
|
302
|
+
return top;
|
|
303
|
+
return top + REFRESH_THRESHOLD;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private get settledTops(): number[] {
|
|
307
|
+
return this.detents.map((_, index) => this.settledTop(index));
|
|
308
|
+
}
|
|
309
|
+
|
|
243
310
|
private layout() {
|
|
244
311
|
const { surface, content, grabber } = this.elements!;
|
|
245
|
-
const width = sheetWidth(this.viewport.width);
|
|
312
|
+
const width = sheetWidth(this.viewport.width, this.config.isInline);
|
|
246
313
|
const radius = `${this.config.cornerRadius ?? DEFAULT_CORNER_RADIUS}px`;
|
|
247
314
|
Object.assign(surface.style, {
|
|
248
315
|
left: `${(this.viewport.width - width) / 2}px`,
|
|
@@ -257,6 +324,10 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
257
324
|
content.style.width = `${width}px`;
|
|
258
325
|
content.style.height = `${this.maximumDetentValue}px`;
|
|
259
326
|
grabber.style.display = this.config.grabberVisible ? '' : 'none';
|
|
327
|
+
this.elements!.refreshSpinner.style.color =
|
|
328
|
+
this.config.refreshIndicatorColor != null
|
|
329
|
+
? toCSSColor(this.config.refreshIndicatorColor)
|
|
330
|
+
: 'GrayText';
|
|
260
331
|
|
|
261
332
|
let height = contentHeight(this.config.detents, this.maximumDetentValue);
|
|
262
333
|
if (this.config.keyboardBehavior === 'resize') {
|
|
@@ -270,6 +341,38 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
270
341
|
if (!this.elements) return;
|
|
271
342
|
this.elements.surface.style.transform = `translate3d(0, ${top}px, 0)`;
|
|
272
343
|
this.elements.backdrop.style.opacity = String(this.backdropAlpha(top));
|
|
344
|
+
this.renderRefreshIndicator(top);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Centered in the gap a pull uncovers below the lowest detent, fading in
|
|
349
|
+
* as the pull nears the threshold.
|
|
350
|
+
*/
|
|
351
|
+
private renderRefreshIndicator(top: number) {
|
|
352
|
+
const { refreshIndicator, refreshSpinner } = this.elements!;
|
|
353
|
+
const visible =
|
|
354
|
+
this.config.refreshing != null &&
|
|
355
|
+
!this.entering &&
|
|
356
|
+
!this.isDismissing &&
|
|
357
|
+
!this.isOffscreen;
|
|
358
|
+
const lowest = Math.max(...this.detentTops);
|
|
359
|
+
const progress = visible
|
|
360
|
+
? Math.min(Math.max((top - lowest) / REFRESH_THRESHOLD, 0), 1)
|
|
361
|
+
: 0;
|
|
362
|
+
const x = (this.viewport.width - REFRESH_INDICATOR_SIZE) / 2;
|
|
363
|
+
const y = top - (REFRESH_THRESHOLD + REFRESH_INDICATOR_SIZE) / 2;
|
|
364
|
+
refreshIndicator.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
365
|
+
refreshIndicator.style.opacity = String(progress);
|
|
366
|
+
if (progress > 0 && !this.refreshSpin) {
|
|
367
|
+
this.refreshSpin =
|
|
368
|
+
refreshSpinner.animate?.(
|
|
369
|
+
[{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }],
|
|
370
|
+
{ duration: 800, iterations: Infinity }
|
|
371
|
+
) ?? null;
|
|
372
|
+
} else if (progress === 0 && this.refreshSpin) {
|
|
373
|
+
this.refreshSpin.cancel();
|
|
374
|
+
this.refreshSpin = null;
|
|
375
|
+
}
|
|
273
376
|
}
|
|
274
377
|
|
|
275
378
|
/** Fully dimmed at every detent; fades out below the lowest one. */
|
|
@@ -286,7 +389,7 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
286
389
|
|
|
287
390
|
private settle(motion: Motion, velocity?: number) {
|
|
288
391
|
this.top.animateTo(
|
|
289
|
-
this.
|
|
392
|
+
this.settledTop(this.selectedIndex),
|
|
290
393
|
this.motion(motion),
|
|
291
394
|
{
|
|
292
395
|
velocity,
|
|
@@ -324,6 +427,9 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
324
427
|
active: false,
|
|
325
428
|
rawTop: 0,
|
|
326
429
|
sheetDrove: false,
|
|
430
|
+
contentScrolled: false,
|
|
431
|
+
canRefresh: false,
|
|
432
|
+
refreshArmed: false,
|
|
327
433
|
samples: [],
|
|
328
434
|
};
|
|
329
435
|
}
|
|
@@ -351,8 +457,15 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
351
457
|
|
|
352
458
|
private activate(drag: Drag) {
|
|
353
459
|
drag.active = true;
|
|
460
|
+
const isSettled = !this.top.isAnimating;
|
|
354
461
|
this.top.stop();
|
|
355
462
|
drag.rawTop = this.top.value;
|
|
463
|
+
drag.canRefresh =
|
|
464
|
+
this.config.pullToRefreshEnabled &&
|
|
465
|
+
!this.config.dismissOnSwipe &&
|
|
466
|
+
!this.refreshing &&
|
|
467
|
+
isSettled &&
|
|
468
|
+
Math.abs(drag.rawTop - Math.max(...this.detentTops)) < 1;
|
|
356
469
|
const { surface } = this.elements!;
|
|
357
470
|
// A native scroll on an ancestor makes React Native Web cancel the press
|
|
358
471
|
// under the pointer, so dragging from a button doesn't leave it pressed.
|
|
@@ -379,21 +492,33 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
379
492
|
const { scroller } = drag;
|
|
380
493
|
if (scroller) {
|
|
381
494
|
// The browser's own scrolling is prevented for this gesture, so the
|
|
382
|
-
// content is scrolled here whenever the sheet can't move.
|
|
495
|
+
// content is scrolled here whenever the sheet can't move. Up: expand
|
|
496
|
+
// the sheet before scrolling. Down: scroll back to the top before
|
|
497
|
+
// collapsing the sheet, in a separate gesture.
|
|
383
498
|
const minTop = Math.min(...this.detentTops);
|
|
384
499
|
const drive =
|
|
385
|
-
delta < 0
|
|
500
|
+
delta < 0
|
|
501
|
+
? drag.rawTop > minTop + 0.5
|
|
502
|
+
: !drag.contentScrolled && scroller.scrollTop <= 0;
|
|
386
503
|
if (drive) {
|
|
387
504
|
drag.rawTop = Math.max(drag.rawTop + delta, minTop);
|
|
388
505
|
} else {
|
|
389
506
|
scroller.scrollTop -= delta;
|
|
507
|
+
if (delta !== 0) {
|
|
508
|
+
drag.contentScrolled = true;
|
|
509
|
+
}
|
|
390
510
|
}
|
|
391
511
|
drag.sheetDrove = drive;
|
|
392
512
|
} else {
|
|
393
513
|
drag.rawTop += delta;
|
|
394
514
|
drag.sheetDrove = true;
|
|
395
515
|
}
|
|
396
|
-
|
|
516
|
+
const top = this.constrained(drag.rawTop);
|
|
517
|
+
if (drag.canRefresh) {
|
|
518
|
+
drag.refreshArmed =
|
|
519
|
+
top - Math.max(...this.detentTops) >= REFRESH_THRESHOLD;
|
|
520
|
+
}
|
|
521
|
+
this.top.set(top);
|
|
397
522
|
}
|
|
398
523
|
|
|
399
524
|
private release(drag: Drag) {
|
|
@@ -406,7 +531,11 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
406
531
|
|
|
407
532
|
const velocity = drag.sheetDrove ? this.velocity(drag) : 0;
|
|
408
533
|
const top = this.top.value;
|
|
409
|
-
|
|
534
|
+
if (drag.refreshArmed && this.config.refreshing != null) {
|
|
535
|
+
// Settles on the refreshing position; React confirms or ends it.
|
|
536
|
+
this.refreshing = true;
|
|
537
|
+
}
|
|
538
|
+
const tops = this.settledTops;
|
|
410
539
|
const lowest = Math.max(...tops);
|
|
411
540
|
// Where a fling of this velocity would come to rest.
|
|
412
541
|
const projected = top + velocity * 0.2;
|
|
@@ -434,6 +563,9 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
434
563
|
this.listener.onDetentChange(index);
|
|
435
564
|
}
|
|
436
565
|
this.settle(SETTLE, velocity);
|
|
566
|
+
if (drag.refreshArmed) {
|
|
567
|
+
this.listener.onPullToRefresh();
|
|
568
|
+
}
|
|
437
569
|
}
|
|
438
570
|
|
|
439
571
|
/** Abandons the gesture without settling (the exit or the content takes over). */
|
|
@@ -464,7 +596,7 @@ export class SheetPresenter extends ModalPresenter {
|
|
|
464
596
|
|
|
465
597
|
/** Rubber-bands above the tallest detent, and below the lowest one when swiping can't dismiss. */
|
|
466
598
|
private constrained(top: number): number {
|
|
467
|
-
const tops = this.
|
|
599
|
+
const tops = this.settledTops;
|
|
468
600
|
const minTop = Math.min(...tops);
|
|
469
601
|
const maxTop = Math.max(...tops);
|
|
470
602
|
const dimension = Math.max(this.viewport.height, 1);
|
package/src/web/dom.ts
CHANGED
|
@@ -17,6 +17,9 @@ export const ZERO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
|
17
17
|
export const canUseDOM =
|
|
18
18
|
typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
19
19
|
|
|
20
|
+
/** Width and height of the pull-to-refresh indicator. */
|
|
21
|
+
export const REFRESH_INDICATOR_SIZE = 24;
|
|
22
|
+
|
|
20
23
|
/** Converts a color from `processColor` (0xAARRGGBB) to CSS, scaling its alpha by `opacity`. */
|
|
21
24
|
export function toCSSColor(argb: number, opacity = 1): string {
|
|
22
25
|
// As an unsigned 32-bit value, whatever the sign it was passed with.
|
|
@@ -53,6 +56,23 @@ export function readSafeAreaInsets(): Insets {
|
|
|
53
56
|
};
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
/** The part of the safe area insets that `element` overlaps. */
|
|
60
|
+
export function inlineInsets(element: Element): Insets {
|
|
61
|
+
const safeArea = readSafeAreaInsets();
|
|
62
|
+
const rect = element.getBoundingClientRect();
|
|
63
|
+
const clamp = (value: number, max: number) =>
|
|
64
|
+
Math.min(Math.max(value, 0), max);
|
|
65
|
+
return {
|
|
66
|
+
top: clamp(safeArea.top - rect.top, rect.height),
|
|
67
|
+
right: clamp(rect.right - (window.innerWidth - safeArea.right), rect.width),
|
|
68
|
+
bottom: clamp(
|
|
69
|
+
rect.bottom - (window.innerHeight - safeArea.bottom),
|
|
70
|
+
rect.height
|
|
71
|
+
),
|
|
72
|
+
left: clamp(safeArea.left - rect.left, rect.width),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
/**
|
|
57
77
|
* Height of the on-screen keyboard over a layout viewport `layoutHeight` tall.
|
|
58
78
|
* Mobile browsers keep the layout viewport and shrink the visual one.
|