Skip to content

fix(modal): support iOS card view transitions for viewport changes #30520

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
4 changes: 2 additions & 2 deletions core/src/components/modal/animations/ios.enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
}

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

const bodyEl = document.body;

if (isMobile) {
if (isPortrait) {
/**
* Fallback for browsers that does not support `max()` (ex: Firefox)
* No need to worry about statusbar padding since engines like Gecko
Expand Down
4 changes: 2 additions & 2 deletions core/src/components/modal/animations/ios.leave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
.addAnimation(wrapperAnimation);

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

const bodyEl = document.body;

if (isMobile) {
if (isPortrait) {
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
const modalTransform = hasCardModal ? '-10px' : transformOffset;
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
Expand Down
180 changes: 180 additions & 0 deletions core/src/components/modal/animations/ios.transition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { createAnimation } from '@utils/animation/animation';
import { getElementRoot } from '@utils/helpers';

import type { Animation } from '../../../interface';
import { SwipeToCloseDefaults } from '../gestures/swipe-to-close';
import type { ModalAnimationOptions } from '../modal-interface';

/**
* Transition animation from portrait view to landscape view
* This handles the case where a card modal is open in portrait view
* and the user switches to landscape view
*/
export const portraitToLandscapeTransition = (
baseEl: HTMLElement,
opts: ModalAnimationOptions,
duration = 300
): Animation => {
const { presentingEl } = opts;

if (!presentingEl) {
// No transition needed for non-card modals
return createAnimation('portrait-to-landscape-transition');
}

const hasCardModal =
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
const presentingElRoot = getElementRoot(presentingEl);
const bodyEl = document.body;

const baseAnimation = createAnimation('portrait-to-landscape-transition')
.addElement(baseEl)
.easing('cubic-bezier(0.32,0.72,0,1)')
.duration(duration);

const presentingAnimation = createAnimation().beforeStyles({
transform: 'translateY(0)',
'transform-origin': 'top center',
overflow: 'hidden',
});

if (!hasCardModal) {
// Non-card modal: transition from portrait state to landscape state
// Portrait: presentingEl has transform and body has black background
// Landscape: no transform, no body background, modal wrapper opacity changes

const root = getElementRoot(baseEl);
const wrapperAnimation = createAnimation()
.addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
.fromTo('opacity', '1', '1'); // Keep wrapper visible in landscape

const backdropAnimation = createAnimation()
.addElement(root.querySelector('ion-backdrop')!)
.fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible

// Animate presentingEl from portrait state back to normal
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;

presentingAnimation
.addElement(presentingEl)
.afterStyles({
transform: 'translateY(0px) scale(1)',
'border-radius': '0px',
})
.beforeAddWrite(() => bodyEl.style.setProperty('background-color', ''))
.fromTo('transform', fromTransform, 'translateY(0px) scale(1)')
.fromTo('filter', 'contrast(0.85)', 'contrast(1)')
.fromTo('border-radius', '10px 10px 0 0', '0px');

baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]);
} else {
// Card modal: transition from portrait card state to landscape card state
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
const fromTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;
const toTransform = `translateY(-10px) scale(${toPresentingScale})`;

presentingAnimation
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
.fromTo('transform', fromTransform, toTransform)
.fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card

const shadowAnimation = createAnimation()
.addElement(presentingElRoot.querySelector('.modal-shadow')!)
.fromTo('opacity', '0', '0') // Shadow stays hidden in landscape for card modals
.fromTo('transform', fromTransform, toTransform);

baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
}

return baseAnimation;
};

/**
* Transition animation from landscape view to portrait view
* This handles the case where a card modal is open in landscape view
* and the user switches to portrait view
*/
export const landscapeToPortraitTransition = (
baseEl: HTMLElement,
opts: ModalAnimationOptions,
duration = 300
): Animation => {
const { presentingEl } = opts;

if (!presentingEl) {
// No transition needed for non-card modals
return createAnimation('landscape-to-portrait-transition');
}

const hasCardModal =
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
const presentingElRoot = getElementRoot(presentingEl);
const bodyEl = document.body;

const baseAnimation = createAnimation('landscape-to-portrait-transition')
.addElement(baseEl)
.easing('cubic-bezier(0.32,0.72,0,1)')
.duration(duration);

const presentingAnimation = createAnimation().beforeStyles({
transform: 'translateY(0)',
'transform-origin': 'top center',
overflow: 'hidden',
});

if (!hasCardModal) {
// Non-card modal: transition from landscape state to portrait state
const root = getElementRoot(baseEl);
const wrapperAnimation = createAnimation()
.addElement(root.querySelectorAll('.modal-wrapper, .modal-shadow')!)
.fromTo('opacity', '1', '1'); // Keep wrapper visible

const backdropAnimation = createAnimation()
.addElement(root.querySelector('ion-backdrop')!)
.fromTo('opacity', 'var(--backdrop-opacity)', 'var(--backdrop-opacity)'); // Keep backdrop visible

// Animate presentingEl from normal state to portrait state
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
const toTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;

presentingAnimation
.addElement(presentingEl)
.afterStyles({
transform: toTransform,
'border-radius': '10px 10px 0 0',
filter: 'contrast(0.85)',
overflow: 'hidden',
'transform-origin': 'top center',
})
.beforeAddWrite(() => bodyEl.style.setProperty('background-color', 'black'))
.fromTo('transform', 'translateY(0px) scale(1)', toTransform)
.fromTo('filter', 'contrast(1)', 'contrast(0.85)')
.fromTo('border-radius', '0px', '10px 10px 0 0');

baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]);
} else {
// Card modal: transition from landscape card state to portrait card state
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
const transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
const toTransform = `translateY(${transformOffset}) scale(${toPresentingScale})`;

presentingAnimation
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
.fromTo('transform', fromTransform, toTransform)
.fromTo('filter', 'contrast(0.85)', 'contrast(0.85)'); // Keep same contrast for card

const shadowAnimation = createAnimation()
.addElement(presentingElRoot.querySelector('.modal-shadow')!)
.fromTo('opacity', '0', '0') // Shadow stays hidden
.fromTo('transform', fromTransform, toTransform);

baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
}

return baseAnimation;
};
124 changes: 124 additions & 0 deletions core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type { OverlayEventDetail } from '../../utils/overlays-interface';

import { iosEnterAnimation } from './animations/ios.enter';
import { iosLeaveAnimation } from './animations/ios.leave';
import { portraitToLandscapeTransition, landscapeToPortraitTransition } from './animations/ios.transition';
import { mdEnterAnimation } from './animations/md.enter';
import { mdLeaveAnimation } from './animations/md.leave';
import type { MoveSheetToBreakpointOptions } from './gestures/sheet';
Expand Down Expand Up @@ -90,6 +91,12 @@ export class Modal implements ComponentInterface, OverlayInterface {
// Whether or not modal is being dismissed via gesture
private gestureAnimationDismissing = false;

// View transition properties for handling portrait/landscape switches
private resizeListener?: () => void;
private currentViewIsPortrait?: boolean;
private viewTransitionAnimation?: Animation;
private resizeTimeout?: any;

lastFocus?: HTMLElement;
animation?: Animation;

Expand Down Expand Up @@ -378,6 +385,7 @@ export class Modal implements ComponentInterface, OverlayInterface {

disconnectedCallback() {
this.triggerController.removeClickListener();
this.cleanupViewTransitionListener();
}

componentWillLoad() {
Expand Down Expand Up @@ -619,6 +627,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.initSwipeToClose();
}

// Initialize view transition listener for iOS card modals
this.initViewTransitionListener();

unlock();
}

Expand Down Expand Up @@ -816,6 +827,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (this.gesture) {
this.gesture.destroy();
}
this.cleanupViewTransitionListener();
}
this.currentBreakpoint = undefined;
this.animation = undefined;
Expand Down Expand Up @@ -963,6 +975,118 @@ export class Modal implements ComponentInterface, OverlayInterface {
}
};

private initViewTransitionListener() {
// Only enable for iOS card modals when no custom animations are provided
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
return;
}

// Set initial view state
this.currentViewIsPortrait = window.innerWidth < 768;

// Create debounced resize handler
this.resizeListener = () => {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
console.log('View transition triggered by resize');
this.handleViewTransition();
}, 50); // Debounce to avoid excessive calls during active resizing
};

window.addEventListener('resize', this.resizeListener);
}

private handleViewTransition() {
const isPortrait = window.innerWidth < 768;

// Only transition if view state actually changed
if (this.currentViewIsPortrait === isPortrait) {
return;
}

// Cancel any ongoing transition animation
if (this.viewTransitionAnimation) {
this.viewTransitionAnimation.destroy();
this.viewTransitionAnimation = undefined;
}

const { presentingElement } = this;
if (!presentingElement) {
return;
}

// Create transition animation
let transitionAnimation: Animation;
if (this.currentViewIsPortrait && !isPortrait) {
// Portrait to landscape transition
transitionAnimation = portraitToLandscapeTransition(this.el, {
presentingEl: presentingElement,
currentBreakpoint: this.currentBreakpoint,
backdropBreakpoint: this.backdropBreakpoint,
expandToScroll: this.expandToScroll,
});
} else {
// Landscape to portrait transition
transitionAnimation = landscapeToPortraitTransition(this.el, {
presentingEl: presentingElement,
currentBreakpoint: this.currentBreakpoint,
backdropBreakpoint: this.backdropBreakpoint,
expandToScroll: this.expandToScroll,
});
}

// Update state and play animation
this.currentViewIsPortrait = isPortrait;
this.viewTransitionAnimation = transitionAnimation;

transitionAnimation.play().then(() => {
this.viewTransitionAnimation = undefined;

// After orientation transition, recreate the swipe-to-close gesture
// with updated animation that reflects the new presenting element state
this.reinitSwipeToClose();
});
}

private cleanupViewTransitionListener() {
if (this.resizeListener) {
window.removeEventListener('resize', this.resizeListener);
this.resizeListener = undefined;
}

// Clear any pending resize timeout
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = undefined;
}

if (this.viewTransitionAnimation) {
this.viewTransitionAnimation.destroy();
this.viewTransitionAnimation = undefined;
}
}

private reinitSwipeToClose() {
// Only reinitialize if we have a presenting element and are on iOS
if (getIonMode(this) !== 'ios' || !this.presentingElement) {
return;
}

// Clean up existing gesture and animation
if (this.gesture) {
this.gesture.destroy();
this.gesture = undefined;
}

if (this.animation) {
this.animation.destroy();
this.animation = undefined;
}

// Reinitialize the swipe-to-close gesture with current state
this.initSwipeToClose();
}

render() {
const {
handle,
Expand Down
Loading