feat(refresher): add ionPullStart and ionPullEnd events (#30946)

Issue number: resolves #24524

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## 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?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

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

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

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>
This commit is contained in:
Shane
2026-02-19 08:57:48 -08:00
committed by GitHub
parent 5cea5aeb44
commit 814c2e5ccd
10 changed files with 172 additions and 14 deletions

View File

@@ -1493,6 +1493,8 @@ ion-refresher,method,cancel,cancel() => Promise<void>
ion-refresher,method,complete,complete() => Promise<void>
ion-refresher,method,getProgress,getProgress() => Promise<number>
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

View File

@@ -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<K extends keyof HTMLIonRefresherElementEventMap>(type: K, listener: (this: HTMLIonRefresherElement, ev: IonRefresherCustomEvent<HTMLIonRefresherElementEventMap[K]>) => 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>) => 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<RefresherPullEndEventDetail>) => void;
/**
* Emitted when the user begins to start pulling down.
*/
"onIonPullStart"?: (event: IonRefresherCustomEvent<void>) => 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<RefresherEventDetail>) => 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>) => 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;

View File

@@ -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;
}

View File

@@ -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<void>;
/**
* Emitted when the user begins to start pulling down.
*/
@Event() ionPullStart!: EventEmitter<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.
*/
@Event() ionPullEnd!: EventEmitter<RefresherPullEndEventDetail>;
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

View File

@@ -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) {

View File

@@ -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' });
});
});
});
});

View File

@@ -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,

View File

@@ -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<CustomEvent<void>>;
/**
* Emitted when the user begins to start pulling down.
TODO(FW-7044): Remove this in a major release @deprecated Use `ionPullStart` instead.
*/
ionStart: EventEmitter<CustomEvent<void>>;
/**
* Emitted when the user begins to start pulling down.
*/
ionPullStart: EventEmitter<CustomEvent<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.
*/
ionPullEnd: EventEmitter<CustomEvent<IIonRefresherRefresherPullEndEventDetail>>;
}

View File

@@ -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<CustomEvent<void>>;
/**
* Emitted when the user begins to start pulling down.
TODO(FW-7044): Remove this in a major release @deprecated Use `ionPullStart` instead.
*/
ionStart: EventEmitter<CustomEvent<void>>;
/**
* Emitted when the user begins to start pulling down.
*/
ionPullStart: EventEmitter<CustomEvent<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.
*/
ionPullEnd: EventEmitter<CustomEvent<IIonRefresherRefresherPullEndEventDetail>>;
}

View File

@@ -783,11 +783,15 @@ export const IonRefresher: StencilVueComponent<JSX.IonRefresher> = /*@__PURE__*/
'disabled',
'ionRefresh',
'ionPull',
'ionStart'
'ionStart',
'ionPullStart',
'ionPullEnd'
], [
'ionRefresh',
'ionPull',
'ionStart'
'ionStart',
'ionPullStart',
'ionPullEnd'
]);