Skip to content

Commit 7633ddb

Browse files
authored
fix(modal): card modal can now be swiped to close on the content (#25185)
resolves #22046
1 parent 56a07f6 commit 7633ddb

24 files changed

+440
-49
lines changed

core/src/components/modal/gestures/swipe-to-close.ts

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Animation } from '../../../interface';
22
import { getTimeGivenProgression } from '../../../utils/animation/cubic-bezier';
3+
import { isIonContent } from '../../../utils/content';
34
import type { GestureDetail } from '../../../utils/gesture';
45
import { createGesture } from '../../../utils/gesture';
56
import { clamp } from '../../../utils/helpers';
@@ -11,11 +12,46 @@ export const SwipeToCloseDefaults = {
1112
MIN_PRESENTING_SCALE: 0.93,
1213
};
1314

14-
export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: Animation, onDismiss: () => void) => {
15+
export const createSwipeToCloseGesture = (
16+
el: HTMLIonModalElement,
17+
contentEl: HTMLElement,
18+
scrollEl: HTMLElement,
19+
animation: Animation,
20+
onDismiss: () => void
21+
) => {
1522
const height = el.offsetHeight;
1623
let isOpen = false;
1724
let canDismissBlocksGesture = false;
1825
const canDismissMaxStep = 0.2;
26+
const getScrollY = () => {
27+
if (isIonContent(contentEl)) {
28+
return (contentEl as HTMLIonContentElement).scrollY;
29+
/**
30+
* Custom scroll containers are intended to be
31+
* used with virtual scrolling, so we assume
32+
* there is scrolling in this case.
33+
*/
34+
} else {
35+
return true;
36+
}
37+
};
38+
const initialScrollY = getScrollY();
39+
40+
const disableContentScroll = () => {
41+
if (isIonContent(contentEl)) {
42+
(contentEl as HTMLIonContentElement).scrollY = false;
43+
} else {
44+
contentEl.style.setProperty('overflow', 'hidden');
45+
}
46+
};
47+
48+
const resetContentScroll = () => {
49+
if (isIonContent(contentEl)) {
50+
(contentEl as HTMLIonContentElement).scrollY = initialScrollY;
51+
} else {
52+
contentEl.style.removeProperty('overflow');
53+
}
54+
};
1955

2056
const canStart = (detail: GestureDetail) => {
2157
const target = detail.event.target as HTMLElement | null;
@@ -24,17 +60,32 @@ export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: An
2460
return true;
2561
}
2662

27-
const contentOrFooter = target.closest('ion-content, ion-footer');
28-
if (contentOrFooter === null) {
63+
/**
64+
* If we are swiping on the content,
65+
* swiping should only be possible if
66+
* the content is scrolled all the way
67+
* to the top so that we do not interfere
68+
* with scrolling.
69+
*/
70+
const content = target.closest('ion-content');
71+
if (content) {
72+
return scrollEl.scrollTop === 0;
73+
}
74+
75+
/**
76+
* Card should be swipeable on all
77+
* parts of the modal except for the footer.
78+
*/
79+
const footer = target.closest('ion-footer');
80+
if (footer === null) {
2981
return true;
3082
}
31-
// Target is in the content or the footer so do not start the gesture.
32-
// We could be more nuanced here and allow it for content that
33-
// does not need to scroll.
83+
3484
return false;
3585
};
3686

37-
const onStart = () => {
87+
const onStart = (detail: GestureDetail) => {
88+
const { deltaY } = detail;
3889
/**
3990
* If canDismiss is anything other than `true`
4091
* then users should be able to swipe down
@@ -43,11 +94,46 @@ export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: An
4394
* TODO (FW-937)
4495
* Remove undefined check
4596
*/
97+
4698
canDismissBlocksGesture = el.canDismiss !== undefined && el.canDismiss !== true;
99+
100+
/**
101+
* If we are pulling down, then
102+
* it is possible we are pulling on the
103+
* content. We do not want scrolling to
104+
* happen at the same time as the gesture.
105+
*/
106+
if (deltaY > 0) {
107+
disableContentScroll();
108+
}
109+
47110
animation.progressStart(true, isOpen ? 1 : 0);
48111
};
49112

50113
const onMove = (detail: GestureDetail) => {
114+
const { deltaY } = detail;
115+
116+
/**
117+
* If we are pulling down, then
118+
* it is possible we are pulling on the
119+
* content. We do not want scrolling to
120+
* happen at the same time as the gesture.
121+
*/
122+
if (deltaY > 0) {
123+
disableContentScroll();
124+
}
125+
126+
/**
127+
* If we are swiping on the content
128+
* then the swipe gesture should only
129+
* happen if we are pulling down.
130+
*
131+
* However, if we pull up and
132+
* then down such that the scroll position
133+
* returns to 0, we should be able to swipe
134+
* the card.
135+
*/
136+
51137
const step = detail.deltaY / height;
52138

53139
/**
@@ -117,6 +203,8 @@ export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: An
117203

118204
gesture.enable(false);
119205

206+
resetContentScroll();
207+
120208
animation
121209
.onFinish(() => {
122210
if (!shouldComplete) {

core/src/components/modal/modal.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
OverlayEventDetail,
1616
OverlayInterface,
1717
} from '../../interface';
18+
import { getScrollElement, findIonContent, printIonContentErrorMsg } from '../../utils/content';
1819
import { CoreDelegate, attachComponent, detachComponent } from '../../utils/framework-delegate';
1920
import { raf } from '../../utils/helpers';
2021
import { KEYBOARD_DID_OPEN } from '../../utils/keyboard/keyboard';
@@ -283,11 +284,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
283284
@Event({ eventName: 'didDismiss' }) didDismissShorthand!: EventEmitter<OverlayEventDetail>;
284285

285286
@Watch('swipeToClose')
286-
swipeToCloseChanged(enable: boolean) {
287+
async swipeToCloseChanged(enable: boolean) {
287288
if (this.gesture) {
288289
this.gesture.enable(enable);
289290
} else if (enable) {
290-
this.initSwipeToClose();
291+
await this.initSwipeToClose();
291292
}
292293
}
293294

@@ -474,7 +475,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
474475
* not run canDismiss on swipe as there would be no swipe gesture created.
475476
*/
476477
} else if (this.swipeToClose || (this.canDismiss !== undefined && this.presentingElement !== undefined)) {
477-
this.initSwipeToClose();
478+
await this.initSwipeToClose();
478479
}
479480

480481
/* tslint:disable-next-line */
@@ -504,17 +505,27 @@ export class Modal implements ComponentInterface, OverlayInterface {
504505
this.currentTransition = undefined;
505506
}
506507

507-
private initSwipeToClose() {
508+
private async initSwipeToClose() {
508509
if (getIonMode(this) !== 'ios') {
509510
return;
510511
}
511512

513+
const { el } = this;
514+
512515
// All of the elements needed for the swipe gesture
513516
// should be in the DOM and referenced by now, except
514517
// for the presenting el
515518
const animationBuilder = this.leaveAnimation || config.get('modalLeave', iosLeaveAnimation);
516-
const ani = (this.animation = animationBuilder(this.el, { presentingEl: this.presentingElement }));
517-
this.gesture = createSwipeToCloseGesture(this.el, ani, () => {
519+
const ani = (this.animation = animationBuilder(el, { presentingEl: this.presentingElement }));
520+
521+
const contentEl = findIonContent(el);
522+
if (!contentEl) {
523+
printIonContentErrorMsg(el);
524+
return;
525+
}
526+
const scrollEl = await getScrollElement(contentEl);
527+
528+
this.gesture = createSwipeToCloseGesture(el, contentEl, scrollEl, ani, () => {
518529
/**
519530
* While the gesture animation is finishing
520531
* it is possible for a user to tap the backdrop.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Modal - Card</title>
6+
<meta name="apple-mobile-web-app-capable" content="yes" />
7+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
8+
<meta
9+
name="viewport"
10+
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
11+
/>
12+
<link href="../../../../../../css/ionic.bundle.css" rel="stylesheet" />
13+
<link href="../../../../../../scripts/testing/styles.css" rel="stylesheet" />
14+
<script src="../../../../../../scripts/testing/scripts.js"></script>
15+
<script type="module" src="../../../../../../dist/ionic/ionic.esm.js"></script>
16+
<script type="module">
17+
import { modalController } from '../../../../../dist/ionic/index.esm.js';
18+
window.modalController = modalController;
19+
</script>
20+
<style>
21+
#content {
22+
position: relative;
23+
24+
display: block;
25+
flex: 1;
26+
27+
height: 100%;
28+
overflow-y: auto;
29+
30+
contain: size style;
31+
}
32+
</style>
33+
</head>
34+
35+
<body>
36+
<ion-app>
37+
<div class="ion-page">
38+
<ion-header>
39+
<ion-toolbar>
40+
<ion-title>Card</ion-title>
41+
</ion-toolbar>
42+
</ion-header>
43+
44+
<ion-content class="ion-padding">
45+
<ion-button expand="block" id="card" onclick="presentModal(document.querySelectorAll('.ion-page')[1])"
46+
>Card Modal</ion-button
47+
>
48+
</ion-content>
49+
</div>
50+
</ion-app>
51+
52+
<script>
53+
async function createModal(presentingEl, opts) {
54+
// create component to open
55+
const element = document.createElement('div');
56+
element.innerHTML = `
57+
<ion-header id="modal-header">
58+
<ion-toolbar>
59+
<ion-title>Contacts</ion-title>
60+
<ion-buttons slot="end">
61+
<ion-button class="add">
62+
<ion-icon name="add" slot="icon-only"></ion-icon>
63+
</ion-button>
64+
</ion-buttons>
65+
</ion-toolbar>
66+
</ion-header>
67+
<ion-content scroll-y="false">
68+
<div id="content" class="ion-padding ion-content-scroll-host">
69+
Hello World!
70+
<ion-button class="dismiss">Dismiss Modal</ion-button>
71+
72+
<br />
73+
74+
<p>
75+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vitae lobortis felis, eu sodales enim. Nam
76+
risus nibh, placerat at rutrum ac, vehicula vel velit. Lorem ipsum dolor sit amet, consectetur adipiscing
77+
elit. Vestibulum quis elementum ligula, ac aliquet nulla. Mauris non placerat mauris. Aenean dignissim lacinia
78+
porttitor. Praesent fringilla at est et ullamcorper. In ac ante ac massa porta venenatis ut id nibh. Fusce
79+
felis neque, aliquet in velit vitae, venenatis euismod libero. Donec vulputate, urna sed sagittis tempor, mi
80+
arcu tristique lacus, eget fringilla urna sem eget felis. Fusce dignissim lacus a scelerisque vehicula. Nulla
81+
nec enim nunc. Quisque nec dui eu nibh pulvinar bibendum quis ut nunc. Duis ex odio, sollicitudin ac mollis
82+
nec, fringilla non lacus. Maecenas sed tincidunt urna. Nunc feugiat maximus venenatis. Donec porttitor, felis
83+
eget porttitor tempor, quam nulla dapibus nisl, sit amet posuere sapien sapien malesuada tortor. Pellentesque
84+
habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Quisque luctus, sapien nec
85+
tincidunt efficitur, nibh turpis faucibus felis, in sodales massa augue nec erat. Morbi sollicitudin nisi ex,
86+
et gravida nisi euismod eu. Suspendisse hendrerit dapibus orci, non viverra neque vestibulum id. Quisque vitae
87+
interdum ligula, quis consectetur nibh. Phasellus in mi at erat ultrices semper. Fusce sollicitudin at dolor
88+
ac lobortis. Morbi sit amet sem quis nulla pellentesque imperdiet. Nullam eu sem a enim maximus eleifend non
89+
vulputate leo. Proin quis congue lacus. Pellentesque placerat, quam at tempus pulvinar, nisl ligula tempor
90+
risus, quis pretium arcu odio et nulla. Nullam mollis consequat pharetra. Phasellus dictum velit sed purus
91+
mattis maximus. In molestie eget massa ut dignissim. In a interdum elit. In finibus nibh a mauris lobortis
92+
aliquet. Proin rutrum varius consequat. In mollis dapibus nisl, eu finibus urna viverra ac. Quisque
93+
scelerisque nisl eu suscipit consectetur.
94+
</p>
95+
</div>
96+
</ion-content>
97+
98+
<ion-footer>
99+
<ion-toolbar>
100+
<ion-title>Footer</ion-title>
101+
</ion-toolbar>
102+
</ion-footer>
103+
`;
104+
105+
// listen for close event
106+
const button = element.querySelector('ion-button.dismiss');
107+
button.addEventListener('click', () => {
108+
modalController.dismiss();
109+
});
110+
111+
const create = element.querySelector('ion-button.add');
112+
create.addEventListener('click', async () => {
113+
const topModal = await modalController.getTop();
114+
115+
presentModal(topModal, opts);
116+
});
117+
118+
// present the modal
119+
const modalElement = await modalController.create({
120+
presentingElement: presentingEl,
121+
component: element,
122+
swipeToClose: true,
123+
...opts,
124+
});
125+
return modalElement;
126+
}
127+
128+
async function presentModal(presentingEl, opts) {
129+
const modal = await createModal(presentingEl, opts);
130+
await modal.present();
131+
}
132+
</script>
133+
</body>
134+
</html>

0 commit comments

Comments
 (0)