Skip to content

Commit 3738016

Browse files
committed
fix(modal): support ios card view transitions for viewport changes
1 parent bcc730c commit 3738016

File tree

2 files changed

+253
-0
lines changed

2 files changed

+253
-0
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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 mobile view to portrait view
10+
* This handles the case where a card modal is open in mobile view
11+
* and the user switches to portrait view
12+
*/
13+
export const mobileToPortraitTransition = (
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('mobile-to-portrait-transition');
23+
}
24+
25+
const hasCardModal =
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('mobile-to-portrait-transition')
31+
.addElement(baseEl)
32+
.easing('cubic-bezier(0.32,0.72,0,1)')
33+
.duration(duration);
34+
35+
const presentingAnimation = createAnimation();
36+
37+
if (!hasCardModal) {
38+
// Non-card modal: transition from mobile state to portrait state
39+
// Mobile: presentingEl has transform and body has black background
40+
// Portrait: no transform, no body background, modal wrapper opacity changes
41+
42+
const root = getElementRoot(baseEl);
43+
const wrapperAnimation = createAnimation()
44+
.addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
45+
.fromTo('opacity', '1', '1'); // Keep wrapper visible in portrait
46+
47+
const backdropAnimation = createAnimation()
48+
.addElement(root.querySelector('ion-backdrop')!)
49+
.fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible
50+
51+
// Animate presentingEl from mobile state back to normal
52+
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
53+
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
54+
const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;
55+
56+
presentingAnimation
57+
.addElement(presentingEl)
58+
.beforeAddWrite(() => bodyEl.style.setProperty('background-color', ''))
59+
.fromTo('transform', fromTransform, 'translateY(0px) scale(1)')
60+
.fromTo('filter', 'contrast(0.85)', 'contrast(1)')
61+
.fromTo('border-radius', '10px 10px 0 0', '0px');
62+
63+
baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]);
64+
} else {
65+
// Card modal: transition from mobile card state to portrait card state
66+
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
67+
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
68+
const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;
69+
const toTransform = `translateY(-10px) scale(${toPresentingScale})`;
70+
71+
presentingAnimation
72+
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
73+
.fromTo('transform', fromTransform, toTransform)
74+
.fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card
75+
76+
const shadowAnimation = createAnimation()
77+
.addElement(presentingElRoot.querySelector('.modal-shadow')!)
78+
.fromTo('opacity', '0', '0') // Shadow stays hidden in portrait for card modals
79+
.fromTo('transform', fromTransform, toTransform);
80+
81+
baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
82+
}
83+
84+
return baseAnimation;
85+
};
86+
87+
/**
88+
* Transition animation from portrait view to mobile view
89+
* This handles the case where a card modal is open in portrait view
90+
* and the user switches to mobile view
91+
*/
92+
export const portraitToMobileTransition = (
93+
baseEl: HTMLElement,
94+
opts: ModalAnimationOptions,
95+
duration = 300
96+
): Animation => {
97+
const { presentingEl } = opts;
98+
99+
if (!presentingEl) {
100+
// No transition needed for non-card modals
101+
return createAnimation('portrait-to-mobile-transition');
102+
}
103+
104+
const hasCardModal =
105+
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
106+
const presentingElRoot = getElementRoot(presentingEl);
107+
const bodyEl = document.body;
108+
109+
const baseAnimation = createAnimation('portrait-to-mobile-transition')
110+
.addElement(baseEl)
111+
.easing('cubic-bezier(0.32,0.72,0,1)')
112+
.duration(duration);
113+
114+
const presentingAnimation = createAnimation();
115+
116+
if (!hasCardModal) {
117+
// Non-card modal: transition from portrait state to mobile state
118+
const root = getElementRoot(baseEl);
119+
const wrapperAnimation = createAnimation()
120+
.addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
121+
.fromTo('opacity', '1', '1'); // Keep wrapper visible
122+
123+
const backdropAnimation = createAnimation()
124+
.addElement(root.querySelector('ion-backdrop')!)
125+
.fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible
126+
127+
// Animate presentingEl from normal state to mobile state
128+
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
129+
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
130+
const toTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;
131+
132+
presentingAnimation
133+
.addElement(presentingEl)
134+
.beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black'))
135+
.fromTo('transform', 'translateY(0px) scale(1)', toTransform)
136+
.fromTo('filter', 'contrast(1)', 'contrast(0.85)')
137+
.fromTo('border-radius', '0px', '10px 10px 0 0');
138+
139+
baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]);
140+
} else {
141+
// Card modal: transition from portrait card state to mobile card state
142+
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
143+
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
144+
const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
145+
const toTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;
146+
147+
presentingAnimation
148+
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
149+
.fromTo('transform', fromTransform, toTransform)
150+
.fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card
151+
152+
const shadowAnimation = createAnimation()
153+
.addElement(presentingElRoot.querySelector('.modal-shadow')!)
154+
.fromTo('opacity', '0', '0') // Shadow stays hidden
155+
.fromTo('transform', fromTransform, toTransform);
156+
157+
baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
158+
}
159+
160+
return baseAnimation;
161+
};

core/src/components/modal/modal.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import type { OverlayEventDetail } from '../../utils/overlays-interface';
3737

3838
import { iosEnterAnimation } from './animations/ios.enter';
3939
import { iosLeaveAnimation } from './animations/ios.leave';
40+
import { mobileToPortraitTransition, portraitToMobileTransition } from './animations/ios.transition';
4041
import { mdEnterAnimation } from './animations/md.enter';
4142
import { mdLeaveAnimation } from './animations/md.leave';
4243
import type { MoveSheetToBreakpointOptions } from './gestures/sheet';
@@ -89,6 +90,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
8990
// Whether or not modal is being dismissed via gesture
9091
private gestureAnimationDismissing = false;
9192

93+
// View transition properties for handling mobile/portrait switches
94+
private resizeListener?: () => void;
95+
private currentViewIsMobile?: boolean;
96+
private viewTransitionAnimation?: Animation;
97+
9298
lastFocus?: HTMLElement;
9399
animation?: Animation;
94100

@@ -377,6 +383,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
377383

378384
disconnectedCallback() {
379385
this.triggerController.removeClickListener();
386+
this.cleanupViewTransitionListener();
380387
}
381388

382389
componentWillLoad() {
@@ -618,6 +625,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
618625
this.initSwipeToClose();
619626
}
620627

628+
// Initialize view transition listener for iOS card modals
629+
this.initViewTransitionListener();
630+
621631
unlock();
622632
}
623633

@@ -815,6 +825,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
815825
if (this.gesture) {
816826
this.gesture.destroy();
817827
}
828+
this.cleanupViewTransitionListener();
818829
}
819830
this.currentBreakpoint = undefined;
820831
this.animation = undefined;
@@ -950,6 +961,87 @@ export class Modal implements ComponentInterface, OverlayInterface {
950961
}
951962
};
952963

964+
private initViewTransitionListener() {
965+
// Only enable for iOS card modals when no custom animations are provided
966+
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
967+
return;
968+
}
969+
970+
// Set initial view state
971+
this.currentViewIsMobile = window.innerWidth < 768;
972+
973+
// Create debounced resize handler
974+
let resizeTimeout: any;
975+
this.resizeListener = () => {
976+
clearTimeout(resizeTimeout);
977+
resizeTimeout = setTimeout(() => {
978+
this.handleViewTransition();
979+
}, 100); // Debounce for 100ms to avoid excessive calls
980+
};
981+
982+
window.addEventListener('resize', this.resizeListener);
983+
}
984+
985+
private handleViewTransition() {
986+
const isMobile = window.innerWidth < 768;
987+
988+
// Only transition if view state actually changed
989+
if (this.currentViewIsMobile === isMobile) {
990+
return;
991+
}
992+
993+
// Cancel any ongoing transition animation
994+
if (this.viewTransitionAnimation) {
995+
this.viewTransitionAnimation.destroy();
996+
this.viewTransitionAnimation = undefined;
997+
}
998+
999+
const { presentingElement } = this;
1000+
if (!presentingElement) {
1001+
return;
1002+
}
1003+
1004+
// Create transition animation
1005+
let transitionAnimation: Animation;
1006+
if (this.currentViewIsMobile && !isMobile) {
1007+
// Mobile to portrait transition
1008+
transitionAnimation = mobileToPortraitTransition(this.el, {
1009+
presentingEl: presentingElement,
1010+
currentBreakpoint: this.currentBreakpoint,
1011+
backdropBreakpoint: this.backdropBreakpoint,
1012+
expandToScroll: this.expandToScroll,
1013+
});
1014+
} else {
1015+
// Portrait to mobile transition
1016+
transitionAnimation = portraitToMobileTransition(this.el, {
1017+
presentingEl: presentingElement,
1018+
currentBreakpoint: this.currentBreakpoint,
1019+
backdropBreakpoint: this.backdropBreakpoint,
1020+
expandToScroll: this.expandToScroll,
1021+
});
1022+
}
1023+
1024+
// Update state and play animation
1025+
this.currentViewIsMobile = isMobile;
1026+
this.viewTransitionAnimation = transitionAnimation;
1027+
1028+
transitionAnimation.play().then(() => {
1029+
this.viewTransitionAnimation = undefined;
1030+
});
1031+
}
1032+
1033+
private cleanupViewTransitionListener() {
1034+
if (this.resizeListener) {
1035+
window.removeEventListener('resize', this.resizeListener);
1036+
this.resizeListener = undefined;
1037+
}
1038+
1039+
if (this.viewTransitionAnimation) {
1040+
this.viewTransitionAnimation.destroy();
1041+
this.viewTransitionAnimation = undefined;
1042+
}
1043+
}
1044+
9531045
render() {
9541046
const {
9551047
handle,

0 commit comments

Comments
 (0)