Skip to content

Commit 814c2e5

Browse files
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>
1 parent 5cea5ae commit 814c2e5

File tree

10 files changed

+172
-14
lines changed

10 files changed

+172
-14
lines changed

core/api.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,6 +1493,8 @@ ion-refresher,method,cancel,cancel() => Promise<void>
14931493
ion-refresher,method,complete,complete() => Promise<void>
14941494
ion-refresher,method,getProgress,getProgress() => Promise<number>
14951495
ion-refresher,event,ionPull,void,true
1496+
ion-refresher,event,ionPullEnd,RefresherPullEndEventDetail,true
1497+
ion-refresher,event,ionPullStart,void,true
14961498
ion-refresher,event,ionRefresh,RefresherEventDetail,true
14971499
ion-refresher,event,ionStart,void,true
14981500

core/src/components.d.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { PickerButton, PickerColumn } from "./components/picker-legacy/picker-in
2929
import { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface";
3030
import { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/radio-group/radio-group-interface";
3131
import { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface";
32-
import { RefresherEventDetail } from "./components/refresher/refresher-interface";
32+
import { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface";
3333
import { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface";
3434
import { NavigationHookCallback } from "./components/route/route-interface";
3535
import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface";
@@ -67,7 +67,7 @@ export { PickerButton, PickerColumn } from "./components/picker-legacy/picker-in
6767
export { PopoverSize, PositionAlign, PositionReference, PositionSide, TriggerAction } from "./components/popover/popover-interface";
6868
export { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/radio-group/radio-group-interface";
6969
export { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface";
70-
export { RefresherEventDetail } from "./components/refresher/refresher-interface";
70+
export { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface";
7171
export { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface";
7272
export { NavigationHookCallback } from "./components/route/route-interface";
7373
export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface";
@@ -2745,7 +2745,7 @@ export namespace Components {
27452745
*/
27462746
"mode"?: "ios" | "md";
27472747
/**
2748-
* 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.
2748+
* 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.
27492749
* @default 1
27502750
*/
27512751
"pullFactor": number;
@@ -4754,6 +4754,8 @@ declare global {
47544754
"ionRefresh": RefresherEventDetail;
47554755
"ionPull": void;
47564756
"ionStart": void;
4757+
"ionPullStart": void;
4758+
"ionPullEnd": RefresherPullEndEventDetail;
47574759
}
47584760
interface HTMLIonRefresherElement extends Components.IonRefresher, HTMLStencilElement {
47594761
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 {
80148016
* Emitted while the user is pulling down the content and exposing the refresher.
80158017
*/
80168018
"onIonPull"?: (event: IonRefresherCustomEvent<void>) => void;
8019+
/**
8020+
* Emitted when the refresher has returned to the inactive state after a pull gesture. This fires whether the refresh completed successfully or was canceled.
8021+
*/
8022+
"onIonPullEnd"?: (event: IonRefresherCustomEvent<RefresherPullEndEventDetail>) => void;
8023+
/**
8024+
* Emitted when the user begins to start pulling down.
8025+
*/
8026+
"onIonPullStart"?: (event: IonRefresherCustomEvent<void>) => void;
80178027
/**
80188028
* 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.
80198029
*/
80208030
"onIonRefresh"?: (event: IonRefresherCustomEvent<RefresherEventDetail>) => void;
80218031
/**
8022-
* Emitted when the user begins to start pulling down.
8032+
* Emitted when the user begins to start pulling down. TODO(FW-7044): Remove this in a major release
8033+
* @deprecated Use `ionPullStart` instead.
80238034
*/
80248035
"onIonStart"?: (event: IonRefresherCustomEvent<void>) => void;
80258036
/**
8026-
* 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.
8037+
* 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.
80278038
* @default 1
80288039
*/
80298040
"pullFactor"?: number;

core/src/components/refresher/refresher-interface.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,16 @@ export interface RefresherEventDetail {
22
complete(): void;
33
}
44

5+
export interface RefresherPullEndEventDetail {
6+
reason: 'complete' | 'cancel';
7+
}
8+
59
export interface RefresherCustomEvent extends CustomEvent {
610
detail: RefresherEventDetail;
711
target: HTMLIonRefresherElement;
812
}
13+
14+
export interface RefresherPullEndCustomEvent extends CustomEvent {
15+
detail: RefresherPullEndEventDetail;
16+
target: HTMLIonRefresherElement;
17+
}

core/src/components/refresher/refresher.tsx

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { ImpactStyle, hapticImpact } from '@utils/native/haptic';
1414
import { getIonMode } from '../../global/ionic-global';
1515
import type { Animation, Gesture, GestureDetail } from '../../interface';
1616

17-
import type { RefresherEventDetail } from './refresher-interface';
17+
import type { RefresherEventDetail, RefresherPullEndEventDetail } from './refresher-interface';
1818
import {
1919
createPullingAnimation,
2020
createSnapBackAnimation,
@@ -107,8 +107,8 @@ export class Refresher implements ComponentInterface {
107107
* than `1`. The default value is `1` which is equal to the speed of the cursor.
108108
* If a negative value is passed in, the factor will be `1` instead.
109109
*
110-
* For example: If the value passed is `1.2` and the content is dragged by
111-
* `10` pixels, instead of `10` pixels the content will be pulled by `12` pixels
110+
* For example, If the value passed is `1.2` and the content is dragged by
111+
* `10` pixels, instead of `10` pixels, the content will be pulled by `12` pixels
112112
* (an increase of 20 percent). If the value passed is `0.8`, the dragged amount
113113
* will be `8` pixels, less than the amount the cursor has moved.
114114
*
@@ -143,9 +143,24 @@ export class Refresher implements ComponentInterface {
143143

144144
/**
145145
* Emitted when the user begins to start pulling down.
146+
* TODO(FW-7044): Remove this in a major release
147+
*
148+
* @deprecated Use `ionPullStart` instead.
146149
*/
147150
@Event() ionStart!: EventEmitter<void>;
148151

152+
/**
153+
* Emitted when the user begins to start pulling down.
154+
*/
155+
@Event() ionPullStart!: EventEmitter<void>;
156+
157+
/**
158+
* Emitted when the refresher has returned to the inactive state
159+
* after a pull gesture. This fires whether the refresh completed
160+
* successfully or was canceled.
161+
*/
162+
@Event() ionPullEnd!: EventEmitter<RefresherPullEndEventDetail>;
163+
149164
private async checkNativeRefresher() {
150165
const useNativeRefresher = await shouldUseNativeRefresher(this.el, getIonMode(this));
151166
if (useNativeRefresher && !this.nativeRefresher) {
@@ -182,6 +197,10 @@ export class Refresher implements ComponentInterface {
182197
this.progress = 0;
183198

184199
this.state = RefresherState.Inactive;
200+
201+
this.ionPullEnd.emit({
202+
reason: state === RefresherState.Completing ? 'complete' : 'cancel',
203+
});
185204
}
186205

187206
private async setupiOSNativeRefresher(
@@ -224,6 +243,7 @@ export class Refresher implements ComponentInterface {
224243
if (!this.didStart) {
225244
this.didStart = true;
226245
this.ionStart.emit();
246+
this.ionPullStart.emit();
227247
}
228248

229249
// emit "pulling" on every move
@@ -308,6 +328,7 @@ export class Refresher implements ComponentInterface {
308328
this.lastVelocityY = ev.velocityY;
309329
},
310330
onEnd: () => {
331+
const hadStarted = this.didStart;
311332
this.pointerDown = false;
312333
this.didStart = false;
313334

@@ -316,6 +337,12 @@ export class Refresher implements ComponentInterface {
316337
this.needsCompletion = false;
317338
} else if (this.didRefresh) {
318339
readTask(() => translateElement(this.elementToTransform, `${this.el.clientHeight}px`));
340+
} else if (hadStarted) {
341+
/**
342+
* User started pulling but released before reaching the refresh threshold.
343+
* Emit ionPullEnd to complete the event pair.
344+
*/
345+
this.ionPullEnd.emit({ reason: 'cancel' });
319346
}
320347
},
321348
});
@@ -378,6 +405,7 @@ export class Refresher implements ComponentInterface {
378405
ev.data.animation = animation;
379406
animation.progressStart(false, 0);
380407
this.ionStart.emit();
408+
this.ionPullStart.emit();
381409
this.animations.push(animation);
382410

383411
return;
@@ -405,6 +433,7 @@ export class Refresher implements ComponentInterface {
405433
this.animations = [];
406434
this.gesture!.enable(true);
407435
this.state = RefresherState.Inactive;
436+
this.ionPullEnd.emit({ reason: 'cancel' });
408437
});
409438
return;
410439
}
@@ -684,6 +713,7 @@ export class Refresher implements ComponentInterface {
684713
if (!this.didStart) {
685714
this.didStart = true;
686715
this.ionStart.emit();
716+
this.ionPullStart.emit();
687717
}
688718

689719
// emit "pulling" on every move
@@ -731,6 +761,16 @@ export class Refresher implements ComponentInterface {
731761
* available right away.
732762
*/
733763
this.restoreOverflowStyle();
764+
765+
/**
766+
* If ionPullStart was emitted, we need to emit ionPullEnd
767+
* even though the gesture was aborted before reaching the
768+
* pulling threshold.
769+
*/
770+
if (this.didStart) {
771+
this.didStart = false;
772+
this.ionPullEnd.emit({ reason: 'cancel' });
773+
}
734774
}
735775
}
736776

@@ -783,6 +823,10 @@ export class Refresher implements ComponentInterface {
783823
if (this.contentFullscreen && this.backgroundContentEl) {
784824
this.backgroundContentEl?.style.removeProperty('--offset-top');
785825
}
826+
827+
this.ionPullEnd.emit({
828+
reason: state === RefresherState.Completing ? 'complete' : 'cancel',
829+
});
786830
}, 600);
787831

788832
// reset the styles on the scroll element

core/src/components/refresher/test/basic/index.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,17 @@
5656
window.dispatchEvent(new CustomEvent('ionRefreshComplete'));
5757
});
5858

59+
// Event listeners for new ionPullStart and ionPullEnd events
60+
refresher.addEventListener('ionPullStart', function () {
61+
console.log('ionPullStart fired');
62+
window.dispatchEvent(new CustomEvent('ionPullStartFired'));
63+
});
64+
65+
refresher.addEventListener('ionPullEnd', function (event) {
66+
console.log('ionPullEnd fired', event.detail);
67+
window.dispatchEvent(new CustomEvent('ionPullEndFired', { detail: event.detail }));
68+
});
69+
5970
function render() {
6071
let html = '';
6172
for (let item of items) {

core/src/components/refresher/test/basic/refresher.e2e.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from '@playwright/test';
2-
import { configs, test } from '@utils/test/playwright';
2+
import { configs, dragElementByYAxis, test } from '@utils/test/playwright';
33

44
import { pullToRefresh } from '../test.utils';
55

@@ -22,6 +22,37 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
2222

2323
expect(await items.count()).toBe(60);
2424
});
25+
26+
test('should emit ionPullStart and ionPullEnd with reason complete', async ({ page }) => {
27+
const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired');
28+
const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired');
29+
30+
await pullToRefresh(page);
31+
32+
// Wait for the close animation to complete
33+
await page.waitForTimeout(700);
34+
35+
expect(ionPullStartEvent).toHaveReceivedEventTimes(1);
36+
expect(ionPullEndEvent).toHaveReceivedEventTimes(1);
37+
expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'complete' });
38+
});
39+
40+
test('should emit ionPullEnd with reason cancel when pull is released early', async ({ page }) => {
41+
const target = page.locator('body');
42+
43+
const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired');
44+
const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired');
45+
46+
// Pull down only 40px (less than pullMin of 60px) to trigger cancel
47+
await dragElementByYAxis(target, page, 40);
48+
49+
// Wait for the cancel animation to complete
50+
await page.waitForTimeout(700);
51+
52+
expect(ionPullStartEvent).toHaveReceivedEventTimes(1);
53+
expect(ionPullEndEvent).toHaveReceivedEventTimes(1);
54+
expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'cancel' });
55+
});
2556
});
2657

2758
test.describe('native refresher', () => {
@@ -41,6 +72,28 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
4172

4273
expect(await items.count()).toBe(60);
4374
});
75+
76+
test('should emit ionPullStart and ionPullEnd with reason complete', async ({ page }) => {
77+
const refresherContent = page.locator('ion-refresher-content');
78+
refresherContent.evaluateHandle((el: any) => {
79+
// Resets the pullingIcon to enable the native refresher
80+
el.pullingIcon = undefined;
81+
});
82+
83+
await page.waitForChanges();
84+
85+
const ionPullStartEvent = await page.spyOnEvent('ionPullStartFired');
86+
const ionPullEndEvent = await page.spyOnEvent('ionPullEndFired');
87+
88+
await pullToRefresh(page);
89+
90+
// Wait for the reset animation to complete (native refresher takes longer due to CSS transitions)
91+
await page.waitForTimeout(1500);
92+
93+
expect(ionPullStartEvent).toHaveReceivedEventTimes(1);
94+
expect(ionPullEndEvent).toHaveReceivedEventTimes(1);
95+
expect(ionPullEndEvent).toHaveReceivedEventDetail({ reason: 'complete' });
96+
});
4497
});
4598
});
4699
});

core/src/interface.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export { PopoverOptions } from './components/popover/popover-interface';
2424
export { RadioGroupCustomEvent } from './components/radio-group/radio-group-interface';
2525
export { RangeCustomEvent, PinFormatter } from './components/range/range-interface';
2626
export { RouterCustomEvent } from './components/router/utils/interface';
27-
export { RefresherCustomEvent } from './components/refresher/refresher-interface';
27+
export { RefresherCustomEvent, RefresherPullEndCustomEvent } from './components/refresher/refresher-interface';
2828
export {
2929
ItemReorderCustomEvent,
3030
ReorderEndCustomEvent,

packages/angular/src/directives/proxies.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1810,12 +1810,13 @@ export class IonRefresher {
18101810
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
18111811
c.detach();
18121812
this.el = r.nativeElement;
1813-
proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart']);
1813+
proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart', 'ionPullStart', 'ionPullEnd']);
18141814
}
18151815
}
18161816

18171817

18181818
import type { RefresherEventDetail as IIonRefresherRefresherEventDetail } from '@ionic/core';
1819+
import type { RefresherPullEndEventDetail as IIonRefresherRefresherPullEndEventDetail } from '@ionic/core';
18191820

18201821
export declare interface IonRefresher extends Components.IonRefresher {
18211822
/**
@@ -1831,8 +1832,19 @@ called when the async operation has completed.
18311832
ionPull: EventEmitter<CustomEvent<void>>;
18321833
/**
18331834
* Emitted when the user begins to start pulling down.
1835+
TODO(FW-7044): Remove this in a major release @deprecated Use `ionPullStart` instead.
18341836
*/
18351837
ionStart: EventEmitter<CustomEvent<void>>;
1838+
/**
1839+
* Emitted when the user begins to start pulling down.
1840+
*/
1841+
ionPullStart: EventEmitter<CustomEvent<void>>;
1842+
/**
1843+
* Emitted when the refresher has returned to the inactive state
1844+
after a pull gesture. This fires whether the refresh completed
1845+
successfully or was canceled.
1846+
*/
1847+
ionPullEnd: EventEmitter<CustomEvent<IIonRefresherRefresherPullEndEventDetail>>;
18361848
}
18371849

18381850

packages/angular/standalone/src/directives/proxies.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1664,12 +1664,13 @@ export class IonRefresher {
16641664
constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
16651665
c.detach();
16661666
this.el = r.nativeElement;
1667-
proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart']);
1667+
proxyOutputs(this, this.el, ['ionRefresh', 'ionPull', 'ionStart', 'ionPullStart', 'ionPullEnd']);
16681668
}
16691669
}
16701670

16711671

16721672
import type { RefresherEventDetail as IIonRefresherRefresherEventDetail } from '@ionic/core/components';
1673+
import type { RefresherPullEndEventDetail as IIonRefresherRefresherPullEndEventDetail } from '@ionic/core/components';
16731674

16741675
export declare interface IonRefresher extends Components.IonRefresher {
16751676
/**
@@ -1685,8 +1686,19 @@ called when the async operation has completed.
16851686
ionPull: EventEmitter<CustomEvent<void>>;
16861687
/**
16871688
* Emitted when the user begins to start pulling down.
1689+
TODO(FW-7044): Remove this in a major release @deprecated Use `ionPullStart` instead.
16881690
*/
16891691
ionStart: EventEmitter<CustomEvent<void>>;
1692+
/**
1693+
* Emitted when the user begins to start pulling down.
1694+
*/
1695+
ionPullStart: EventEmitter<CustomEvent<void>>;
1696+
/**
1697+
* Emitted when the refresher has returned to the inactive state
1698+
after a pull gesture. This fires whether the refresh completed
1699+
successfully or was canceled.
1700+
*/
1701+
ionPullEnd: EventEmitter<CustomEvent<IIonRefresherRefresherPullEndEventDetail>>;
16901702
}
16911703

16921704

0 commit comments

Comments
 (0)