From 814c2e5ccd6d5bfda12bdf13a566cd66ff830d5b Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 19 Feb 2026 08:57:48 -0800 Subject: [PATCH] feat(refresher): add ionPullStart and ionPullEnd events (#30946) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue number: resolves #24524 --------- ## What is the current behavior? There is no way to know when the refresher has fully returned to its inactive state after a pull gesture. The existing `ionStart` event fires when pulling begins, but there is no corresponding end event. Watching the progress property is insufficient because hitting zero doesn’t necessarily mean the user has completed the pull gesture. ## What is the new behavior? Two new events are added to the refresher component: - `ionPullStart`: Emitted when the user begins pulling down (same as `ionStart`, which is now deprecated) - `ionPullEnd`: Emitted when the refresher returns to inactive state, with a `reason` property of `'complete'` or `'cancel'` indicating whether the refresh operation completed successfully or was cancelled This allows you to know both when the user is no longer touching the screen AND when the refresher is ready to be pulled again. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information Test page: https://ionic-framework-git-fw-6591-ionic1.vercel.app/src/components/refresher/test/basic/index.html Current dev build: ``` 8.7.17-dev.11770319814.172b4f50 ``` --------- Co-authored-by: Patrick McDonald <764290+WhatsThatItsPat@users.noreply.github.com> --- core/api.txt | 2 + core/src/components.d.ts | 21 +++++-- .../refresher/refresher-interface.ts | 9 +++ core/src/components/refresher/refresher.tsx | 50 ++++++++++++++++- .../refresher/test/basic/index.html | 11 ++++ .../refresher/test/basic/refresher.e2e.ts | 55 ++++++++++++++++++- core/src/interface.d.ts | 2 +- packages/angular/src/directives/proxies.ts | 14 ++++- .../standalone/src/directives/proxies.ts | 14 ++++- packages/vue/src/proxies.ts | 8 ++- 10 files changed, 172 insertions(+), 14 deletions(-) diff --git a/core/api.txt b/core/api.txt index 33582d0fe5..1a74b49cbe 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1493,6 +1493,8 @@ ion-refresher,method,cancel,cancel() => Promise ion-refresher,method,complete,complete() => Promise ion-refresher,method,getProgress,getProgress() => Promise ion-refresher,event,ionPull,void,true +ion-refresher,event,ionPullEnd,RefresherPullEndEventDetail,true +ion-refresher,event,ionPullStart,void,true ion-refresher,event,ionRefresh,RefresherEventDetail,true ion-refresher,event,ionStart,void,true diff --git a/core/src/components.d.ts b/core/src/components.d.ts index d690184aff..582cbfcd3a 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -29,7 +29,7 @@ import { PickerButton, PickerColumn } from "./components/picker-legacy/picker-in import { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface"; import { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/radio-group/radio-group-interface"; import { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface"; -import { RefresherEventDetail } from "./components/refresher/refresher-interface"; +import { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface"; import { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface"; import { NavigationHookCallback } from "./components/route/route-interface"; import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface"; @@ -67,7 +67,7 @@ export { PickerButton, PickerColumn } from "./components/picker-legacy/picker-in export { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface"; export { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/radio-group/radio-group-interface"; export { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface"; -export { RefresherEventDetail } from "./components/refresher/refresher-interface"; +export { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface"; export { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface"; export { NavigationHookCallback } from "./components/route/route-interface"; export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface"; @@ -2745,7 +2745,7 @@ export namespace Components { */ "mode"?: "ios" | "md"; /** - * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher. + * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example, If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels, the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher. * @default 1 */ "pullFactor": number; @@ -4754,6 +4754,8 @@ declare global { "ionRefresh": RefresherEventDetail; "ionPull": void; "ionStart": void; + "ionPullStart": void; + "ionPullEnd": RefresherPullEndEventDetail; } interface HTMLIonRefresherElement extends Components.IonRefresher, HTMLStencilElement { addEventListener(type: K, listener: (this: HTMLIonRefresherElement, ev: IonRefresherCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; @@ -8014,16 +8016,25 @@ declare namespace LocalJSX { * Emitted while the user is pulling down the content and exposing the refresher. */ "onIonPull"?: (event: IonRefresherCustomEvent) => void; + /** + * Emitted when the refresher has returned to the inactive state after a pull gesture. This fires whether the refresh completed successfully or was canceled. + */ + "onIonPullEnd"?: (event: IonRefresherCustomEvent) => void; + /** + * Emitted when the user begins to start pulling down. + */ + "onIonPullStart"?: (event: IonRefresherCustomEvent) => void; /** * Emitted when the user lets go of the content and has pulled down further than the `pullMin` or pulls the content down and exceeds the pullMax. Updates the refresher state to `refreshing`. The `complete()` method should be called when the async operation has completed. */ "onIonRefresh"?: (event: IonRefresherCustomEvent) => void; /** - * Emitted when the user begins to start pulling down. + * Emitted when the user begins to start pulling down. TODO(FW-7044): Remove this in a major release + * @deprecated Use `ionPullStart` instead. */ "onIonStart"?: (event: IonRefresherCustomEvent) => void; /** - * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example: If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher. + * How much to multiply the pull speed by. To slow the pull animation down, pass a number less than `1`. To speed up the pull, pass a number greater than `1`. The default value is `1` which is equal to the speed of the cursor. If a negative value is passed in, the factor will be `1` instead. For example, If the value passed is `1.2` and the content is dragged by `10` pixels, instead of `10` pixels, the content will be pulled by `12` pixels (an increase of 20 percent). If the value passed is `0.8`, the dragged amount will be `8` pixels, less than the amount the cursor has moved. Does not apply when the refresher content uses a spinner, enabling the native refresher. * @default 1 */ "pullFactor"?: number; diff --git a/core/src/components/refresher/refresher-interface.ts b/core/src/components/refresher/refresher-interface.ts index 20fd97e182..9866a4cc86 100644 --- a/core/src/components/refresher/refresher-interface.ts +++ b/core/src/components/refresher/refresher-interface.ts @@ -2,7 +2,16 @@ export interface RefresherEventDetail { complete(): void; } +export interface RefresherPullEndEventDetail { + reason: 'complete' | 'cancel'; +} + export interface RefresherCustomEvent extends CustomEvent { detail: RefresherEventDetail; target: HTMLIonRefresherElement; } + +export interface RefresherPullEndCustomEvent extends CustomEvent { + detail: RefresherPullEndEventDetail; + target: HTMLIonRefresherElement; +} diff --git a/core/src/components/refresher/refresher.tsx b/core/src/components/refresher/refresher.tsx index 77f72f1a68..2b933bc5f7 100644 --- a/core/src/components/refresher/refresher.tsx +++ b/core/src/components/refresher/refresher.tsx @@ -14,7 +14,7 @@ import { ImpactStyle, hapticImpact } from '@utils/native/haptic'; import { getIonMode } from '../../global/ionic-global'; import type { Animation, Gesture, GestureDetail } from '../../interface'; -import type { RefresherEventDetail } from './refresher-interface'; +import type { RefresherEventDetail, RefresherPullEndEventDetail } from './refresher-interface'; import { createPullingAnimation, createSnapBackAnimation, @@ -107,8 +107,8 @@ export class Refresher implements ComponentInterface { * than `1`. The default value is `1` which is equal to the speed of the cursor. * If a negative value is passed in, the factor will be `1` instead. * - * For example: If the value passed is `1.2` and the content is dragged by - * `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels + * For example, If the value passed is `1.2` and the content is dragged by + * `10` pixels, instead of `10` pixels, the content will be pulled by `12` pixels * (an increase of 20 percent). If the value passed is `0.8`, the dragged amount * will be `8` pixels, less than the amount the cursor has moved. * @@ -143,9 +143,24 @@ export class Refresher implements ComponentInterface { /** * Emitted when the user begins to start pulling down. + * TODO(FW-7044): Remove this in a major release + * + * @deprecated Use `ionPullStart` instead. */ @Event() ionStart!: EventEmitter; + /** + * Emitted when the user begins to start pulling down. + */ + @Event() ionPullStart!: EventEmitter; + + /** + * Emitted when the refresher has returned to the inactive state + * after a pull gesture. This fires whether the refresh completed + * successfully or was canceled. + */ + @Event() ionPullEnd!: EventEmitter; + private async checkNativeRefresher() { const useNativeRefresher = await shouldUseNativeRefresher(this.el, getIonMode(this)); if (useNativeRefresher && !this.nativeRefresher) { @@ -182,6 +197,10 @@ export class Refresher implements ComponentInterface { this.progress = 0; this.state = RefresherState.Inactive; + + this.ionPullEnd.emit({ + reason: state === RefresherState.Completing ? 'complete' : 'cancel', + }); } private async setupiOSNativeRefresher( @@ -224,6 +243,7 @@ export class Refresher implements ComponentInterface { if (!this.didStart) { this.didStart = true; this.ionStart.emit(); + this.ionPullStart.emit(); } // emit "pulling" on every move @@ -308,6 +328,7 @@ export class Refresher implements ComponentInterface { this.lastVelocityY = ev.velocityY; }, onEnd: () => { + const hadStarted = this.didStart; this.pointerDown = false; this.didStart = false; @@ -316,6 +337,12 @@ export class Refresher implements ComponentInterface { this.needsCompletion = false; } else if (this.didRefresh) { readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`)); + } else if (hadStarted) { + /** + * User started pulling but released before reaching the refresh threshold. + * Emit ionPullEnd to complete the event pair. + */ + this.ionPullEnd.emit({ reason: 'cancel' }); } }, }); @@ -378,6 +405,7 @@ export class Refresher implements ComponentInterface { ev.data.animation = animation; animation.progressStart(false, 0); this.ionStart.emit(); + this.ionPullStart.emit(); this.animations.push(animation); return; @@ -405,6 +433,7 @@ export class Refresher implements ComponentInterface { this.animations = []; this.gesture!.enable(true); this.state = RefresherState.Inactive; + this.ionPullEnd.emit({ reason: 'cancel' }); }); return; } @@ -684,6 +713,7 @@ export class Refresher implements ComponentInterface { if (!this.didStart) { this.didStart = true; this.ionStart.emit(); + this.ionPullStart.emit(); } // emit "pulling" on every move @@ -731,6 +761,16 @@ export class Refresher implements ComponentInterface { * available right away. */ this.restoreOverflowStyle(); + + /** + * If ionPullStart was emitted, we need to emit ionPullEnd + * even though the gesture was aborted before reaching the + * pulling threshold. + */ + if (this.didStart) { + this.didStart = false; + this.ionPullEnd.emit({ reason: 'cancel' }); + } } } @@ -783,6 +823,10 @@ export class Refresher implements ComponentInterface { if (this.contentFullscreen && this.backgroundContentEl) { this.backgroundContentEl?.style.removeProperty('--offset-top'); } + + this.ionPullEnd.emit({ + reason: state === RefresherState.Completing ? 'complete' : 'cancel', + }); }, 600); // reset the styles on the scroll element diff --git a/core/src/components/refresher/test/basic/index.html b/core/src/components/refresher/test/basic/index.html index 60982df867..fdc65b2098 100644 --- a/core/src/components/refresher/test/basic/index.html +++ b/core/src/components/refresher/test/basic/index.html @@ -56,6 +56,17 @@ window.dispatchEvent(new CustomEvent('ionRefreshComplete')); }); + // Event listeners for new ionPullStart and ionPullEnd events + refresher.addEventListener('ionPullStart', function () { + console.log('ionPullStart fired'); + window.dispatchEvent(new CustomEvent('ionPullStartFired')); + }); + + refresher.addEventListener('ionPullEnd', function (event) { + console.log('ionPullEnd fired', event.detail); + window.dispatchEvent(new CustomEvent('ionPullEndFired', { detail: event.detail })); + }); + function render() { let html = ''; for (let item of items) { diff --git a/core/src/components/refresher/test/basic/refresher.e2e.ts b/core/src/components/refresher/test/basic/refresher.e2e.ts index 70f38bc88e..10d6e62d32 100644 --- a/core/src/components/refresher/test/basic/refresher.e2e.ts +++ b/core/src/components/refresher/test/basic/refresher.e2e.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { configs, test } from '@utils/test/playwright'; +import { configs, dragElementByYAxis, test } from '@utils/test/playwright'; import { pullToRefresh } from '../test.utils'; @@ -22,6 +22,37 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { expect(await items.count()).toBe(60); }); + + test('should emit ionPullStart and ionPullEnd with reason complete', async ({ page }) => { + const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired'); + const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired'); + + await pullToRefresh(page); + + // Wait for the close animation to complete + await page.waitForTimeout(700); + + expect(ionPullStartEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'complete' }); + }); + + test('should emit ionPullEnd with reason cancel when pull is released early', async ({ page }) => { + const target = page.locator('body'); + + const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired'); + const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired'); + + // Pull down only 40px (less than pullMin of 60px) to trigger cancel + await dragElementByYAxis(target, page, 40); + + // Wait for the cancel animation to complete + await page.waitForTimeout(700); + + expect(ionPullStartEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'cancel' }); + }); }); test.describe('native refresher', () => { @@ -41,6 +72,28 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => { expect(await items.count()).toBe(60); }); + + test('should emit ionPullStart and ionPullEnd with reason complete', async ({ page }) => { + const refresherContent = page.locator('ion-refresher-content'); + refresherContent.evaluateHandle((el: any) => { + // Resets the pullingIcon to enable the native refresher + el.pullingIcon = undefined; + }); + + await page.waitForChanges(); + + const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired'); + const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired'); + + await pullToRefresh(page); + + // Wait for the reset animation to complete (native refresher takes longer due to CSS transitions) + await page.waitForTimeout(1500); + + expect(ionPullStartEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventTimes(1); + expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'complete' }); + }); }); }); }); diff --git a/core/src/interface.d.ts b/core/src/interface.d.ts index 70be4af143..61489837cf 100644 --- a/core/src/interface.d.ts +++ b/core/src/interface.d.ts @@ -24,7 +24,7 @@ export { PopoverOptions } from './components/popover/popover-interface'; export { RadioGroupCustomEvent } from './components/radio-group/radio-group-interface'; export { RangeCustomEvent, PinFormatter } from './components/range/range-interface'; export { RouterCustomEvent } from './components/router/utils/interface'; -export { RefresherCustomEvent } from './components/refresher/refresher-interface'; +export { RefresherCustomEvent, RefresherPullEndCustomEvent } from './components/refresher/refresher-interface'; export { ItemReorderCustomEvent, ReorderEndCustomEvent, diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index d5b1a91bac..3d17a93602 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -1810,12 +1810,13 @@ export class IonRefresher { constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { c.detach(); this.el = r.nativeElement; - proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart']); + proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart', 'ionPullStart', 'ionPullEnd']); } } import type { RefresherEventDetail as IIonRefresherRefresherEventDetail } from '@ionic/core'; +import type { RefresherPullEndEventDetail as IIonRefresherRefresherPullEndEventDetail } from '@ionic/core'; export declare interface IonRefresher extends Components.IonRefresher { /** @@ -1831,8 +1832,19 @@ called when the async operation has completed. ionPull: EventEmitter>; /** * Emitted when the user begins to start pulling down. +TODO(FW-7044): Remove this in a major release @deprecated Use `ionPullStart` instead. */ ionStart: EventEmitter>; + /** + * Emitted when the user begins to start pulling down. + */ + ionPullStart: EventEmitter>; + /** + * Emitted when the refresher has returned to the inactive state +after a pull gesture. This fires whether the refresh completed +successfully or was canceled. + */ + ionPullEnd: EventEmitter>; } diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 3d2693c0b8..d92609b550 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -1664,12 +1664,13 @@ export class IonRefresher { constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { c.detach(); this.el = r.nativeElement; - proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart']); + proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart', 'ionPullStart', 'ionPullEnd']); } } import type { RefresherEventDetail as IIonRefresherRefresherEventDetail } from '@ionic/core/components'; +import type { RefresherPullEndEventDetail as IIonRefresherRefresherPullEndEventDetail } from '@ionic/core/components'; export declare interface IonRefresher extends Components.IonRefresher { /** @@ -1685,8 +1686,19 @@ called when the async operation has completed. ionPull: EventEmitter>; /** * Emitted when the user begins to start pulling down. +TODO(FW-7044): Remove this in a major release @deprecated Use `ionPullStart` instead. */ ionStart: EventEmitter>; + /** + * Emitted when the user begins to start pulling down. + */ + ionPullStart: EventEmitter>; + /** + * Emitted when the refresher has returned to the inactive state +after a pull gesture. This fires whether the refresh completed +successfully or was canceled. + */ + ionPullEnd: EventEmitter>; } diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 31d13970a4..735a790697 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -783,11 +783,15 @@ export const IonRefresher: StencilVueComponent = /*@__PURE__*/ 'disabled', 'ionRefresh', 'ionPull', - 'ionStart' + 'ionStart', + 'ionPullStart', + 'ionPullEnd' ], [ 'ionRefresh', 'ionPull', - 'ionStart' + 'ionStart', + 'ionPullStart', + 'ionPullEnd' ]);