From 0fd9e824508333a53175d7da5f681fc3126a2394 Mon Sep 17 00:00:00 2001 From: Shane Date: Wed, 9 Jul 2025 12:15:06 -0700 Subject: [PATCH] fix(modal): support iOS card view transitions for viewport changes (#30520) Issue number: resolves #30296 --------- ## What is the current behavior? 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? 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 ## Other information [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` --- .../components/modal/animations/ios.enter.ts | 4 +- .../components/modal/animations/ios.leave.ts | 4 +- .../modal/animations/ios.transition.ts | 198 ++++++++++++++++++ core/src/components/modal/modal.tsx | 158 +++++++++++++- 4 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 core/src/components/modal/animations/ios.transition.ts diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts index 34940062dd..c79e8752e2 100644 --- a/core/src/components/modal/animations/ios.enter.ts +++ b/core/src/components/modal/animations/ios.enter.ts @@ -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); @@ -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 diff --git a/core/src/components/modal/animations/ios.leave.ts b/core/src/components/modal/animations/ios.leave.ts index 914652878f..de543acaa5 100644 --- a/core/src/components/modal/animations/ios.leave.ts +++ b/core/src/components/modal/animations/ios.leave.ts @@ -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); @@ -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; diff --git a/core/src/components/modal/animations/ios.transition.ts b/core/src/components/modal/animations/ios.transition.ts new file mode 100644 index 0000000000..6ce2cd75e1 --- /dev/null +++ b/core/src/components/modal/animations/ios.transition.ts @@ -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; +}; diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 6845edcd18..8f528e658e 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -1,8 +1,8 @@ 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 { 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 { createLockController } from '@utils/lock-controller'; import { printIonWarning } from '@utils/logging'; @@ -37,11 +37,12 @@ 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'; 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 { setCardStatusBarDark, setCardStatusBarDefault } from './utils'; @@ -90,6 +91,11 @@ 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 currentViewIsPortrait?: boolean; + private viewTransitionAnimation?: Animation; + private resizeTimeout?: any; + lastFocus?: HTMLElement; 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 * automatically be mounted when the modal is created. The @@ -378,6 +397,7 @@ export class Modal implements ComponentInterface, OverlayInterface { disconnectedCallback() { this.triggerController.removeClickListener(); + this.cleanupViewTransitionListener(); } componentWillLoad() { @@ -619,6 +639,9 @@ export class Modal implements ComponentInterface, OverlayInterface { this.initSwipeToClose(); } + // Initialize view transition listener for iOS card modals + this.initViewTransitionListener(); + unlock(); } @@ -816,6 +839,7 @@ export class Modal implements ComponentInterface, OverlayInterface { if (this.gesture) { this.gesture.destroy(); } + this.cleanupViewTransitionListener(); } this.currentBreakpoint = 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() { const { handle,