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`
This commit is contained in:
Shane
2025-07-09 12:15:06 -07:00
committed by GitHub
parent 73f7b3f839
commit 0fd9e82450
4 changed files with 357 additions and 7 deletions

View File

@ -48,7 +48,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
} }
if (presentingEl) { if (presentingEl) {
const isMobile = window.innerWidth < 768; const isPortrait = window.innerWidth < 768;
const hasCardModal = const hasCardModal =
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
const presentingElRoot = getElementRoot(presentingEl); const presentingElRoot = getElementRoot(presentingEl);
@ -61,7 +61,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
const bodyEl = document.body; const bodyEl = document.body;
if (isMobile) { if (isPortrait) {
/** /**
* Fallback for browsers that does not support `max()` (ex: Firefox) * Fallback for browsers that does not support `max()` (ex: Firefox)
* No need to worry about statusbar padding since engines like Gecko * No need to worry about statusbar padding since engines like Gecko

View File

@ -35,7 +35,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
.addAnimation(wrapperAnimation); .addAnimation(wrapperAnimation);
if (presentingEl) { if (presentingEl) {
const isMobile = window.innerWidth < 768; const isPortrait = window.innerWidth < 768;
const hasCardModal = const hasCardModal =
presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined; presentingEl.tagName === 'ION-MODAL' && (presentingEl as HTMLIonModalElement).presentingElement !== undefined;
const presentingElRoot = getElementRoot(presentingEl); const presentingElRoot = getElementRoot(presentingEl);
@ -61,7 +61,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio
const bodyEl = document.body; 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 transformOffset = !CSS.supports('width', 'max(0px, 1px)') ? '30px' : 'max(30px, var(--ion-safe-area-top))';
const modalTransform = hasCardModal ? '-10px' : transformOffset; const modalTransform = hasCardModal ? '-10px' : transformOffset;
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE; const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;

View File

@ -0,0 +1,198 @@
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 presentingElIsCardModal =
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 (!presentingElIsCardModal) {
// The presenting element is not a card modal, so we do not
// need to care about layering and modal-specific styles.
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 {
// The presenting element is a card modal, so we do
// need to care about layering and modal-specific styles.
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
const toTransform = `translateY(-10px) scale(${toPresentingScale})`;
presentingAnimation
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
.afterStyles({
transform: toTransform,
})
.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')!)
.afterStyles({
transform: toTransform,
})
.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 presentingElIsCardModal =
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 (!presentingElIsCardModal) {
// The presenting element is not a card modal, so we do not
// need to care about layering and modal-specific styles.
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)
.beforeStyles({
transform: 'translateY(0px) scale(1)',
'transform-origin': 'top center',
overflow: 'hidden',
})
.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'))
.keyframes([
{ offset: 0, transform: 'translateY(0px) scale(1)', filter: 'contrast(1)', borderRadius: '0px' },
{ offset: 0.2, transform: 'translateY(0px) scale(1)', filter: 'contrast(1)', borderRadius: '10px 10px 0 0' },
{ offset: 1, transform: toTransform, filter: 'contrast(0.85)', borderRadius: '10px 10px 0 0' },
]);
baseAnimation.addAnimation([presentingAnimation, wrapperAnimation, backdropAnimation]);
} else {
// The presenting element is also a card modal, so we need
// to handle layering and modal-specific styles.
const toPresentingScale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
const fromTransform = `translateY(-10px) scale(${toPresentingScale})`;
const toTransform = `translateY(-10px) scale(${toPresentingScale})`;
presentingAnimation
.addElement(presentingElRoot.querySelector('.modal-wrapper')!)
.afterStyles({
transform: toTransform,
})
.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')!)
.afterStyles({
transform: toTransform,
})
.fromTo('opacity', '0', '0') // Shadow stays hidden
.fromTo('transform', fromTransform, toTransform);
baseAnimation.addAnimation([presentingAnimation, shadowAnimation]);
}
return baseAnimation;
};

View File

@ -1,8 +1,8 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core'; import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, writeTask } from '@stencil/core'; import { Component, Element, Event, Host, Listen, Method, Prop, State, Watch, h, writeTask } from '@stencil/core';
import { findIonContent, printIonContentErrorMsg } from '@utils/content'; import { findIonContent, printIonContentErrorMsg } from '@utils/content';
import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate'; import { CoreDelegate, attachComponent, detachComponent } from '@utils/framework-delegate';
import { raf, inheritAttributes, hasLazyBuild } from '@utils/helpers'; import { raf, inheritAttributes, hasLazyBuild, getElementRoot } from '@utils/helpers';
import type { Attributes } from '@utils/helpers'; import type { Attributes } from '@utils/helpers';
import { createLockController } from '@utils/lock-controller'; import { createLockController } from '@utils/lock-controller';
import { printIonWarning } from '@utils/logging'; import { printIonWarning } from '@utils/logging';
@ -37,11 +37,12 @@ import type { OverlayEventDetail } from '../../utils/overlays-interface';
import { iosEnterAnimation } from './animations/ios.enter'; import { iosEnterAnimation } from './animations/ios.enter';
import { iosLeaveAnimation } from './animations/ios.leave'; import { iosLeaveAnimation } from './animations/ios.leave';
import { portraitToLandscapeTransition, landscapeToPortraitTransition } from './animations/ios.transition';
import { mdEnterAnimation } from './animations/md.enter'; import { mdEnterAnimation } from './animations/md.enter';
import { mdLeaveAnimation } from './animations/md.leave'; import { mdLeaveAnimation } from './animations/md.leave';
import type { MoveSheetToBreakpointOptions } from './gestures/sheet'; import type { MoveSheetToBreakpointOptions } from './gestures/sheet';
import { createSheetGesture } from './gestures/sheet'; import { createSheetGesture } from './gestures/sheet';
import { createSwipeToCloseGesture } from './gestures/swipe-to-close'; import { createSwipeToCloseGesture, SwipeToCloseDefaults } from './gestures/swipe-to-close';
import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from './modal-interface'; import type { ModalBreakpointChangeEventDetail, ModalHandleBehavior } from './modal-interface';
import { setCardStatusBarDark, setCardStatusBarDefault } from './utils'; import { setCardStatusBarDark, setCardStatusBarDefault } from './utils';
@ -90,6 +91,11 @@ export class Modal implements ComponentInterface, OverlayInterface {
// Whether or not modal is being dismissed via gesture // Whether or not modal is being dismissed via gesture
private gestureAnimationDismissing = false; private gestureAnimationDismissing = false;
// View transition properties for handling portrait/landscape switches
private currentViewIsPortrait?: boolean;
private viewTransitionAnimation?: Animation;
private resizeTimeout?: any;
lastFocus?: HTMLElement; lastFocus?: HTMLElement;
animation?: Animation; animation?: Animation;
@ -261,6 +267,19 @@ export class Modal implements ComponentInterface, OverlayInterface {
} }
} }
@Listen('resize', { target: 'window' })
onWindowResize() {
// Only handle resize for iOS card modals when no custom animations are provided
if (getIonMode(this) !== 'ios' || !this.presentingElement || this.enterAnimation || this.leaveAnimation) {
return;
}
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.handleViewTransition();
}, 50); // Debounce to avoid excessive calls during active resizing
}
/** /**
* If `true`, the component passed into `ion-modal` will * If `true`, the component passed into `ion-modal` will
* automatically be mounted when the modal is created. The * automatically be mounted when the modal is created. The
@ -378,6 +397,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
disconnectedCallback() { disconnectedCallback() {
this.triggerController.removeClickListener(); this.triggerController.removeClickListener();
this.cleanupViewTransitionListener();
} }
componentWillLoad() { componentWillLoad() {
@ -619,6 +639,9 @@ export class Modal implements ComponentInterface, OverlayInterface {
this.initSwipeToClose(); this.initSwipeToClose();
} }
// Initialize view transition listener for iOS card modals
this.initViewTransitionListener();
unlock(); unlock();
} }
@ -816,6 +839,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (this.gesture) { if (this.gesture) {
this.gesture.destroy(); this.gesture.destroy();
} }
this.cleanupViewTransitionListener();
} }
this.currentBreakpoint = undefined; this.currentBreakpoint = undefined;
this.animation = undefined; this.animation = undefined;
@ -963,6 +987,134 @@ 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;
}
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() {
// 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) {
// Properly end the progress-based animation at initial state before destroying
// to avoid leaving modal in intermediate swipe position
this.animation.progressEnd(0, 0, 0);
this.animation.destroy();
this.animation = undefined;
}
// Force the modal back to the correct position or it could end up
// in a weird state after destroying the animation
raf(() => {
this.ensureCorrectModalPosition();
this.initSwipeToClose();
});
}
private ensureCorrectModalPosition() {
const { el, presentingElement } = this;
const root = getElementRoot(el);
const wrapperEl = root.querySelector('.modal-wrapper') as HTMLElement | null;
if (wrapperEl) {
wrapperEl.style.transform = 'translateY(0vh)';
wrapperEl.style.opacity = '1';
}
if (presentingElement) {
const isPortrait = window.innerWidth < 768;
if (isPortrait) {
const transformOffset = !CSS.supports('width', 'max(0px, 1px)')
? '30px'
: 'max(30px, var(--ion-safe-area-top))';
const scale = SwipeToCloseDefaults.MIN_PRESENTING_SCALE;
presentingElement.style.transform = `translateY(${transformOffset}) scale(${scale})`;
} else {
presentingElement.style.transform = 'translateY(0px) scale(1)';
}
}
}
render() { render() {
const { const {
handle, handle,