Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 22 additions & 27 deletions core/src/components/modal/gestures/sheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { Animation, ModalDragEventDetail } from '../../../interface';
import type { GestureDetail } from '../../../utils/gesture';
import { getBackdropValueForSheet } from '../utils';

import { calculateSpringStep, handleCanDismiss } from './utils';
import { calculateSpringStep, canSwipeOnContent, handleCanDismiss } from './utils';

export interface MoveSheetToBreakpointOptions {
/**
Expand Down Expand Up @@ -82,6 +82,8 @@ export const createSheetGesture = (
};

const contentEl = baseEl.querySelector('ion-content');
// Cache the initial value so the gesture restores it instead of forcing scrolling on.
const initialContentScrollY = contentEl?.scrollY ?? true;
const height = wrapperEl.clientHeight;
let currentBreakpoint = initialBreakpoint;
let offset = 0;
Expand Down Expand Up @@ -261,9 +263,6 @@ export const createSheetGesture = (

const canStart = (detail: GestureDetail) => {
/**
* If we are swiping on the content, swiping should only be possible if the content
* is scrolled all the way to the top so that we do not interfere with scrolling.
*
* We cannot assume that the `ion-content` target will remain consistent between swipes.
* For example, when using ion-nav within a modal it is possible to swipe, push a view,
* and then swipe again. The target content will not be the same between swipes.
Expand All @@ -272,27 +271,11 @@ export const createSheetGesture = (
currentBreakpoint = getCurrentBreakpoint();

/**
* If `expandToScroll` is disabled, we should not allow the swipe gesture
* to start if the content is not scrolled to the top.
* Upwards swipes on the content cannot move the sheet anyway, so this only
* blocks swiping the sheet down from the content.
*/
if (!expandToScroll && contentEl) {
const scrollEl = isIonContent(contentEl) ? getElementRoot(contentEl).querySelector('.inner-scroll') : contentEl;
return scrollEl!.scrollTop === 0;
}

if (currentBreakpoint === 1 && contentEl) {
/**
* The modal should never swipe to close on the content with a refresher.
* Note 1: We cannot solve this by making this gesture have a higher priority than
* the refresher gesture as the iOS native refresh gesture uses a scroll listener in
* addition to a gesture.
*
* Note 2: Do not use getScrollElement here because we need this to be a synchronous
* operation, and getScrollElement is asynchronous.
*/
const scrollEl = isIonContent(contentEl) ? getElementRoot(contentEl).querySelector('.inner-scroll') : contentEl;
const hasRefresherInContent = !!contentEl.querySelector('ion-refresher');
return !hasRefresherInContent && scrollEl!.scrollTop === 0;
if (contentEl && (!expandToScroll || currentBreakpoint === 1)) {
return canSwipeOnContent(contentEl);
}

return true;
Expand Down Expand Up @@ -562,14 +545,14 @@ export const createSheetGesture = (
}

/**
* Enables scrolling immediately if the sheet is about to fully expand
* or if it allows scrolling at any breakpoint. Without this, there would
* Restores the content's scroll setting immediately if the sheet is about to
* fully expand or if it allows scrolling at any breakpoint. Without this, there would
* be a ~500ms delay while the modal animation completes, causing a
* noticeable lag. Native iOS allows scrolling as soon as the gesture is
* released, so we align with that behavior.
*/
if (contentEl && (snapToBreakpoint === breakpoints[breakpoints.length - 1] || !expandToScroll)) {
contentEl.scrollY = true;
contentEl.scrollY = initialContentScrollY;
}

/**
Expand Down Expand Up @@ -768,8 +751,20 @@ export const createSheetGesture = (
onEnd,
});

/**
* Puts the content back the way the app declared it. A sheet can dismiss
* without going through moveSheetToBreakpoint, and an inline modal reuses
* the same element on the next present.
*/
const resetContentScroll = () => {
if (contentEl) {
contentEl.scrollY = initialContentScrollY;
}
};

return {
gesture,
moveSheetToBreakpoint,
resetContentScroll,
};
};
35 changes: 3 additions & 32 deletions core/src/components/modal/gestures/swipe-to-close.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { getTimeGivenProgression } from '@utils/animation/cubic-bezier';
import { isIonContent, findClosestIonContent, disableContentScrollY, resetContentScrollY } from '@utils/content';
import { createGesture } from '@utils/gesture';
import { clamp, getElementRoot } from '@utils/helpers';
import { clamp } from '@utils/helpers';
import { OVERLAY_GESTURE_PRIORITY } from '@utils/overlays';

import type { Animation, ModalDragEventDetail } from '../../../interface';
import type { GestureDetail } from '../../../utils/gesture';
import type { Style as StatusBarStyle } from '../../../utils/native/status-bar';
import { setCardStatusBarDark, setCardStatusBarDefault } from '../utils';

import { calculateSpringStep, handleCanDismiss } from './utils';
import { calculateSpringStep, canSwipeOnContent, handleCanDismiss } from './utils';

// Defaults for the card swipe animation
export const SwipeToCloseDefaults = {
Expand All @@ -35,7 +35,6 @@ export const createSwipeToCloseGesture = (
let isOpen = false;
let canDismissBlocksGesture = false;
let contentEl: HTMLElement | null = null;
let scrollEl: HTMLElement | null = null;
const canDismissMaxStep = 0.2;
let initialScrollY = true;
let lastStep = 0;
Expand All @@ -60,12 +59,6 @@ export const createSwipeToCloseGesture = (
}

/**
* If we are swiping on the content,
* swiping should only be possible if
* the content is scrolled all the way
* to the top so that we do not interfere
* with scrolling.
*
* We cannot assume that the `ion-content`
* target will remain consistent between
* swipes. For example, when using
Expand All @@ -76,29 +69,7 @@ export const createSwipeToCloseGesture = (
*/
contentEl = findClosestIonContent(target);
if (contentEl) {
/**
* The card should never swipe to close
* on the content with a refresher.
* Note: We cannot solve this by making the
* swipeToClose gesture have a higher priority
* than the refresher gesture as the iOS native
* refresh gesture uses a scroll listener in
* addition to a gesture.
*
* Note: Do not use getScrollElement here
* because we need this to be a synchronous
* operation, and getScrollElement is
* asynchronous.
*/
if (isIonContent(contentEl)) {
const root = getElementRoot(contentEl);
scrollEl = root.querySelector('.inner-scroll');
} else {
scrollEl = contentEl;
}

const hasRefresherInContent = !!contentEl.querySelector('ion-refresher');
return !hasRefresherInContent && scrollEl!.scrollTop === 0;
return canSwipeOnContent(contentEl);
}

/**
Expand Down
19 changes: 19 additions & 0 deletions core/src/components/modal/gestures/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import { findRefresherInContent, isIonContent } from '@utils/content';
import { getElementRoot } from '@utils/helpers';
import { GESTURE } from '@utils/overlays';

import type { Animation } from '../../../interface';

/**
* Swiping is only possible when the content is scrolled to the top, so that we
* do not interfere with scrolling, and never on content with a refresher.
*
* Note: We cannot solve the refresher case with gesture priority as the iOS
* native refresh gesture uses a scroll listener in addition to a gesture.
*
* Note: Do not use `getScrollElement` here because we need this to be a
* synchronous operation, and `getScrollElement` is asynchronous.
*/
export const canSwipeOnContent = (contentEl: HTMLElement) => {
const scrollEl = isIonContent(contentEl) ? getElementRoot(contentEl).querySelector('.inner-scroll') : contentEl;
const hasRefresherInContent = !!findRefresherInContent(contentEl);

return !hasRefresherInContent && scrollEl!.scrollTop === 0;
};

export const handleCanDismiss = async (el: HTMLIonModalElement, animation: Animation) => {
/**
* If canDismiss is not a function
Expand Down
11 changes: 10 additions & 1 deletion core/src/components/modal/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export class Modal implements ComponentInterface, OverlayInterface {
private sortedBreakpoints?: number[];
private keyboardOpenCallback?: () => void;
private moveSheetToBreakpoint?: (options: MoveSheetToBreakpointOptions) => Promise<void>;
private resetSheetContentScroll?: () => void;
private inheritedAttributes: Attributes = {};
private statusBarStyle?: StatusBarStyle;

Expand Down Expand Up @@ -779,7 +780,7 @@ export class Modal implements ComponentInterface, OverlayInterface {

ani.progressStart(true, 1);

const { gesture, moveSheetToBreakpoint } = createSheetGesture(
const { gesture, moveSheetToBreakpoint, resetContentScroll } = createSheetGesture(
this.el,
this.backdropEl!,
wrapperEl,
Expand All @@ -803,6 +804,7 @@ export class Modal implements ComponentInterface, OverlayInterface {

this.gesture = gesture;
this.moveSheetToBreakpoint = moveSheetToBreakpoint;
this.resetSheetContentScroll = resetContentScroll;

this.gesture.enable(true);

Expand Down Expand Up @@ -1029,6 +1031,13 @@ export class Modal implements ComponentInterface, OverlayInterface {
if (this.gesture) {
this.gesture.destroy();
}
/**
* The sheet gesture turns content scrolling off while the sheet sits below
* the top breakpoint. Inline modals reuse the same content on the next
* present, so hand it back before the gesture goes away.
*/
this.resetSheetContentScroll?.();
this.resetSheetContentScroll = undefined;
this.cleanupViewTransitionListener();
this.cleanupParentRemovalObserver();
this.cleanupSafeAreaOverrides();
Expand Down
136 changes: 136 additions & 0 deletions core/src/components/modal/test/refresher-scroll-target/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Modal - Refresher with Custom Scroll Target</title>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../../scripts/testing/scripts.js"></script>
<script type="module" src="../../../../../../dist/ionic/ionic.esm.js"></script>

<style>
#content {
position: relative;

display: block;
flex: 1;

height: 100%;
overflow-y: auto;

contain: size style;
}
</style>
</head>

<script type="module">
import { modalController } from '../../../../../dist/ionic/index.esm.js';
window.modalController = modalController;
</script>

<body>
<ion-app>
<div class="ion-page">
<ion-header>
<ion-toolbar>
<ion-title>Refresher with Custom Scroll Target</ion-title>
</ion-toolbar>
</ion-header>

<ion-content class="ion-padding">
<button class="expand" id="card" onclick="presentCardModal()">Card Modal</button>
<button class="expand" id="sheet" onclick="presentSheetModal()">Sheet Modal</button>
<button class="expand" id="sheet-no-expand" onclick="presentSheetModal({ expandToScroll: false })">
Sheet Modal (expandToScroll false)
</button>
<button class="expand" id="sheet-breakpoints" onclick="presentSheetModal(MULTIPLE_BREAKPOINTS)">
Sheet Modal (breakpoints)
</button>
<button
class="expand"
id="sheet-breakpoints-no-expand"
onclick="presentSheetModal({ ...MULTIPLE_BREAKPOINTS, expandToScroll: false })"
>
Sheet Modal (breakpoints, expandToScroll false)
</button>
</ion-content>
</div>
</ion-app>

<script>
const MULTIPLE_BREAKPOINTS = { breakpoints: [0, 0.25, 0.5, 1], initialBreakpoint: 0.5 };

// The refresher is a child of ion-content while the scroll host is a
// sibling of it, matching how virtual scroll viewports are set up.
function createModalContent() {
const element = document.createElement('div');
element.innerHTML = `
<ion-header id="modal-header">
<ion-toolbar>
<ion-title>Modal</ion-title>
</ion-toolbar>
</ion-header>
<ion-content scroll-y="false">
<ion-refresher id="refresher" slot="fixed">
<ion-refresher-content pulling-text="Pull to refresh" refreshing-text="Refreshing..."></ion-refresher-content>
</ion-refresher>
<div id="content" class="ion-padding ion-content-scroll-host">
<ion-list id="list"></ion-list>
</div>
</ion-content>
`;

const list = element.querySelector('#list');
for (let i = 0; i < 30; i++) {
const item = document.createElement('ion-item');
item.textContent = `Item ${i}`;
list.appendChild(item);
}

let refreshCount = 0;
const refresher = element.querySelector('#refresher');
refresher.addEventListener('ionRefresh', () => {
// Hold the refreshing state so the spinner is visible when testing by
// hand, then prepend an item so the refresh leaves something behind.
setTimeout(() => {
refreshCount++;

const item = document.createElement('ion-item');
item.setAttribute('color', 'success');
item.textContent = `Refreshed ${refreshCount}`;
list.prepend(item);

refresher.complete();
}, 1500);
});

return element;
}

async function presentCardModal() {
const modal = await modalController.create({
// ion-app also carries the ion-page class, so the page is the second match.
presentingElement: document.querySelectorAll('.ion-page')[1],
component: createModalContent(),
});
await modal.present();
}

async function presentSheetModal(opts = {}) {
const modal = await modalController.create({
component: createModalContent(),
breakpoints: [0, 1],
initialBreakpoint: 1,
...opts,
});
await modal.present();
}
</script>
</body>
</html>
Loading
Loading