Skip to content
Open
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
29 changes: 5 additions & 24 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 @@ -261,9 +261,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 +269,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
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
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