@ionic/core
Version:
Base components for Ionic
310 lines (309 loc) • 12.2 kB
JavaScript
/*!
* (C) Ionic http://ionicframework.com - MIT License
*/
import { getTimeGivenProgression } from "../../../utils/animation/cubic-bezier";
import { isIonContent, findClosestIonContent, disableContentScrollY, resetContentScrollY } from "../../../utils/content/index";
import { createGesture } from "../../../utils/gesture/index";
import { clamp } from "../../../utils/helpers";
import { OVERLAY_GESTURE_PRIORITY } from "../../../utils/overlays";
import { setCardStatusBarDark, setCardStatusBarDefault } from "../utils";
import { calculateSpringStep, canSwipeOnContent, handleCanDismiss } from "./utils";
// Defaults for the card swipe animation
export const SwipeToCloseDefaults = {
MIN_PRESENTING_SCALE: 0.915,
};
export const createSwipeToCloseGesture = (el, animation, statusBarStyle, onDismiss, onDragStart, onDragMove, onDragEnd) => {
/**
* The step value at which a card modal
* is eligible for dismissing via gesture.
*/
const DISMISS_THRESHOLD = 0.5;
const height = el.offsetHeight;
let isOpen = false;
let canDismissBlocksGesture = false;
let contentEl = null;
const canDismissMaxStep = 0.2;
let initialScrollY = true;
let lastStep = 0;
const getScrollY = () => {
if (contentEl && isIonContent(contentEl)) {
return contentEl.scrollY;
/**
* Custom scroll containers are intended to be
* used with virtual scrolling, so we assume
* there is scrolling in this case.
*/
}
else {
return true;
}
};
const canStart = (detail) => {
const target = detail.event.target;
if (target === null || !target.closest) {
return true;
}
/**
* We cannot assume that the `ion-content`
* target will remain consistent between
* swipes. For example, when using
* ion-nav within a card modal it is
* possible to swipe, push a view, and then
* swipe again. The target content will not
* be the same between swipes.
*/
contentEl = findClosestIonContent(target);
if (contentEl) {
return canSwipeOnContent(contentEl);
}
/**
* Card should be swipeable on all
* parts of the modal except for the footer.
*/
const footer = target.closest('ion-footer');
if (footer === null) {
return true;
}
return false;
};
const onStart = (detail) => {
const { deltaY } = detail;
/**
* Firefox automatically selects the header text during drag
* due to the focusable wrapper (tabindex="-1"). Remove any
* selection that may have occurred.
*/
window.getSelection()?.removeAllRanges();
/**
* Get the initial scrollY value so
* that we can correctly reset the scrollY
* prop when the gesture ends.
*/
initialScrollY = getScrollY();
/**
* If canDismiss is anything other than `true`
* then users should be able to swipe down
* until a threshold is hit. At that point,
* the card modal should not proceed any further.
* TODO (FW-937)
* Remove undefined check
*/
canDismissBlocksGesture = el.canDismiss !== undefined && el.canDismiss !== true;
/**
* If we are pulling down, then
* it is possible we are pulling on the
* content. We do not want scrolling to
* happen at the same time as the gesture.
*/
if (deltaY > 0 && contentEl) {
disableContentScrollY(contentEl);
}
animation.progressStart(true, isOpen ? 1 : 0);
onDragStart();
};
const onMove = (detail) => {
const { deltaY } = detail;
/**
* If we are pulling down, then
* it is possible we are pulling on the
* content. We do not want scrolling to
* happen at the same time as the gesture.
*/
if (deltaY > 0 && contentEl) {
disableContentScrollY(contentEl);
}
/**
* If we are swiping on the content
* then the swipe gesture should only
* happen if we are pulling down.
*
* However, if we pull up and
* then down such that the scroll position
* returns to 0, we should be able to swipe
* the card.
*/
const step = detail.deltaY / height;
/**
* Check if user is swiping down and
* if we have a canDismiss value that
* should block the gesture from
* proceeding,
*/
const isAttemptingDismissWithCanDismiss = step >= 0 && canDismissBlocksGesture;
/**
* If we are blocking the gesture from dismissing,
* set the max step value so that the sheet cannot be
* completely hidden.
*/
const maxStep = isAttemptingDismissWithCanDismiss ? canDismissMaxStep : 0.9999;
/**
* If we are blocking the gesture from
* dismissing, calculate the spring modifier value
* this will be added to the starting breakpoint
* value to give the gesture a spring-like feeling.
* Note that the starting breakpoint is always 0,
* so we omit adding 0 to the result.
*/
const processedStep = isAttemptingDismissWithCanDismiss ? calculateSpringStep(step / maxStep) : step;
const clampedStep = clamp(0.0001, processedStep, maxStep);
animation.progressStep(clampedStep);
/**
* When swiping down half way, the status bar style
* should be reset to its default value.
*
* We track lastStep so that we do not fire these
* functions on every onMove, only when the user has
* crossed a certain threshold.
*/
if (clampedStep >= DISMISS_THRESHOLD && lastStep < DISMISS_THRESHOLD) {
setCardStatusBarDefault(statusBarStyle);
/**
* However, if we swipe back up, then the
* status bar style should be set to have light
* text on a dark background.
*/
}
else if (clampedStep < DISMISS_THRESHOLD && lastStep >= DISMISS_THRESHOLD) {
setCardStatusBarDark();
}
lastStep = clampedStep;
const eventDetail = {
currentY: detail.currentY,
deltaY: detail.deltaY,
velocityY: detail.velocityY,
progress: calculateProgress(el, detail.deltaY),
};
onDragMove(eventDetail);
};
const onEnd = (detail) => {
const velocity = detail.velocityY;
const step = detail.deltaY / height;
const isAttemptingDismissWithCanDismiss = step >= 0 && canDismissBlocksGesture;
const maxStep = isAttemptingDismissWithCanDismiss ? canDismissMaxStep : 0.9999;
const processedStep = isAttemptingDismissWithCanDismiss ? calculateSpringStep(step / maxStep) : step;
const clampedStep = clamp(0.0001, processedStep, maxStep);
const threshold = (detail.deltaY + velocity * 1000) / height;
/**
* If canDismiss blocks
* the swipe gesture, then the
* animation can never complete until
* canDismiss is checked.
*/
const shouldComplete = !isAttemptingDismissWithCanDismiss && threshold >= DISMISS_THRESHOLD;
let newStepValue = shouldComplete ? -0.001 : 0.001;
if (!shouldComplete) {
animation.easing('cubic-bezier(1, 0, 0.68, 0.28)');
newStepValue += getTimeGivenProgression([0, 0], [1, 0], [0.68, 0.28], [1, 1], clampedStep)[0];
}
else {
animation.easing('cubic-bezier(0.32, 0.72, 0, 1)');
newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], clampedStep)[0];
}
const duration = shouldComplete
? computeDuration(step * height, velocity)
: computeDuration((1 - clampedStep) * height, velocity);
isOpen = shouldComplete;
gesture.enable(false);
if (contentEl) {
resetContentScrollY(contentEl, initialScrollY);
}
animation
.onFinish(() => {
if (!shouldComplete) {
gesture.enable(true);
}
})
.progressEnd(shouldComplete ? 1 : 0, newStepValue, duration);
/**
* If the canDismiss value blocked the gesture
* from proceeding, then we should ignore whatever
* shouldComplete is. Whether or not the modal
* animation should complete is now determined by
* canDismiss.
*
* If the user swiped >25% of the way
* to the max step, then we should
* check canDismiss. 25% was chosen
* to avoid accidental swipes.
*/
const isAttemptingCanDismiss = isAttemptingDismissWithCanDismiss && clampedStep > maxStep / 4;
if (isAttemptingCanDismiss) {
handleCanDismiss(el, animation);
}
else if (shouldComplete) {
onDismiss();
}
/**
* `shouldComplete` is not enough on its own, since it is always `false`
* when canDismiss blocks the gesture. `canDismiss: false` blocks it and
* never dismisses, because `handleCanDismiss` returns early when
* canDismiss is not a function. A canDismiss function dismisses through
* `handleCanDismiss`, which awaits the callback, so the outcome is not
* known when this event is emitted.
*/
const isDismissing = isAttemptingDismissWithCanDismiss
? isAttemptingCanDismiss && typeof el.canDismiss === 'function'
: shouldComplete;
const eventDetail = {
currentY: detail.currentY,
deltaY: detail.deltaY,
velocityY: detail.velocityY,
progress: calculateProgress(el, detail.deltaY),
isDismissing,
};
onDragEnd(eventDetail);
};
const gesture = createGesture({
el,
gestureName: 'modalSwipeToClose',
gesturePriority: OVERLAY_GESTURE_PRIORITY,
direction: 'y',
threshold: 10,
canStart,
onStart,
onMove,
onEnd,
});
return gesture;
};
const computeDuration = (remaining, velocity) => {
return clamp(400, remaining / Math.abs(velocity * 1.1), 500);
};
/**
* Calculates the progress of the swipe gesture.
*
* The progress is a value between 0 and 1 that represents how far
* the swipe has progressed towards closing the modal.
*
* A value closer to 1 means the modal is closer to being opened,
* while a value closer to 0 means the modal is closer to being closed.
*
* @param el The modal
* @param deltaY The change in Y position (positive when dragging down, negative when dragging up)
* @returns The progress of the swipe gesture
*/
const calculateProgress = (el, deltaY) => {
const windowHeight = window.innerHeight;
// Position when fully open
const modalTop = el.getBoundingClientRect().top;
/**
* The distance between the top of the modal and the bottom of the screen
* is the total distance the modal needs to travel to be fully closed.
*/
const totalDistance = windowHeight - modalTop;
/**
* The pull percentage is how far the user has swiped compared to the total
* distance needed to close the modal.
*/
const pullPercentage = deltaY / totalDistance;
/**
* The progress is the inverse of the pull percentage because
* when the user starts swiping up, the progress should be close to 1,
* and when the user has swiped all the way down, the progress should be
* close to 0.
*/
const progress = 1 - pullPercentage;
// Round to the nearest thousandth to avoid returning very small decimal
const roundedProgress = Math.round(progress * 1000) / 1000;
return Math.max(0, Math.min(1, roundedProgress));
};