Skip to content

Commit f66c84a

Browse files
committed
fix(modal): dynamically apply safe-area insets based on viewport edge contact
1 parent c54f257 commit f66c84a

File tree

5 files changed

+371
-31
lines changed

5 files changed

+371
-31
lines changed

core/src/components/modal/gestures/sheet.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export const createSheetGesture = (
5252
expandToScroll: boolean,
5353
getCurrentBreakpoint: () => number,
5454
onDismiss: () => void,
55-
onBreakpointChange: (breakpoint: number) => void
55+
onBreakpointChange: (breakpoint: number) => void,
56+
onGestureMove?: () => void
5657
) => {
5758
// Defaults for the sheet swipe animation
5859
const defaultBackdrop = [
@@ -423,6 +424,9 @@ export const createSheetGesture = (
423424

424425
offset = clamp(0.0001, processedStep, maxStep);
425426
animation.progressStep(offset);
427+
428+
// Notify modal of position change for safe-area updates
429+
onGestureMove?.();
426430
};
427431

428432
const onEnd = (detail: GestureDetail) => {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export const createSwipeToCloseGesture = (
2020
el: HTMLIonModalElement,
2121
animation: Animation,
2222
statusBarStyle: StatusBarStyle,
23-
onDismiss: () => void
23+
onDismiss: () => void,
24+
onGestureMove?: () => void
2425
) => {
2526
/**
2627
* The step value at which a card modal
@@ -199,6 +200,9 @@ export const createSwipeToCloseGesture = (
199200

200201
animation.progressStep(clampedStep);
201202

203+
// Notify modal of position change for safe-area updates
204+
onGestureMove?.();
205+
202206
/**
203207
* When swiping down half way, the status bar style
204208
* should be reset to its default value.

core/src/components/modal/modal.tsx

Lines changed: 80 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,10 @@ export class Modal implements ComponentInterface, OverlayInterface {
276276

277277
@Listen('resize', { target: 'window' })
278278
onWindowResize() {
279-
// Only handle resize for iOS card modals when no custom animations are provided
279+
// Update safe-area overrides for all modal types on resize
280+
this.updateSafeAreaOverrides();
281+
282+
// Only handle view transition for iOS card modals when no custom animations are provided
280283
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
281284
return;
282285
}
@@ -592,6 +595,15 @@ export class Modal implements ComponentInterface, OverlayInterface {
592595
await waitForMount();
593596
}
594597

598+
// Set all safe-areas to 0 before modal becomes visible to prevent flash
599+
// for centered dialogs. After animation, updateSafeAreaOverrides() will
600+
// restore values for edges that touch the viewport.
601+
const style = this.el.style;
602+
style.setProperty('--ion-safe-area-top', '0px');
603+
style.setProperty('--ion-safe-area-bottom', '0px');
604+
style.setProperty('--ion-safe-area-left', '0px');
605+
style.setProperty('--ion-safe-area-right', '0px');
606+
595607
writeTask(() => this.el.classList.add('show-modal'));
596608

597609
const hasCardModal = presentingElement !== undefined;
@@ -659,6 +671,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
659671
this.initSwipeToClose();
660672
}
661673

674+
// Now that animation is complete, update safe-area based on actual position
675+
this.updateSafeAreaOverrides();
676+
662677
// Initialize view transition listener for iOS card modals
663678
this.initViewTransitionListener();
664679

@@ -692,33 +707,39 @@ export class Modal implements ComponentInterface, OverlayInterface {
692707

693708
const statusBarStyle = this.statusBarStyle ?? StatusBarStyle.Default;
694709

695-
this.gesture = createSwipeToCloseGesture(el, ani, statusBarStyle, () => {
696-
/**
697-
* While the gesture animation is finishing
698-
* it is possible for a user to tap the backdrop.
699-
* This would result in the dismiss animation
700-
* being played again. Typically this is avoided
701-
* by setting `presented = false` on the overlay
702-
* component; however, we cannot do that here as
703-
* that would prevent the element from being
704-
* removed from the DOM.
705-
*/
706-
this.gestureAnimationDismissing = true;
707-
708-
/**
709-
* Reset the status bar style as the dismiss animation
710-
* starts otherwise the status bar will be the wrong
711-
* color for the duration of the dismiss animation.
712-
* The dismiss method does this as well, but
713-
* in this case it's only called once the animation
714-
* has finished.
715-
*/
716-
setCardStatusBarDefault(this.statusBarStyle);
717-
this.animation!.onFinish(async () => {
718-
await this.dismiss(undefined, GESTURE);
719-
this.gestureAnimationDismissing = false;
720-
});
721-
});
710+
this.gesture = createSwipeToCloseGesture(
711+
el,
712+
ani,
713+
statusBarStyle,
714+
() => {
715+
/**
716+
* While the gesture animation is finishing
717+
* it is possible for a user to tap the backdrop.
718+
* This would result in the dismiss animation
719+
* being played again. Typically this is avoided
720+
* by setting `presented = false` on the overlay
721+
* component; however, we cannot do that here as
722+
* that would prevent the element from being
723+
* removed from the DOM.
724+
*/
725+
this.gestureAnimationDismissing = true;
726+
727+
/**
728+
* Reset the status bar style as the dismiss animation
729+
* starts otherwise the status bar will be the wrong
730+
* color for the duration of the dismiss animation.
731+
* The dismiss method does this as well, but
732+
* in this case it's only called once the animation
733+
* has finished.
734+
*/
735+
setCardStatusBarDefault(this.statusBarStyle);
736+
this.animation!.onFinish(async () => {
737+
await this.dismiss(undefined, GESTURE);
738+
this.gestureAnimationDismissing = false;
739+
});
740+
},
741+
() => this.updateSafeAreaOverrides()
742+
);
722743
this.gesture.enable(true);
723744
}
724745

@@ -755,7 +776,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
755776
this.currentBreakpoint = breakpoint;
756777
this.ionBreakpointDidChange.emit({ breakpoint });
757778
}
758-
}
779+
this.updateSafeAreaOverrides();
780+
},
781+
() => this.updateSafeAreaOverrides()
759782
);
760783

761784
this.gesture = gesture;
@@ -849,6 +872,34 @@ export class Modal implements ComponentInterface, OverlayInterface {
849872
this.cachedPageParent = undefined;
850873
}
851874

875+
/**
876+
* Updates safe-area CSS variable overrides based on whether the modal
877+
* is touching each edge of the viewport. This ensures that modals which
878+
* don't touch an edge (e.g., centered dialogs) don't have unnecessary
879+
* safe-area padding, while full-screen modals properly respect safe areas.
880+
*/
881+
private updateSafeAreaOverrides() {
882+
const wrapper = this.wrapperEl;
883+
if (!wrapper) return;
884+
885+
const rect = wrapper.getBoundingClientRect();
886+
const threshold = 2; // Account for subpixel rendering
887+
888+
const touchingTop = rect.top <= threshold;
889+
const touchingBottom = rect.bottom >= window.innerHeight - threshold;
890+
const touchingLeft = rect.left <= threshold;
891+
const touchingRight = rect.right >= window.innerWidth - threshold;
892+
893+
// Remove override when touching edge (allow inheritance), set to 0 when not touching
894+
const style = this.el.style;
895+
touchingTop ? style.removeProperty('--ion-safe-area-top') : style.setProperty('--ion-safe-area-top', '0px');
896+
touchingBottom
897+
? style.removeProperty('--ion-safe-area-bottom')
898+
: style.setProperty('--ion-safe-area-bottom', '0px');
899+
touchingLeft ? style.removeProperty('--ion-safe-area-left') : style.setProperty('--ion-safe-area-left', '0px');
900+
touchingRight ? style.removeProperty('--ion-safe-area-right') : style.setProperty('--ion-safe-area-right', '0px');
901+
}
902+
852903
private sheetOnDismiss() {
853904
/**
854905
* While the gesture animation is finishing
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Modal - Safe Area</title>
6+
<meta
7+
name="viewport"
8+
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
<style>
16+
/**
17+
* Simulate safe-area insets for testing.
18+
* These values represent typical iPad safe areas.
19+
*/
20+
:root {
21+
--ion-safe-area-top: 44px;
22+
--ion-safe-area-bottom: 34px;
23+
--ion-safe-area-left: 0px;
24+
--ion-safe-area-right: 0px;
25+
}
26+
27+
.fullscreen-modal {
28+
--width: 100%;
29+
--height: 100%;
30+
}
31+
</style>
32+
</head>
33+
34+
<body>
35+
<ion-app>
36+
<div class="ion-page" id="main-page">
37+
<ion-header>
38+
<ion-toolbar>
39+
<ion-title>Modal - Safe Area</ion-title>
40+
</ion-toolbar>
41+
</ion-header>
42+
43+
<ion-content class="ion-padding">
44+
<p>Test safe-area handling in modals on tablet-sized screens.</p>
45+
46+
<ion-list>
47+
<ion-item>
48+
<ion-label>
49+
<h2>Default Modal</h2>
50+
<p>Centered dialog on tablet - should NOT have safe-area padding</p>
51+
</ion-label>
52+
<ion-button slot="end" id="default-modal" onclick="presentDefaultModal()">Present</ion-button>
53+
</ion-item>
54+
55+
<ion-item>
56+
<ion-label>
57+
<h2>Fullscreen Modal</h2>
58+
<p>Full screen on tablet - should have safe-area padding</p>
59+
</ion-label>
60+
<ion-button slot="end" id="fullscreen-modal" onclick="presentFullscreenModal()">Present</ion-button>
61+
</ion-item>
62+
63+
<ion-item>
64+
<ion-label>
65+
<h2>Sheet Modal (Partial)</h2>
66+
<p>At 0.5 breakpoint - should have bottom safe-area only</p>
67+
</ion-label>
68+
<ion-button slot="end" id="sheet-modal-partial" onclick="presentSheetModalPartial()">Present</ion-button>
69+
</ion-item>
70+
71+
<ion-item>
72+
<ion-label>
73+
<h2>Sheet Modal (Full)</h2>
74+
<p>At 1.0 breakpoint - should have bottom safe-area (handle creates top gap)</p>
75+
</ion-label>
76+
<ion-button slot="end" id="sheet-modal-full" onclick="presentSheetModalFull()">Present</ion-button>
77+
</ion-item>
78+
79+
<ion-item>
80+
<ion-label>
81+
<h2>Card Modal (iOS)</h2>
82+
<p>Card presentation - should have safe-area padding</p>
83+
</ion-label>
84+
<ion-button slot="end" id="card-modal" onclick="presentCardModal()">Present</ion-button>
85+
</ion-item>
86+
</ion-list>
87+
</ion-content>
88+
</div>
89+
</ion-app>
90+
91+
<script>
92+
function createModalContent(title) {
93+
const element = document.createElement('div');
94+
element.innerHTML = `
95+
<ion-header>
96+
<ion-toolbar>
97+
<ion-title>${title}</ion-title>
98+
<ion-buttons slot="end">
99+
<ion-button class="dismiss" onclick="dismissModal()">Close</ion-button>
100+
</ion-buttons>
101+
</ion-toolbar>
102+
</ion-header>
103+
<ion-content class="ion-padding">
104+
<h1>Modal Content</h1>
105+
<p>This modal tests safe-area handling.</p>
106+
<p>The header should respect safe-area-top when the modal touches the top edge.</p>
107+
<p>The footer should respect safe-area-bottom when the modal touches the bottom edge.</p>
108+
</ion-content>
109+
<ion-footer>
110+
<ion-toolbar>
111+
<ion-title>Footer</ion-title>
112+
</ion-toolbar>
113+
</ion-footer>
114+
`;
115+
return element;
116+
}
117+
118+
let currentModal = null;
119+
120+
async function dismissModal() {
121+
if (currentModal) {
122+
await currentModal.dismiss();
123+
currentModal = null;
124+
}
125+
}
126+
127+
async function presentDefaultModal() {
128+
currentModal = Object.assign(document.createElement('ion-modal'), {
129+
component: createModalContent('Default Modal'),
130+
});
131+
document.body.appendChild(currentModal);
132+
await currentModal.present();
133+
}
134+
135+
async function presentFullscreenModal() {
136+
currentModal = Object.assign(document.createElement('ion-modal'), {
137+
component: createModalContent('Fullscreen Modal'),
138+
});
139+
currentModal.classList.add('fullscreen-modal');
140+
document.body.appendChild(currentModal);
141+
await currentModal.present();
142+
}
143+
144+
async function presentSheetModalPartial() {
145+
currentModal = Object.assign(document.createElement('ion-modal'), {
146+
component: createModalContent('Sheet Modal (Partial)'),
147+
initialBreakpoint: 0.5,
148+
breakpoints: [0, 0.25, 0.5, 0.75, 1],
149+
});
150+
document.body.appendChild(currentModal);
151+
await currentModal.present();
152+
}
153+
154+
async function presentSheetModalFull() {
155+
currentModal = Object.assign(document.createElement('ion-modal'), {
156+
component: createModalContent('Sheet Modal (Full)'),
157+
initialBreakpoint: 1,
158+
breakpoints: [0, 0.5, 1],
159+
});
160+
document.body.appendChild(currentModal);
161+
await currentModal.present();
162+
}
163+
164+
async function presentCardModal() {
165+
const presentingElement = document.getElementById('main-page');
166+
currentModal = Object.assign(document.createElement('ion-modal'), {
167+
component: createModalContent('Card Modal'),
168+
presentingElement: presentingElement,
169+
});
170+
document.body.appendChild(currentModal);
171+
await currentModal.present();
172+
}
173+
</script>
174+
</body>
175+
</html>

0 commit comments

Comments
 (0)