Skip to content

fix(sheet): changing the way sheet footers work during dragging #30433

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 10 commits into
base: main
Choose a base branch
from
Open
45 changes: 1 addition & 44 deletions core/src/components/modal/animations/ios.enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
30 changes: 2 additions & 28 deletions core/src/components/modal/animations/ios.leave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down
45 changes: 1 addition & 44 deletions core/src/components/modal/animations/md.enter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,50 +43,7 @@ export const mdEnterAnimation = (baseEl: HTMLElement, opts: ModalAnimationOption
.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);
Expand Down
30 changes: 2 additions & 28 deletions core/src/components/modal/animations/md.leave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
};
139 changes: 104 additions & 35 deletions core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 footer 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);
}
};

/**
Expand Down Expand Up @@ -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');
}

/**
Expand All @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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<void>((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
Expand Down
Loading
Loading