Skip to content
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

feat(modal): add canDismiss support for card modals #24920

Merged
merged 7 commits into from
Mar 11, 2022
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ ion-modal,prop,animated,boolean,true,false,false
ion-modal,prop,backdropBreakpoint,number,0,false,false
ion-modal,prop,backdropDismiss,boolean,true,false,false
ion-modal,prop,breakpoints,number[] | undefined,undefined,false,false
ion-modal,prop,canDismiss,(() => Promise<boolean>) | boolean | undefined,undefined,false,false
ion-modal,prop,enterAnimation,((baseEl: any, opts?: any) => Animation) | undefined,undefined,false,false
ion-modal,prop,handle,boolean | undefined,undefined,false,false
ion-modal,prop,htmlAttributes,ModalAttributes | undefined,undefined,false,false
Expand Down
10 changes: 10 additions & 0 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,10 @@ export namespace Components {
* The breakpoints to use when creating a sheet modal. Each value in the array must be a decimal between 0 and 1 where 0 indicates the modal is fully closed and 1 indicates the modal is fully open. Values are relative to the height of the modal, not the height of the screen. One of the values in this array must be the value of the `initialBreakpoint` property. For example: [0, .25, .5, 1]
*/
"breakpoints"?: number[];
/**
* Determines whether or not a modal can dismiss when calling the `dismiss` method. If the value is `true` or the value's function returns `true`, the modal will close when trying to dismiss. If the value is `false` or the value's function returns `false`, the modal will not close when trying to dismiss.
*/
"canDismiss"?: undefined | boolean | (() => Promise<boolean>);
/**
* The component to display inside of the modal.
*/
Expand Down Expand Up @@ -1580,6 +1584,7 @@ export namespace Components {
"showBackdrop": boolean;
/**
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
* @deprecated - To prevent modals from dismissing, use canDismiss instead.
*/
"swipeToClose": boolean;
/**
Expand Down Expand Up @@ -5214,6 +5219,10 @@ declare namespace LocalJSX {
* The breakpoints to use when creating a sheet modal. Each value in the array must be a decimal between 0 and 1 where 0 indicates the modal is fully closed and 1 indicates the modal is fully open. Values are relative to the height of the modal, not the height of the screen. One of the values in this array must be the value of the `initialBreakpoint` property. For example: [0, .25, .5, 1]
*/
"breakpoints"?: number[];
/**
* Determines whether or not a modal can dismiss when calling the `dismiss` method. If the value is `true` or the value's function returns `true`, the modal will close when trying to dismiss. If the value is `false` or the value's function returns `false`, the modal will not close when trying to dismiss.
*/
"canDismiss"?: undefined | boolean | (() => Promise<boolean>);
/**
* The component to display inside of the modal.
*/
Expand Down Expand Up @@ -5303,6 +5312,7 @@ declare namespace LocalJSX {
"showBackdrop"?: boolean;
/**
* If `true`, the modal can be swiped to dismiss. Only applies in iOS mode.
* @deprecated - To prevent modals from dismissing, use canDismiss instead.
*/
"swipeToClose"?: boolean;
/**
Expand Down
84 changes: 75 additions & 9 deletions core/src/components/modal/gestures/swipe-to-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { getTimeGivenProgression } from '../../../utils/animation/cubic-bezier';
import { GestureDetail, createGesture } from '../../../utils/gesture';
import { clamp } from '../../../utils/helpers';

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

// Defaults for the card swipe animation
export const SwipeToCloseDefaults = {
MIN_PRESENTING_SCALE: 0.93,
Expand All @@ -15,6 +17,8 @@ export const createSwipeToCloseGesture = (
) => {
const height = el.offsetHeight;
let isOpen = false;
let canDismissBlocksGesture = false;
const canDismissMaxStep = 0.20;

const canStart = (detail: GestureDetail) => {
const target = detail.event.target as HTMLElement | null;
Expand All @@ -35,34 +39,82 @@ export const createSwipeToCloseGesture = (
};

const onStart = () => {
/**
* 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;
animation.progressStart(true, (isOpen) ? 1 : 0);
};

const onMove = (detail: GestureDetail) => {
const step = clamp(0.0001, detail.deltaY / height, 0.9999);

animation.progressStep(step);
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 isAttempingDismissWithCanDismiss = 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 = isAttempingDismissWithCanDismiss ? 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 = isAttempingDismissWithCanDismiss ? calculateSpringStep(step / maxStep) : step;

const clampedStep = clamp(0.0001, processedStep, maxStep);

animation.progressStep(clampedStep);
};

const onEnd = (detail: GestureDetail) => {
const velocity = detail.velocityY;
const step = detail.deltaY / height;

const isAttempingDismissWithCanDismiss = step >= 0 && canDismissBlocksGesture;
const maxStep = isAttempingDismissWithCanDismiss ? canDismissMaxStep : 0.9999;

const processedStep = isAttempingDismissWithCanDismiss ? calculateSpringStep(step / maxStep) : step;

const step = clamp(0.0001, detail.deltaY / height, 0.9999);
const clampedStep = clamp(0.0001, processedStep, maxStep);

const threshold = (detail.deltaY + velocity * 1000) / height;

const shouldComplete = threshold >= 0.5;
/**
* If canDismiss blocks
* the swipe gesture, then the
* animation can never complete until
* canDismiss is checked.
*/
const shouldComplete = !isAttempingDismissWithCanDismiss && threshold >= 0.5;
averyjohnston marked this conversation as resolved.
Show resolved Hide resolved
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], step)[0];
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], step)[0];
newStepValue += getTimeGivenProgression([0, 0], [0.32, 0.72], [0, 1], [1, 1], clampedStep)[0];
}

const duration = (shouldComplete) ? computeDuration(step * height, velocity) : computeDuration((1 - step) * height, velocity);
const duration = (shouldComplete) ? computeDuration(step * height, velocity) : computeDuration((1 - clampedStep) * height, velocity);
isOpen = shouldComplete;

gesture.enable(false);
Expand All @@ -75,7 +127,21 @@ export const createSwipeToCloseGesture = (
})
.progressEnd((shouldComplete) ? 1 : 0, newStepValue, duration);

if (shouldComplete) {
/**
* 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.
*/
if (isAttempingDismissWithCanDismiss && clampedStep > (maxStep / 4)) {
handleCanDismiss(el, animation);
} else if (shouldComplete) {
onDismiss();
}
};
Expand Down
120 changes: 120 additions & 0 deletions core/src/components/modal/gestures/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Animation } from '../../../interface';

export const handleCanDismiss = async (
el: HTMLIonModalElement,
animation: Animation,
) => {
/**
* If canDismiss is not a function
* then we can return early. If canDismiss is `true`,
* then canDismissBlocksGesture is `false` as canDismiss
* will never interrupt the gesture. As a result,
* this code block is never reached. If canDismiss is `false`,
* then we never dismiss.
*/
if (typeof el.canDismiss !== 'function') { return; }

/**
* Run the canDismiss callback.
* If the function returns `true`,
* then we can proceed with dismiss.
*/
const shouldDismiss = await el.canDismiss();
if (!shouldDismiss) { return; }

/**
* If canDismiss resolved after the snap
* back animation finished, we can
* dismiss immediately.
*
* If canDismiss resolved before the snap
* back animation finished, we need to
* wait until the snap back animation is
* done before dismissing.
*/

if (animation.isRunning()) {
animation.onFinish(() => {
el.dismiss(undefined, 'handler')
}, { oneTimeCallback: true })
} else {
el.dismiss(undefined, 'handler');
}
}


/**
* This function lets us simulate a realistic spring-like animation
* when swiping down on the modal.
* There are two forces that we need to use to compute the spring physics:
*
* 1. Stiffness, k: This is a measure of resistance applied a spring.
* 2. Dampening, c: This value has the effect of reducing or preventing oscillation.
*
* Using these two values, we can calculate the Spring Force and the Dampening Force
* to compute the total force applied to a spring.
*
* Spring Force: This force pulls a spring back into its equilibrium position.
* Hooke's Law tells us that that spring force (FS) = kX.
* k is the stiffness of a spring, and X is the displacement of the spring from its
* equilibrium position. In this case, it is the amount by which the free end
* of a spring was displaced (stretched/pushed) from its "relaxed" position.
*
* Dampening Force: This force slows down motion. Without it, a spring would oscillate forever.
* The dampening force, FD, can be found via this formula: FD = -cv
* where c the dampening value and v is velocity.
*
* Therefore, the resulting force that is exerted on the block is:
* F = FS + FD = -kX - cv
*
* Newton's 2nd Law tells us that F = ma:
* ma = -kX - cv.
*
* For Ionic's purposes, we can assume that m = 1:
* a = -kX - cv
*
* Imagine a block attached to the end of a spring. At equilibrium
* the block is at position x = 1.
* Pressing on the block moves it to position x = 0;
* So, to calculate the displacement, we need to take the
* current position and subtract the previous position from it.
* X = x - x0 = 0 - 1 = -1.
*
* For Ionic's purposes, we are only pushing on the spring modal
* so we have a max position of 1.
* As a result, we can expand displacement to this formula:
* X = x - 1
*
* a = -k(x - 1) - cv
*
* We can represent the motion of something as a function of time: f(t) = x.
* The derivative of position gives us the velocity: f'(t)
* The derivative of the velocity gives us the acceleration: f''(t)
*
* We can substitute the formula above with these values:
*
* f"(t) = -k * (f(t) - 1) - c * f'(t)
*
* This is called a differential equation.
*
* We know that at t = 0, we are at x = 0 because the modal does not move: f(0) = 0
* This means our velocity is also zero: f'(0) = 0.
*
* We can cheat a bit and plug the formula into Wolfram Alpha.
* However, we need to pick stiffness and dampening values:
* k = 0.57
* c = 15
*
* I picked these as they are fairly close to native iOS's spring effect
* with the modal.
*
* What we plug in is this: f(0) = 0; f'(0) = 0; f''(t) = -0.57(f(t) - 1) - 15f'(t)
*
* The result is a formula that lets us calculate the acceleration
* for a given time t.
* Note: This is the approximate form of the solution. Wolfram Alpha will
* give you a complex differential equation too.
*/
export const calculateSpringStep = (t: number) => {
return 0.00255275 * 2.71828 ** (-14.9619 * t) - 1.00255 * 2.71828 ** (-0.0380968 * t) + 1
}
Loading