diff --git a/core/src/components/modal/animations/ios.enter.ts b/core/src/components/modal/animations/ios.enter.ts index 3c8924f2889..34940062dd2 100644 --- a/core/src/components/modal/animations/ios.enter.ts +++ b/core/src/components/modal/animations/ios.enter.ts @@ -41,50 +41,7 @@ export const iosEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio .addElement(baseEl) .easing('cubic-bezier(0.32,0.72,0,1)') .duration(500) - .addAnimation([wrapperAnimation]) - .beforeAddWrite(() => { - if (expandToScroll) { - // Scroll can only be done when the modal is fully expanded. - return; - } - - /** - * There are some browsers that causes flickering when - * dragging the content when scroll is enabled at every - * breakpoint. This is due to the wrapper element being - * transformed off the screen and having a snap animation. - * - * A workaround is to clone the footer element and append - * it outside of the wrapper element. This way, the footer - * is still visible and the drag can be done without - * flickering. The original footer is hidden until the modal - * is dismissed. This maintains the animation of the footer - * when the modal is dismissed. - * - * The workaround needs to be done before the animation starts - * so there are no flickering issues. - */ - const ionFooter = baseEl.querySelector('ion-footer'); - /** - * This check is needed to prevent more than one footer - * from being appended to the shadow root. - * Otherwise, iOS and MD enter animations would append - * the footer twice. - */ - const ionFooterAlreadyAppended = baseEl.shadowRoot!.querySelector('ion-footer'); - if (ionFooter && !ionFooterAlreadyAppended) { - const footerHeight = ionFooter.clientHeight; - const clonedFooter = ionFooter.cloneNode(true) as HTMLIonFooterElement; - - baseEl.shadowRoot!.appendChild(clonedFooter); - ionFooter.style.setProperty('display', 'none'); - ionFooter.setAttribute('aria-hidden', 'true'); - - // Padding is added to prevent some content from being hidden. - const page = baseEl.querySelector('.ion-page') as HTMLElement; - page.style.setProperty('padding-bottom', `${footerHeight}px`); - } - }); + .addAnimation([wrapperAnimation]); if (contentAnimation) { baseAnimation.addAnimation(contentAnimation); diff --git a/core/src/components/modal/animations/ios.leave.ts b/core/src/components/modal/animations/ios.leave.ts index 89ba3ce8427..914652878fa 100644 --- a/core/src/components/modal/animations/ios.leave.ts +++ b/core/src/components/modal/animations/ios.leave.ts @@ -19,7 +19,7 @@ const createLeaveAnimation = () => { * iOS Modal Leave Animation */ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions, duration = 500): Animation => { - const { presentingEl, currentBreakpoint, expandToScroll } = opts; + const { presentingEl, currentBreakpoint } = opts; const root = getElementRoot(baseEl); const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation(); @@ -32,33 +32,7 @@ export const iosLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptio .addElement(baseEl) .easing('cubic-bezier(0.32,0.72,0,1)') .duration(duration) - .addAnimation(wrapperAnimation) - .beforeAddWrite(() => { - if (expandToScroll) { - // Scroll can only be done when the modal is fully expanded. - return; - } - - /** - * If expandToScroll is disabled, we need to swap - * the visibility to the original, so the footer - * dismisses with the modal and doesn't stay - * until the modal is removed from the DOM. - */ - const ionFooter = baseEl.querySelector('ion-footer'); - if (ionFooter) { - const clonedFooter = baseEl.shadowRoot!.querySelector('ion-footer')!; - - ionFooter.style.removeProperty('display'); - ionFooter.removeAttribute('aria-hidden'); - - clonedFooter.style.setProperty('display', 'none'); - clonedFooter.setAttribute('aria-hidden', 'true'); - - const page = baseEl.querySelector('.ion-page') as HTMLElement; - page.style.removeProperty('padding-bottom'); - } - }); + .addAnimation(wrapperAnimation); if (presentingEl) { const isMobile = window.innerWidth < 768; diff --git a/core/src/components/modal/animations/md.enter.ts b/core/src/components/modal/animations/md.enter.ts index fee0efc4f64..97dc0a4b200 100644 --- a/core/src/components/modal/animations/md.enter.ts +++ b/core/src/components/modal/animations/md.enter.ts @@ -37,56 +37,13 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOption // The content animation is only added if scrolling is enabled for // all the breakpoints. - expandToScroll && contentAnimation?.addElement(baseEl.querySelector('.ion-page')!); + !expandToScroll && contentAnimation?.addElement(baseEl.querySelector('.ion-page')!); const baseAnimation = createAnimation() .addElement(baseEl) .easing('cubic-bezier(0.36,0.66,0.04,1)') .duration(280) - .addAnimation([backdropAnimation, wrapperAnimation]) - .beforeAddWrite(() => { - if (expandToScroll) { - // Scroll can only be done when the modal is fully expanded. - return; - } - - /** - * There are some browsers that causes flickering when - * dragging the content when scroll is enabled at every - * breakpoint. This is due to the wrapper element being - * transformed off the screen and having a snap animation. - * - * A workaround is to clone the footer element and append - * it outside of the wrapper element. This way, the footer - * is still visible and the drag can be done without - * flickering. The original footer is hidden until the modal - * is dismissed. This maintains the animation of the footer - * when the modal is dismissed. - * - * The workaround needs to be done before the animation starts - * so there are no flickering issues. - */ - const ionFooter = baseEl.querySelector('ion-footer'); - /** - * This check is needed to prevent more than one footer - * from being appended to the shadow root. - * Otherwise, iOS and MD enter animations would append - * the footer twice. - */ - const ionFooterAlreadyAppended = baseEl.shadowRoot!.querySelector('ion-footer'); - if (ionFooter && !ionFooterAlreadyAppended) { - const footerHeight = ionFooter.clientHeight; - const clonedFooter = ionFooter.cloneNode(true) as HTMLIonFooterElement; - - baseEl.shadowRoot!.appendChild(clonedFooter); - ionFooter.style.setProperty('display', 'none'); - ionFooter.setAttribute('aria-hidden', 'true'); - - // Padding is added to prevent some content from being hidden. - const page = baseEl.querySelector('.ion-page') as HTMLElement; - page.style.setProperty('padding-bottom', `${footerHeight}px`); - } - }); + .addAnimation([backdropAnimation, wrapperAnimation]); if (contentAnimation) { baseAnimation.addAnimation(contentAnimation); diff --git a/core/src/components/modal/animations/md.leave.ts b/core/src/components/modal/animations/md.leave.ts index e453e9339cd..0caa73e0e84 100644 --- a/core/src/components/modal/animations/md.leave.ts +++ b/core/src/components/modal/animations/md.leave.ts @@ -21,7 +21,7 @@ const createLeaveAnimation = () => { * Md Modal Leave Animation */ export const mdLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOptions): Animation => { - const { currentBreakpoint, expandToScroll } = opts; + const { currentBreakpoint } = opts; const root = getElementRoot(baseEl); const { wrapperAnimation, backdropAnimation } = currentBreakpoint !== undefined ? createSheetLeaveAnimation(opts) : createLeaveAnimation(); @@ -32,33 +32,7 @@ export const mdLeaveAnimation = (baseEl: HTMLElement, opts: ModalAnimationOption const baseAnimation = createAnimation() .easing('cubic-bezier(0.47,0,0.745,0.715)') .duration(200) - .addAnimation([backdropAnimation, wrapperAnimation]) - .beforeAddWrite(() => { - if (expandToScroll) { - // Scroll can only be done when the modal is fully expanded. - return; - } - - /** - * If expandToScroll is disabled, we need to swap - * the visibility to the original, so the footer - * dismisses with the modal and doesn't stay - * until the modal is removed from the DOM. - */ - const ionFooter = baseEl.querySelector('ion-footer'); - if (ionFooter) { - const clonedFooter = baseEl.shadowRoot!.querySelector('ion-footer')!; - - ionFooter.style.removeProperty('display'); - ionFooter.removeAttribute('aria-hidden'); - - clonedFooter.style.setProperty('display', 'none'); - clonedFooter.setAttribute('aria-hidden', 'true'); - - const page = baseEl.querySelector('.ion-page') as HTMLElement; - page.style.removeProperty('padding-bottom'); - } - }); + .addAnimation([backdropAnimation, wrapperAnimation]); return baseAnimation; }; diff --git a/core/src/components/modal/gestures/sheet.ts b/core/src/components/modal/gestures/sheet.ts index 68df1a2ecaf..a3f548879f8 100644 --- a/core/src/components/modal/gestures/sheet.ts +++ b/core/src/components/modal/gestures/sheet.ts @@ -84,6 +84,9 @@ export const createSheetGesture = ( let offset = 0; let canDismissBlocksGesture = false; let cachedScrollEl: HTMLElement | null = null; + let cachedFooterEl: HTMLIonFooterElement | null = null; + let cachedFooterYPosition: number | null = null; + let currentFooterState: 'moving' | 'stationary' | null = null; const canDismissMaxStep = 0.95; const maxBreakpoint = breakpoints[breakpoints.length - 1]; const minBreakpoint = breakpoints[0]; @@ -118,33 +121,74 @@ export const createSheetGesture = ( }; /** - * Toggles the visible modal footer when `expandToScroll` is disabled. - * @param footer The footer to show. + * Toggles the footer to an absolute position while moving to prevent + * it from shaking while the sheet is being dragged. + * @param newPosition Whether the footer is in a moving or stationary position. */ - const swapFooterVisibility = (footer: 'original' | 'cloned') => { - const originalFooter = baseEl.querySelector('ion-footer') as HTMLIonFooterElement | null; - - if (!originalFooter) { - return; + const swapFooterPosition = (newPosition: 'moving' | 'stationary') => { + if (!cachedFooterEl) { + cachedFooterEl = baseEl.querySelector('ion-footer') as HTMLIonFooterElement | null; + if (!cachedFooterEl) { + return; + } } - const clonedFooter = wrapperEl.nextElementSibling as HTMLIonFooterElement; - const footerToHide = footer === 'original' ? clonedFooter : originalFooter; - const footerToShow = footer === 'original' ? originalFooter : clonedFooter; - - footerToShow.style.removeProperty('display'); - footerToShow.removeAttribute('aria-hidden'); - - const page = baseEl.querySelector('.ion-page') as HTMLElement; - if (footer === 'original') { - page.style.removeProperty('padding-bottom'); + const page = baseEl.querySelector('.ion-page') as HTMLElement | null; + + currentFooterState = newPosition; + if (newPosition === 'stationary') { + // Reset positioning styles to allow normal document flow + cachedFooterEl.classList.remove('modal-footer-moving'); + cachedFooterEl.style.removeProperty('position'); + cachedFooterEl.style.removeProperty('width'); + cachedFooterEl.style.removeProperty('height'); + cachedFooterEl.style.removeProperty('top'); + cachedFooterEl.style.removeProperty('left'); + page?.style.removeProperty('padding-bottom'); + + // Move to page + page?.appendChild(cachedFooterEl); } else { - const pagePadding = footerToShow.clientHeight; - page.style.setProperty('padding-bottom', `${pagePadding}px`); - } + // Get both the footer and document body positions + const cachedFooterElRect = cachedFooterEl.getBoundingClientRect(); + const bodyRect = document.body.getBoundingClientRect(); + + // Add padding to the parent element to prevent content from being hidden + // when the footer is positioned absolutely. This has to be done before we + // make the footer absolutely positioned or we may accidentally cause the + // sheet to scroll. + const footerHeight = cachedFooterEl.clientHeight; + page?.style.setProperty('padding-bottom', `${footerHeight}px`); + + // Apply positioning styles to keep footer at bottom + cachedFooterEl.classList.add('modal-footer-moving'); + + // Calculate absolute position relative to body + // We need to subtract the body's offsetTop to get true position within document.body + const absoluteTop = cachedFooterElRect.top - bodyRect.top; + const absoluteLeft = cachedFooterElRect.left - bodyRect.left; + + // Capture the footer's current dimensions and hard code them during the drag + cachedFooterEl.style.setProperty('position', 'absolute'); + cachedFooterEl.style.setProperty('width', `${cachedFooterEl.clientWidth}px`); + cachedFooterEl.style.setProperty('height', `${cachedFooterEl.clientHeight}px`); + cachedFooterEl.style.setProperty('top', `${absoluteTop}px`); + cachedFooterEl.style.setProperty('left', `${absoluteLeft}px`); + + // Also cache the footer Y position, which we use to determine if the + // sheet has been moved below the footer. When that happens, we need to swap + // the position back so it will collapse correctly. + cachedFooterYPosition = absoluteTop; + // If there's a toolbar, we need to combine the toolbar height with the footer position + // because the toolbar moves with the drag handle, so when it starts overlapping the footer, + // we need to account for that. + const toolbar = baseEl.querySelector('ion-toolbar') as HTMLIonToolbarElement | null; + if (toolbar) { + cachedFooterYPosition -= toolbar.clientHeight; + } - footerToHide.style.setProperty('display', 'none'); - footerToHide.setAttribute('aria-hidden', 'true'); + document.body.appendChild(cachedFooterEl); + } }; /** @@ -247,12 +291,11 @@ export const createSheetGesture = ( /** * If expandToScroll is disabled, we need to swap - * the footer visibility to the original, so if the modal - * is dismissed, the footer dismisses with the modal - * and doesn't stay on the screen after the modal is gone. + * the footer position to moving so that it doesn't shake + * while the sheet is being dragged. */ if (!expandToScroll) { - swapFooterVisibility('original'); + swapFooterPosition('moving'); } /** @@ -275,6 +318,21 @@ export const createSheetGesture = ( }; const onMove = (detail: GestureDetail) => { + /** + * If `expandToScroll` is disabled, we need to see if we're currently below + * the footer element and the footer is in a stationary position. If so, + * we need to make the stationary the original position so that the footer + * collapses with the sheet. + */ + if (!expandToScroll && cachedFooterYPosition !== null && currentFooterState !== null) { + // Check if we need to swap the footer position + if (detail.currentY >= cachedFooterYPosition && currentFooterState === 'moving') { + swapFooterPosition('stationary'); + } else if (detail.currentY < cachedFooterYPosition && currentFooterState === 'stationary') { + swapFooterPosition('moving'); + } + } + /** * If `expandToScroll` is disabled, and an upwards swipe gesture is done within * the scrollable content, we should not allow the swipe gesture to continue. @@ -431,15 +489,6 @@ export const createSheetGesture = ( */ gesture.enable(false); - /** - * If expandToScroll is disabled, we need to swap - * the footer visibility to the cloned one so the footer - * doesn't flicker when the sheet's height is animated. - */ - if (!expandToScroll && shouldRemainOpen) { - swapFooterVisibility('cloned'); - } - if (shouldPreventDismiss) { handleCanDismiss(baseEl, animation); } else if (!shouldRemainOpen) { @@ -457,11 +506,31 @@ export const createSheetGesture = ( contentEl.scrollY = true; } + /** + * If expandToScroll is disabled and we're animating + * to close the sheet, we need to swap + * the footer position to stationary so that it + * will collapse correctly. We cannot just always swap + * here or it'll be jittery while animating movement. + */ + if (!expandToScroll && snapToBreakpoint === 0) { + swapFooterPosition('stationary'); + } + return new Promise((resolve) => { animation .onFinish( () => { if (shouldRemainOpen) { + /** + * If expandToScroll is disabled, we need to swap + * the footer position to stationary so that it + * will act as it would by default. + */ + if (!expandToScroll) { + swapFooterPosition('stationary'); + } + /** * Once the snapping animation completes, * we need to reset the animation to go diff --git a/core/src/components/modal/modal.ios.scss b/core/src/components/modal/modal.ios.scss index dffb778e020..fc5e25e3d19 100644 --- a/core/src/components/modal/modal.ios.scss +++ b/core/src/components/modal/modal.ios.scss @@ -87,16 +87,3 @@ :host(.modal-sheet) .modal-wrapper { @include border-radius(var(--border-radius), var(--border-radius), 0, 0); } - -// iOS Sheet Modal - Scroll at all breakpoints -// -------------------------------------------------- - -/** - * Sheet modals require an additional padding as mentioned in the - * `core.scss` file. However, there's a workaround that requires - * a cloned footer to be added to the modal. This is only necessary - * because the core styles are not being applied to the cloned footer. - */ -:host(.modal-sheet.modal-no-expand-scroll) ion-footer ion-toolbar:first-of-type { - padding-top: $modal-sheet-padding-top; -} diff --git a/core/src/css/core.scss b/core/src/css/core.scss index 1cf0e8bfb7d..cf7560bd348 100644 --- a/core/src/css/core.scss +++ b/core/src/css/core.scss @@ -55,7 +55,8 @@ body.backdrop-no-scroll { */ html.ios ion-modal.modal-card ion-header ion-toolbar:first-of-type, html.ios ion-modal.modal-sheet ion-header ion-toolbar:first-of-type, -html.ios ion-modal ion-footer ion-toolbar:first-of-type { +html.ios ion-modal ion-footer ion-toolbar:first-of-type, +html.ios ion-footer.modal-footer-moving ion-toolbar:first-of-type { padding-top: $modal-sheet-padding-top; } @@ -74,7 +75,8 @@ html.ios ion-modal.modal-sheet ion-header ion-toolbar:last-of-type { * of toolbars while accounting for * safe area values when in landscape. */ -html.ios ion-modal ion-toolbar { +html.ios ion-modal ion-toolbar, +html.ios .modal-footer-moving ion-toolbar { padding-right: calc(var(--ion-safe-area-right) + 8px); padding-left: calc(var(--ion-safe-area-left) + 8px); }