Skip to content

Commit 0fd9e82

Browse files
authored
fix(modal): support iOS card view transitions for viewport changes (#30520)
Issue number: resolves #30296 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> Currently, there is no support for moving between an iOS card view (mobile, portrait modal with presenting element) to a non-card view when the resolution changes (e.g., the device goes from a portrait layout to landscape). This causes issues both way because modals that should be card modals when the user transitions to a portrait view stay as non-card modals and modals that were card modals when they were opened but the user goes to landscape view end up with a black box stuck around the edges of the screen. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> With this change, we now fully support transitioning between the two modal views when the resolution changes. This should fix the issue where the background could become stuck and should be a nicer experience for users switching between the two orientations while using modals. I also took the time to clean up the terminology in use here to refer to "mobile view" (as it was meant here) to be portrait view and the other view to be referred to as landscape view. I did this because I had accidentally mixed them up while working on this and I had to do a refactor to fix it, so I'm hoping that by clarifying the terminology now it helps prevent similar mistakes for others in the future. ## Does this introduce a breaking change? - [ ] Yes - [X] No <!-- If this introduces a breaking change: 1. Describe the impact and migration path for existing applications below. 2. Update the BREAKING.md file with the breaking change. 3. Add "BREAKING CHANGE: [...]" to the commit description when merging. See https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer for more information. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> [Relevant test screen](https://ionic-framework-git-fw-6596-ionic1.vercel.app/src/components/modal/test/card?ionic:mode=ios) Dev build: `8.6.3-dev.11751378808.12cc4a5c`
1 parent 73f7b3f commit 0fd9e82

File tree

4 files changed

+357
-7
lines changed

4 files changed

+357
-7
lines changed

core/src/components/modal/animations/ios.enter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
4848
}
4949

5050
if (presentingEl) {
51-
const isMobile = window.innerWidth < 768;
51+
const isPortrait = window.innerWidth < 768;
5252
const hasCardModal =
5353
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
5454
const presentingElRoot = getElementRoot(presentingEl);
@@ -61,7 +61,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
6161

6262
const bodyEl = document.body;
6363

64-
if (isMobile) {
64+
if (isPortrait) {
6565
/**
6666
* Fallback for browsers that does not support `max()` (ex: Firefox)
6767
* No need to worry about statusbar padding since engines like Gecko

core/src/components/modal/animations/ios.leave.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
3535
.addAnimation(wrapperAnimation);
3636

3737
if (presentingEl) {
38-
const isMobile = window.innerWidth < 768;
38+
const isPortrait = window.innerWidth < 768;
3939
const hasCardModal =
4040
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
4141
const presentingElRoot = getElementRoot(presentingEl);
@@ -61,7 +61,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
6161

6262
const bodyEl = document.body;
6363

64-
if (isMobile) {
64+
if (isPortrait) {
6565
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
6666
const modalTransform = hasCardModal ? '-10px' : transformOffset;
6767
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { createAnimation } from '@utils/animation/animation';
2+
import { getElementRoot } from '@utils/helpers';
3+
4+
import type { Animation } from '../../../interface';
5+
import { SwipeToCloseDefaults } from '../gestures/swipe-to-close';
6+
import type { ModalAnimationOptions } from '../modal-interface';
7+
8+
/**
9+
* Transition animation from portrait view to landscape view
10+
* This handles the case where a card modal is open in portrait view
11+
* and the user switches to landscape view
12+
*/
13+
export const portraitToLandscapeTransition = (
14+
baseEl: HTMLElement,
15+
opts: ModalAnimationOptions,
16+
duration = 300
17+
): Animation => {
18+
const { presentingEl } = opts;
19+
20+
if (!presentingEl) {
21+
// No transition needed for non-card modals
22+
return createAnimation('portrait-to-landscape-transition');
23+
}
24+
25+
const presentingElIsCardModal =
26+
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
27+
const presentingElRoot = getElementRoot(presentingEl);
28+
const bodyEl = document.body;
29+
30+
const baseAnimation = createAnimation('portrait-to-landscape-transition')
31+
.addElement(baseEl)
32+
.easing('cubic-bezier(0.32,0.72,0,1)')
33+
.duration(duration);
34+
35+
const presentingAnimation = createAnimation().beforeStyles({
36+
transform: 'translateY(0)',
37+
'transform-origin': 'top center',
38+
overflow: 'hidden',
39+
});
40+
41+
if (!presentingElIsCardModal) {
42+
// The presenting element is not a card modal, so we do not
43+
// need to care about layering and modal-specific styles.
44+
const root = getElementRoot(baseEl);
45+
const wrapperAnimation = createAnimation()
46+
.addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
47+
.fromTo('opacity', '1', '1'); // Keep wrapper visible in landscape
48+
49+
const backdropAnimation = createAnimation()
50+
.addElement(root.querySelector('ion-backdrop')!)
51+
.fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible
52+
53+
// Animate presentingEl from portrait state back to normal
54+
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
55+
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
56+
const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;
57+
58+
presentingAnimation
59+
.addElement(presentingEl)
60+
.afterStyles({
61+
transform: 'translateY(0px) scale(1)',
62+
'border-radius': '0px',
63+
})
64+
.beforeAddWrite(() => bodyEl.style.setProperty('background-color', ''))
65+
.fromTo('transform', fromTransform, 'translateY(0px) scale(1)')
66+
.fromTo('filter', 'contrast(0.85)', 'contrast(1)')
67+
.fromTo('border-radius', '10px 10px 0 0', '0px');
68+
69+
baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]);
70+
} else {
71+
// The presenting element is a card modal, so we do
72+
// need to care about layering and modal-specific styles.
73+
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
74+
const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
75+
const toTransform = `translateY(-10px) scale(${toPresentingScale})`;
76+
77+
presentingAnimation
78+
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
79+
.afterStyles({
80+
transform: toTransform,
81+
})
82+
.fromTo('transform', fromTransform, toTransform)
83+
.fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card
84+
85+
const shadowAnimation = createAnimation()
86+
.addElement(presentingElRoot.querySelector('.modal-shadow')!)
87+
.afterStyles({
88+
transform: toTransform,
89+
})
90+
.fromTo('opacity', '0', '0') // Shadow stays hidden in landscape for card modals
91+
.fromTo('transform', fromTransform, toTransform);
92+
93+
baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
94+
}
95+
96+
return baseAnimation;
97+
};
98+
99+
/**
100+
* Transition animation from landscape view to portrait view
101+
* This handles the case where a card modal is open in landscape view
102+
* and the user switches to portrait view
103+
*/
104+
export const landscapeToPortraitTransition = (
105+
baseEl: HTMLElement,
106+
opts: ModalAnimationOptions,
107+
duration = 300
108+
): Animation => {
109+
const { presentingEl } = opts;
110+
111+
if (!presentingEl) {
112+
// No transition needed for non-card modals
113+
return createAnimation('landscape-to-portrait-transition');
114+
}
115+
116+
const presentingElIsCardModal =
117+
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
118+
const presentingElRoot = getElementRoot(presentingEl);
119+
const bodyEl = document.body;
120+
121+
const baseAnimation = createAnimation('landscape-to-portrait-transition')
122+
.addElement(baseEl)
123+
.easing('cubic-bezier(0.32,0.72,0,1)')
124+
.duration(duration);
125+
126+
const presentingAnimation = createAnimation().beforeStyles({
127+
transform: 'translateY(0)',
128+
'transform-origin': 'top center',
129+
overflow: 'hidden',
130+
});
131+
132+
if (!presentingElIsCardModal) {
133+
// The presenting element is not a card modal, so we do not
134+
// need to care about layering and modal-specific styles.
135+
const root = getElementRoot(baseEl);
136+
const wrapperAnimation = createAnimation()
137+
.addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
138+
.fromTo('opacity', '1', '1'); // Keep wrapper visible
139+
140+
const backdropAnimation = createAnimation()
141+
.addElement(root.querySelector('ion-backdrop')!)
142+
.fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible
143+
144+
// Animate presentingEl from normal state to portrait state
145+
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
146+
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
147+
const toTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;
148+
149+
presentingAnimation
150+
.addElement(presentingEl)
151+
.beforeStyles({
152+
transform: 'translateY(0px) scale(1)',
153+
'transform-origin': 'top center',
154+
overflow: 'hidden',
155+
})
156+
.afterStyles({
157+
transform: toTransform,
158+
'border-radius': '10px 10px 0 0',
159+
filter: 'contrast(0.85)',
160+
overflow: 'hidden',
161+
'transform-origin': 'top center',
162+
})
163+
.beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black'))
164+
.keyframes([
165+
{ offset: 0, transform: 'translateY(0px) scale(1)', filter: 'contrast(1)', borderRadius: '0px' },
166+
{ offset: 0.2, transform: 'translateY(0px) scale(1)', filter: 'contrast(1)', borderRadius: '10px 10px 0 0' },
167+
{ offset: 1, transform: toTransform, filter: 'contrast(0.85)', borderRadius: '10px 10px 0 0' },
168+
]);
169+
170+
baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]);
171+
} else {
172+
// The presenting element is also a card modal, so we need
173+
// to handle layering and modal-specific styles.
174+
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
175+
const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
176+
const toTransform = `translateY(-10px) scale(${toPresentingScale})`;
177+
178+
presentingAnimation
179+
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
180+
.afterStyles({
181+
transform: toTransform,
182+
})
183+
.fromTo('transform', fromTransform, toTransform)
184+
.fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card
185+
186+
const shadowAnimation = createAnimation()
187+
.addElement(presentingElRoot.querySelector('.modal-shadow')!)
188+
.afterStyles({
189+
transform: toTransform,
190+
})
191+
.fromTo('opacity', '0', '0') // Shadow stays hidden
192+
.fromTo('transform', fromTransform, toTransform);
193+
194+
baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
195+
}
196+
197+
return baseAnimation;
198+
};

0 commit comments

Comments
 (0)