Skip to content

Commit 6dc87ba

Browse files
feat(item-sliding): added velocity validation for open options
1 parent c0338ab commit 6dc87ba

2 files changed

Lines changed: 183 additions & 24 deletions

File tree

core/src/components/item-sliding/item-sliding.tsx

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ const ELASTIC_FACTOR = 0.55;
1414
const IONIC_ELASTIC_FACTOR = 0.15;
1515
const IONIC_SNAP_OPEN_RATIO = 0.4;
1616
const IONIC_EXPAND_TRIGGER = 40;
17-
const IONIC_VELOCITY_THRESHOLD = 400;
17+
const IONIC_FULL_SWIPE_VELOCITY_THRESHOLD = 400;
18+
const IONIC_OPEN_VELOCITY_THRESHOLD = 200;
1819
const IONIC_ACTION_BASE_WIDTH = 64;
1920
const IONIC_CONFIRM_PAUSE = 300;
2021
const FULL_SWIPE_TRANSITION_MS = 250;
@@ -802,16 +803,19 @@ export class ItemSliding implements ComponentInterface {
802803
const optionsWidth = this.getOptionsWidthForDirection(activeDirection);
803804
const extraWidth = Math.max(0, Math.abs(this.openAmount) - optionsWidth);
804805
const hasExpandable = this.hasExpandableOptions(activeDirection === 'end' ? this.rightOptions : this.leftOptions);
805-
const wasRevealed = Math.abs(this.initialOpenAmount) >= optionsWidth;
806-
807-
806+
808807
const closeDirection =
809-
activeDirection === 'end' ? velocityX > IONIC_VELOCITY_THRESHOLD : velocityX < -IONIC_VELOCITY_THRESHOLD;
810-
808+
activeDirection === 'end' ? velocityX > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD : velocityX < -IONIC_FULL_SWIPE_VELOCITY_THRESHOLD;
809+
810+
if (closeDirection) {
811+
this.setOpenAmount(0, true);
812+
return;
813+
}
814+
811815
if (
812-
!closeDirection &&
813816
hasExpandable &&
814-
(extraWidth >= IONIC_EXPAND_TRIGGER || (extraWidth > 0 && (wasRevealed && Math.abs(velocityX) > IONIC_VELOCITY_THRESHOLD)))
817+
(extraWidth >= IONIC_EXPAND_TRIGGER ||
818+
Math.abs(velocityX) > IONIC_FULL_SWIPE_VELOCITY_THRESHOLD)
815819
) {
816820
this.animateIonicFullSwipe(activeDirection).catch(() => {
817821
if (this.gesture) {
@@ -821,22 +825,20 @@ export class ItemSliding implements ComponentInterface {
821825
return;
822826
}
823827

824-
if (closeDirection) {
825-
this.setOpenAmount(0, true);
826-
return;
827-
}
828+
const flickOpen =
829+
activeDirection === 'end'
830+
? velocityX < -IONIC_OPEN_VELOCITY_THRESHOLD
831+
: velocityX > IONIC_OPEN_VELOCITY_THRESHOLD;
828832

833+
const fullOpen =
834+
activeDirection === 'end' ? this.optsWidthRightSide : -this.optsWidthLeftSide;
829835
const openThreshold = optionsWidth * IONIC_SNAP_OPEN_RATIO;
830-
const shouldSnapOpen = Math.abs(this.openAmount) > openThreshold;
831-
const restingPoint = shouldSnapOpen
832-
? activeDirection === 'end'
833-
? this.optsWidthRightSide
834-
: -this.optsWidthLeftSide
835-
: 0;
836+
const shouldSnapOpen = flickOpen || Math.abs(this.openAmount) > openThreshold;
837+
const restingPoint = shouldSnapOpen ? fullOpen : 0;
836838

837839
this.setOpenAmount(restingPoint, true);
838840
}
839-
841+
840842
private calculateOptsWidth() {
841843
this.optsWidthRightSide = 0;
842844
if (this.rightOptions) {

core/src/components/item-sliding/test/full-swipe/item-sliding.e2e.ts

Lines changed: 162 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test';
22
import { configs, dragElementBy, test } from '@utils/test/playwright';
33

44
/**
5-
* Full swipe animation behavior is mode-independent but
6-
* child components (ion-item-options, ion-item-option) have
7-
* mode-specific styling, so we test across all modes.
8-
*
95
* When an item has at least one expandable option and the user swipes
106
* beyond the threshold (or with sufficient velocity), the item slides
117
* off-screen, fires ionSwipe, and returns to its closed position.
@@ -14,7 +10,7 @@ import { configs, dragElementBy, test } from '@utils/test/playwright';
1410
// Full animation cycle duration (100ms expand + 250ms off-screen + 300ms delay + 250ms return)
1511
const FULL_ANIMATION_MS = 1100;
1612

17-
configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => {
13+
configs({ modes: ['ios', 'md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => {
1814
test.describe(title('item-sliding: full swipe'), () => {
1915
test('should fire ionSwipe when expandable option is swiped fully (end side)', async ({ page }) => {
2016
await page.setContent(
@@ -58,6 +54,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
5854

5955
const ionSwipe = await page.spyOnEvent('ionSwipe');
6056
const item = page.locator('ion-item-sliding');
57+
await expect(item).toBeVisible();
58+
await page.waitForChanges();
6159
const dragByX = config.direction === 'rtl' ? -190 : 190;
6260

6361
await dragElementBy(item, page, dragByX);
@@ -82,6 +80,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
8280
);
8381

8482
const item = page.locator('ion-item-sliding');
83+
await expect(item).toBeVisible();
84+
await page.waitForChanges();
8585
const dragByX = config.direction === 'rtl' ? 190 : -190;
8686

8787
await dragElementBy(item, page, dragByX);
@@ -108,6 +108,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
108108

109109
const ionSwipe = await page.spyOnEvent('ionSwipe');
110110
const item = page.locator('ion-item-sliding');
111+
await expect(item).toBeVisible();
112+
await page.waitForChanges();
111113
const dragByX = config.direction === 'rtl' ? 180 : -180;
112114

113115
await dragElementBy(item, page, dragByX);
@@ -138,6 +140,8 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
138140

139141
const ionSwipe = await page.spyOnEvent('ionSwipe');
140142
const item = page.locator('ion-item-sliding');
143+
await expect(item).toBeVisible();
144+
await page.waitForChanges();
141145
const dragByX = config.direction === 'rtl' ? 190 : -190;
142146

143147
await dragElementBy(item, page, dragByX);
@@ -148,6 +152,157 @@ configs({ modes: ['ios', 'md', 'ionic-md'], directions: ['ltr', 'rtl'] }).forEac
148152
});
149153
});
150154

155+
156+
/**
157+
* Test for Ionic theme that has a different full swipe animation behavior.
158+
*/
159+
configs({ modes: ['ionic-md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config }) => {
160+
test.describe(title('item-sliding: full swipe'), () => {
161+
test('should fire ionSwipe when expandable option is swiped fully (end side)', async ({ page }) => {
162+
await page.setContent(
163+
`
164+
<ion-item-sliding>
165+
<ion-item>
166+
<ion-label>Expandable End (Swipe Left)</ion-label>
167+
</ion-item>
168+
<ion-item-options side="end">
169+
<ion-item-option expandable="true">Delete</ion-item-option>
170+
</ion-item-options>
171+
</ion-item-sliding>
172+
`,
173+
config
174+
);
175+
176+
const ionSwipe = await page.spyOnEvent('ionSwipe');
177+
const item = page.locator('ion-item-sliding');
178+
await expect(item).toBeVisible();
179+
await page.waitForChanges();
180+
const box = (await item.boundingBox())!;
181+
const y = box.y + box.height / 2;
182+
183+
// 1) Peek open (distance-based; moderate steps is fine)
184+
const peek = config.direction === 'rtl' ? 120 : -120;
185+
await dragElementBy(item, page, peek);
186+
187+
// 2) Fast flick in the same direction as “full swipe”
188+
const startX = config.direction === 'rtl' ? box.x + 40 : box.x + box.width - 40;
189+
const endX = config.direction === 'rtl' ? box.x + box.width - 40 : box.x + 40;
190+
await page.mouse.move(startX, y);
191+
await page.mouse.down();
192+
await page.mouse.move(endX, y, { steps: 2 }); // try 1–3; lower = faster
193+
await page.mouse.up();
194+
await ionSwipe.next();
195+
expect(ionSwipe).toHaveReceivedEventTimes(1);
196+
});
197+
198+
test('should fire ionSwipe when expandable option is swiped fully (start side)', async ({ page }) => {
199+
await page.setContent(
200+
`
201+
<ion-item-sliding>
202+
<ion-item>
203+
<ion-label>Expandable Start (Swipe Right)</ion-label>
204+
</ion-item>
205+
<ion-item-options side="start">
206+
<ion-item-option expandable="true">Archive</ion-item-option>
207+
</ion-item-options>
208+
</ion-item-sliding>
209+
`,
210+
config
211+
);
212+
213+
const ionSwipe = await page.spyOnEvent('ionSwipe');
214+
const item = page.locator('ion-item-sliding');
215+
await expect(item).toBeVisible();
216+
await page.waitForChanges();
217+
const box = (await item.boundingBox())!;
218+
const y = box.y + box.height / 2;
219+
220+
// 1) Peek open (distance-based; moderate steps is fine)
221+
const peek = config.direction === 'rtl' ? -120 : 120;
222+
await dragElementBy(item, page, peek);
223+
224+
// 2) Fast flick in the same direction as “full swipe”
225+
const startX = config.direction === 'rtl' ? box.x + box.width - 40 : box.x + 40;
226+
const endX = config.direction === 'rtl' ? box.x + 40 : box.x + box.width - 40;
227+
await page.mouse.move(startX, y);
228+
await page.mouse.down();
229+
await page.mouse.move(endX, y, { steps: 2 });
230+
await page.mouse.up();
231+
await ionSwipe.next();
232+
expect(ionSwipe).toHaveReceivedEventTimes(1);
233+
});
234+
235+
236+
test('should return to closed state after full swipe animation completes', async ({ page }) => {
237+
await page.setContent(
238+
`
239+
<ion-item-sliding>
240+
<ion-item>
241+
<ion-label>Expandable End (Swipe Left)</ion-label>
242+
</ion-item>
243+
<ion-item-options side="end">
244+
<ion-item-option expandable="true">Delete</ion-item-option>
245+
</ion-item-options>
246+
</ion-item-sliding>
247+
`,
248+
config
249+
);
250+
251+
const item = page.locator('ion-item-sliding');
252+
await expect(item).toBeVisible();
253+
await page.waitForChanges();
254+
const box = (await item.boundingBox())!;
255+
const y = box.y + box.height / 2;
256+
257+
// 1) Peek open (distance-based; moderate steps is fine)
258+
const peek = config.direction === 'rtl' ? -120 : 120;
259+
await dragElementBy(item, page, peek);
260+
261+
// 2) Fast flick in the same direction as “full swipe”
262+
const startX = config.direction === 'rtl' ? box.x + box.width - 40 : box.x + 40;
263+
const endX = config.direction === 'rtl' ? box.x + 40 : box.x + box.width - 40;
264+
await page.mouse.move(startX, y);
265+
await page.mouse.down();
266+
await page.mouse.move(endX, y, { steps: 2 });
267+
await page.mouse.up();
268+
await page.waitForTimeout(FULL_ANIMATION_MS);
269+
270+
const openAmount = await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount());
271+
expect(openAmount).toBe(0);
272+
});
273+
274+
test('should NOT trigger full swipe animation for non-expandable options', async ({ page }) => {
275+
await page.setContent(
276+
`
277+
<ion-item-sliding>
278+
<ion-item>
279+
<ion-label>Non-Expandable (Should Show Options)</ion-label>
280+
</ion-item>
281+
<ion-item-options side="end">
282+
<ion-item-option>Edit</ion-item-option>
283+
</ion-item-options>
284+
</ion-item-sliding>
285+
`,
286+
config
287+
);
288+
const ionSwipe = await page.spyOnEvent('ionSwipe');
289+
const item = page.locator('ion-item-sliding');
290+
await expect(item).toBeVisible();
291+
await page.waitForChanges();
292+
const dragByX = config.direction === 'rtl' ? 180 : -180;
293+
294+
await dragElementBy(item, page, dragByX);
295+
await page.waitForChanges();
296+
297+
expect(ionSwipe).toHaveReceivedEventTimes(0);
298+
299+
const openAmount = await item.evaluate((el: HTMLIonItemSlidingElement) => el.getOpenAmount());
300+
301+
expect(Math.abs(openAmount)).toBeGreaterThan(0);
302+
});
303+
});
304+
});
305+
151306
/**
152307
* Velocity-based trigger: a fast short swipe should trigger the full animation
153308
* even if the raw distance alone wouldn't exceed the threshold.
@@ -172,6 +327,8 @@ configs({ modes: ['md'], directions: ['ltr', 'rtl'] }).forEach(({ title, config
172327

173328
const ionSwipe = await page.spyOnEvent('ionSwipe');
174329
const item = page.locator('ion-item-sliding');
330+
const item = page.locator('ion-item-sliding');
331+
await expect(item).toBeVisible();
175332
const box = (await item.boundingBox())!;
176333

177334
// Few steps = high velocity gesture

0 commit comments

Comments
 (0)