diff --git a/core/src/components/modal/gestures/swipe-to-close.ts b/core/src/components/modal/gestures/swipe-to-close.ts index 945d010494..1d9f2002fc 100644 --- a/core/src/components/modal/gestures/swipe-to-close.ts +++ b/core/src/components/modal/gestures/swipe-to-close.ts @@ -9,6 +9,7 @@ import { import type { GestureDetail } from '../../../utils/gesture'; import { createGesture } from '../../../utils/gesture'; import { clamp, getElementRoot } from '../../../utils/helpers'; +import { setCardStatusBarDark, setCardStatusBarDefault } from '../utils'; import { calculateSpringStep, handleCanDismiss } from './utils'; @@ -18,6 +19,12 @@ export const SwipeToCloseDefaults = { }; export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: Animation, onDismiss: () => void) => { + /** + * 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; @@ -25,6 +32,7 @@ export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: An let scrollEl: HTMLElement | null = null; const canDismissMaxStep = 0.2; let initialScrollY = true; + let lastStep = 0; const getScrollY = () => { if (contentEl && isIonContent(contentEl)) { return (contentEl as HTMLIonContentElement).scrollY; @@ -187,6 +195,28 @@ export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: An 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(); + + /** + * 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 onEnd = (detail: GestureDetail) => { @@ -208,7 +238,7 @@ export const createSwipeToCloseGesture = (el: HTMLIonModalElement, animation: An * animation can never complete until * canDismiss is checked. */ - const shouldComplete = !isAttempingDismissWithCanDismiss && threshold >= 0.5; + const shouldComplete = !isAttempingDismissWithCanDismiss && threshold >= DISMISS_THRESHOLD; let newStepValue = shouldComplete ? -0.001 : 0.001; if (!shouldComplete) { diff --git a/core/src/components/modal/modal.tsx b/core/src/components/modal/modal.tsx index 4d7725b27e..68ed71e222 100644 --- a/core/src/components/modal/modal.tsx +++ b/core/src/components/modal/modal.tsx @@ -31,6 +31,7 @@ import { mdLeaveAnimation } from './animations/md.leave'; import type { MoveSheetToBreakpointOptions } from './gestures/sheet'; import { createSheetGesture } from './gestures/sheet'; import { createSwipeToCloseGesture } from './gestures/swipe-to-close'; +import { setCardStatusBarDark, setCardStatusBarDefault } from './utils'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. @@ -466,21 +467,31 @@ export class Modal implements ComponentInterface, OverlayInterface { backdropBreakpoint: this.backdropBreakpoint, }); + /** + * TODO (FW-937) - In the next major release of Ionic, all card modals + * will be swipeable by default. canDismiss will be used to determine if the + * modal can be dismissed. This check should change to check the presence of + * presentingElement instead. + * + * If we did not do this check, then not using swipeToClose would mean you could + * not run canDismiss on swipe as there would be no swipe gesture created. + */ + const hasCardModal = this.swipeToClose || (this.canDismiss !== undefined && this.presentingElement !== undefined); + + /** + * We need to change the status bar at the + * start of the animation so that it completes + * by the time the card animation is done. + */ + if (hasCardModal && getIonMode(this) === 'ios') { + setCardStatusBarDark(); + } + await this.currentTransition; if (this.isSheetModal) { this.initSheetGesture(); - - /** - * TODO (FW-937) - In the next major release of Ionic, all card modals - * will be swipeable by default. canDismiss will be used to determine if the - * modal can be dismissed. This check should change to check the presence of - * presentingElement instead. - * - * If we did not do this check, then not using swipeToClose would mean you could - * not run canDismiss on swipe as there would be no swipe gesture created. - */ - } else if (this.swipeToClose || (this.canDismiss !== undefined && this.presentingElement !== undefined)) { + } else if (hasCardModal) { await this.initSwipeToClose(); } @@ -631,6 +642,17 @@ export class Modal implements ComponentInterface, OverlayInterface { return false; } + /** + * We need to start the status bar change + * before the animation so that the change + * finishes when the dismiss animation does. + * TODO (FW-937) + */ + const hasCardModal = this.swipeToClose || (this.canDismiss !== undefined && this.presentingElement !== undefined); + if (hasCardModal && getIonMode(this) === 'ios') { + setCardStatusBarDefault(); + } + /* tslint:disable-next-line */ if (typeof window !== 'undefined' && this.keyboardOpenCallback) { window.removeEventListener(KEYBOARD_DID_OPEN, this.keyboardOpenCallback); diff --git a/core/src/components/modal/utils.ts b/core/src/components/modal/utils.ts index e48e3113c6..5a5ffc907d 100644 --- a/core/src/components/modal/utils.ts +++ b/core/src/components/modal/utils.ts @@ -1,3 +1,6 @@ +import { StatusBar, Style } from '../../utils/native/status-bar'; +import { win } from '../../utils/window'; + /** * Use y = mx + b to * figure out the backdrop value @@ -57,3 +60,31 @@ export const getBackdropValueForSheet = (x: number, backdropBreakpoint: number) return x * slope + b; }; + +/** + * The tablet/desktop card modal activates + * when the window width is >= 768. + * At that point, the presenting element + * is not transformed, so we do not need to + * adjust the status bar color. + * + * Note: We check supportsDefaultStatusBarStyle so that + * Capacitor <= 2 users do not get their status bar + * stuck in an inconsistent state due to a lack of + * support for Style.Default. + */ +export const setCardStatusBarDark = () => { + if (!win || win.innerWidth >= 768 || !StatusBar.supportsDefaultStatusBarStyle()) { + return; + } + + StatusBar.setStyle({ style: Style.Dark }); +}; + +export const setCardStatusBarDefault = () => { + if (!win || win.innerWidth >= 768 || !StatusBar.supportsDefaultStatusBarStyle()) { + return; + } + + StatusBar.setStyle({ style: Style.Default }); +}; diff --git a/core/src/utils/native/status-bar.ts b/core/src/utils/native/status-bar.ts new file mode 100644 index 0000000000..ebe320de8a --- /dev/null +++ b/core/src/utils/native/status-bar.ts @@ -0,0 +1,34 @@ +import { win } from '../window'; + +interface StyleOptions { + style: Style; +} + +export enum Style { + Dark = 'DARK', + Light = 'LIGHT', + Default = 'DEFAULT', +} + +export const StatusBar = { + getEngine() { + return (win as any)?.Capacitor?.isPluginAvailable('StatusBar') && (win as any)?.Capacitor.Plugins.StatusBar; + }, + supportsDefaultStatusBarStyle() { + /** + * The 'DEFAULT' status bar style was added + * to the @capacitor/status-bar plugin in Capacitor 3. + * PluginHeaders is only supported in Capacitor 3+, + * so we can use this to detect Capacitor 3. + */ + return !!(win as any)?.Capacitor?.PluginHeaders; + }, + setStyle(options: StyleOptions) { + const engine = this.getEngine(); + if (!engine) { + return; + } + + engine.setStyle(options); + }, +}; diff --git a/core/src/utils/window/index.ts b/core/src/utils/window/index.ts new file mode 100644 index 0000000000..c34190e0e0 --- /dev/null +++ b/core/src/utils/window/index.ts @@ -0,0 +1,23 @@ +/** + * When accessing the window, it is important + * to account for SSR applications where the + * window is not available. Code that accesses + * window when it is not available will crash. + * Even checking if `window === undefined` will cause + * apps to crash in SSR. + * + * Use win below to access an SSR-safe version + * of the window. + * + * Example 1: + * Before: + * if (window.innerWidth > 768) { ... } + * + * After: + * import { win } from 'path/to/this/file'; + * if (win?.innerWidth > 768) { ... } + * + * Note: Code inside of this if-block will + * not run in an SSR environment. + */ +export const win: Window | undefined = typeof window !== 'undefined' ? window : undefined;