mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
21
core/src/components.d.ts
vendored
21
core/src/components.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
2
core/src/interface.d.ts
vendored
2
core/src/interface.d.ts
vendored
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user