diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts index 34940062dd2..c79e8752e22 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 914652878fa..de543acaa54 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 00000000000..6ce2cd75e16 --- /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 6845edcd18d..8f528e658e5 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,