Skip to content

Commit 32201b7

Browse files
OS-pedrolourencoShaneKthetaPCIonitronos-davidlourenco
authored
feat(item-sliding): add automatic full expand animation of items (#31036)
Issue number: internal --------- ## What is the current behavior? Dragging an ion-item with an expandable option would cause a full swipe event to be triggered but also cause the item to return to a position just after the item options once the mouse button was released. ## What is the new behavior? - Now, once the new threshold is passed and the mouse button is released, a full expand animation will take place and, after it is finished, only then is the full swipe event triggered and the item returns to its initial state where no options are visible. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information [Preview](https://ionic-framework-git-rou-12664-ionic1.vercel.app/src/components/item-sliding/test/full-swipe/) --------- Co-authored-by: Shane <shane@shanessite.net> Co-authored-by: Maria Hutt <thetaPC@users.noreply.github.com> Co-authored-by: ionitron <hi@ionicframework.com> Co-authored-by: David Lourenço <david.lourenco@outsystems.com> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Co-authored-by: Kanhaiya Pandey <kanhaiyapandey2232@gmail.com> Co-authored-by: Brandy Smith <brandyscarney@users.noreply.github.com>
1 parent 72b7b99 commit 32201b7

6 files changed

Lines changed: 501 additions & 11 deletions

File tree

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

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const enum SlidingState {
2727

2828
SwipeEnd = 1 << 5,
2929
SwipeStart = 1 << 6,
30+
AnimatingFullSwipe = 1 << 7,
3031
}
3132

3233
let openSlidingItem: HTMLIonItemSlidingElement | undefined;
@@ -47,6 +48,7 @@ export class ItemSliding implements ComponentInterface {
4748
private optsWidthLeftSide = 0;
4849
private sides = ItemSide.None;
4950
private tmr?: ReturnType<typeof setTimeout>;
51+
private animationAbortController?: AbortController;
5052
private leftOptions?: HTMLIonItemOptionsElement;
5153
private rightOptions?: HTMLIonItemOptionsElement;
5254
private optsDirty = true;
@@ -113,6 +115,15 @@ export class ItemSliding implements ComponentInterface {
113115
this.gesture = undefined;
114116
}
115117

118+
if (this.tmr !== undefined) {
119+
clearTimeout(this.tmr);
120+
this.tmr = undefined;
121+
}
122+
123+
// Abort any in-progress animation. The abort handler rejects the pending
124+
// promise, causing animateFullSwipe's finally block to run cleanup.
125+
this.animationAbortController?.abort();
126+
116127
this.item = null;
117128
this.leftOptions = this.rightOptions = undefined;
118129

@@ -153,6 +164,10 @@ export class ItemSliding implements ComponentInterface {
153164
*/
154165
@Method()
155166
async open(side: Side | undefined) {
167+
if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) {
168+
return;
169+
}
170+
156171
/**
157172
* It is possible for the item to be added to the DOM
158173
* after the item-sliding component was created. As a result,
@@ -216,6 +231,9 @@ export class ItemSliding implements ComponentInterface {
216231
*/
217232
@Method()
218233
async close() {
234+
if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) {
235+
return;
236+
}
219237
this.setOpenAmount(0, true);
220238
}
221239

@@ -248,6 +266,135 @@ export class ItemSliding implements ComponentInterface {
248266
}
249267
}
250268

269+
/**
270+
* Check if the given item options element contains at least one expandable, non-disabled option.
271+
*/
272+
private hasExpandableOptions(options: HTMLIonItemOptionsElement | undefined): boolean {
273+
if (!options) return false;
274+
275+
const optionElements = options.querySelectorAll('ion-item-option');
276+
return Array.from(optionElements).some((option: any) => {
277+
return option.expandable === true && !option.disabled;
278+
});
279+
}
280+
281+
/**
282+
* Returns a Promise that resolves after `ms` milliseconds, or rejects if the
283+
* given AbortSignal is fired before the timer expires.
284+
*/
285+
private delay(ms: number, signal: AbortSignal): Promise<void> {
286+
return new Promise<void>((resolve, reject) => {
287+
const id = setTimeout(resolve, ms);
288+
signal.addEventListener(
289+
'abort',
290+
() => {
291+
clearTimeout(id);
292+
reject(new DOMException('Animation cancelled', 'AbortError'));
293+
},
294+
{ once: true }
295+
);
296+
});
297+
}
298+
299+
/**
300+
* Animate the item to a specific position using CSS transitions.
301+
* Returns a Promise that resolves when the animation completes, or rejects if
302+
* the given AbortSignal is fired.
303+
*/
304+
private animateToPosition(position: number, duration: number, signal: AbortSignal): Promise<void> {
305+
return new Promise<void>((resolve, reject) => {
306+
if (!this.item) {
307+
return resolve();
308+
}
309+
310+
this.item.style.transition = `transform ${duration}ms ease-out`;
311+
this.item.style.transform = `translate3d(${-position}px, 0, 0)`;
312+
313+
const id = setTimeout(resolve, duration);
314+
signal.addEventListener(
315+
'abort',
316+
() => {
317+
clearTimeout(id);
318+
reject(new DOMException('Animation cancelled', 'AbortError'));
319+
},
320+
{ once: true }
321+
);
322+
});
323+
}
324+
325+
/**
326+
* Calculate the swipe threshold distance required to trigger a full swipe animation.
327+
* Returns the maximum options width plus a margin to ensure it's achievable.
328+
*/
329+
private getSwipeThreshold(direction: 'start' | 'end'): number {
330+
const maxWidth = direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide;
331+
return maxWidth + SWIPE_MARGIN;
332+
}
333+
334+
/**
335+
* Animate the item through a full swipe sequence: off-screen → trigger action → return.
336+
* This is used when an expandable option is swiped beyond the threshold.
337+
*/
338+
private async animateFullSwipe(direction: 'start' | 'end') {
339+
const abortController = new AbortController();
340+
this.animationAbortController = abortController;
341+
const { signal } = abortController;
342+
343+
// Prevent interruption during animation
344+
if (this.gesture) {
345+
this.gesture.enable(false);
346+
}
347+
348+
try {
349+
const options = direction === 'end' ? this.rightOptions : this.leftOptions;
350+
351+
// Trigger expandable state without moving the item
352+
// Set state directly so expandable option fills its container, starting from
353+
// the exact position where the user released, without any visual snap.
354+
this.state =
355+
direction === 'end'
356+
? SlidingState.End | SlidingState.SwipeEnd | SlidingState.AnimatingFullSwipe
357+
: SlidingState.Start | SlidingState.SwipeStart | SlidingState.AnimatingFullSwipe;
358+
359+
await this.delay(100, signal);
360+
361+
// Animate off-screen while maintaining the expanded state
362+
const offScreenDistance = direction === 'end' ? window.innerWidth : -window.innerWidth;
363+
await this.animateToPosition(offScreenDistance, 250, signal);
364+
365+
// Trigger action
366+
if (options) {
367+
options.fireSwipeEvent();
368+
}
369+
370+
// Small delay before returning
371+
await this.delay(300, signal);
372+
373+
// Return to closed state
374+
await this.animateToPosition(0, 250, signal);
375+
} catch {
376+
// Animation was aborted (e.g. component disconnected). finally handles cleanup.
377+
} finally {
378+
this.animationAbortController = undefined;
379+
380+
// Reset state
381+
if (this.item) {
382+
this.item.style.transition = '';
383+
this.item.style.transform = '';
384+
}
385+
this.openAmount = 0;
386+
this.state = SlidingState.Disabled;
387+
388+
if (openSlidingItem === this.el) {
389+
openSlidingItem = undefined;
390+
}
391+
392+
if (this.gesture) {
393+
this.gesture.enable(!this.disabled);
394+
}
395+
}
396+
}
397+
251398
private async updateOptions() {
252399
const options = this.el.querySelectorAll('ion-item-options');
253400

@@ -370,6 +517,27 @@ export class ItemSliding implements ComponentInterface {
370517
resetContentScrollY(contentEl, initialContentScrollY);
371518
}
372519

520+
// Check for full swipe conditions with expandable options
521+
const rawSwipeDistance = Math.abs(gesture.deltaX);
522+
const direction = gesture.deltaX < 0 ? 'end' : 'start';
523+
const options = direction === 'end' ? this.rightOptions : this.leftOptions;
524+
const hasExpandable = this.hasExpandableOptions(options);
525+
526+
const shouldTriggerFullSwipe =
527+
hasExpandable &&
528+
(rawSwipeDistance > this.getSwipeThreshold(direction) ||
529+
(Math.abs(gesture.velocityX) > 0.5 &&
530+
rawSwipeDistance > (direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide) * 0.5));
531+
532+
if (shouldTriggerFullSwipe) {
533+
this.animateFullSwipe(direction).catch(() => {
534+
if (this.gesture) {
535+
this.gesture.enable(!this.disabled);
536+
}
537+
});
538+
return;
539+
}
540+
373541
const velocity = gesture.velocityX;
374542

375543
let restingPoint = this.openAmount > 0 ? this.optsWidthRightSide : -this.optsWidthLeftSide;

core/src/components/item-sliding/test/basic/item-sliding.e2e.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
2323
* Positive dragByX value to drag element from the left to the right
2424
* to reveal the options on the left side.
2525
*/
26-
const dragByX = config.direction === 'rtl' ? -150 : 150;
26+
const dragByX = config.direction === 'rtl' ? -100 : 100;
2727

28-
await dragElementBy(item, page, dragByX);
28+
await dragElementBy(item, page, dragByX, 0, undefined, undefined, true, 20);
2929
await page.waitForChanges();
3030

3131
await expect(item).toHaveScreenshot(screenshot('item-sliding-start'));
@@ -44,7 +44,7 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
4444
*/
4545
const dragByX = config.direction === 'rtl' ? 150 : -150;
4646

47-
await dragElementBy(item, page, dragByX);
47+
await dragElementBy(item, page, dragByX, 0, undefined, undefined, true, 20);
4848

4949
await expect(item).toHaveScreenshot(screenshot('item-sliding-end'));
5050
});
@@ -61,7 +61,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
6161
await page.goto(`/src/components/item-sliding/test/basic`, config);
6262
const item = page.locator('#item2');
6363

64-
await dragElementBy(item, page, -150);
64+
await dragElementBy(item, page, -150, 0, undefined, undefined, true, 20);
6565
await page.waitForChanges();
6666

6767
// item-sliding doesn't have an easy way to tell whether it's fully open so just screenshot it
@@ -141,7 +141,7 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
141141
const item = page.locator('ion-item-sliding');
142142

143143
const dragByX = direction == 'rtl' ? -150 : 150;
144-
await dragElementBy(item, page, dragByX);
144+
await dragElementBy(item, page, dragByX, 0, undefined, undefined, true, 20);
145145
await page.waitForChanges();
146146

147147
await expect(item).toHaveScreenshot(screenshot(`item-sliding-safe-area-left`));
@@ -182,7 +182,7 @@ configs({ modes: ['ios', 'md', 'ionic-md'] }).forEach(({ title, screenshot, conf
182182
const item = page.locator('ion-item-sliding');
183183

184184
const dragByX = direction == 'rtl' ? 150 : -150;
185-
await dragElementBy(item, page, dragByX);
185+
await dragElementBy(item, page, dragByX, 0, undefined, undefined, true, 20);
186186
await page.waitForChanges();
187187

188188
await expect(item).toHaveScreenshot(screenshot(`item-sliding-safe-area-right`));
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<!DOCTYPE html>
2+
<html lang="en" dir="ltr">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>Item Sliding - Full Swipe</title>
6+
<meta
7+
name="viewport"
8+
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
9+
/>
10+
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
11+
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
12+
<script src="../../../../../scripts/testing/scripts.js"></script>
13+
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
14+
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
15+
<style>
16+
h2 {
17+
font-size: 12px;
18+
font-weight: normal;
19+
20+
color: #6f7378;
21+
22+
margin-top: 10px;
23+
margin-left: 5px;
24+
}
25+
</style>
26+
</head>
27+
28+
<body>
29+
<ion-app>
30+
<ion-header>
31+
<ion-toolbar>
32+
<ion-title>Item Sliding - Full Swipe</ion-title>
33+
</ion-toolbar>
34+
</ion-header>
35+
36+
<ion-content>
37+
<div class="ion-padding-start" style="padding-top: 30px">
38+
<h2>Full Swipe - Expandable Options</h2>
39+
</div>
40+
<ion-list>
41+
<!-- Expandable option on end side -->
42+
<ion-item-sliding id="expandable-end">
43+
<ion-item>
44+
<ion-label>Expandable End (Swipe Left)</ion-label>
45+
</ion-item>
46+
<ion-item-options side="end">
47+
<ion-item-option expandable="true" color="danger">Delete</ion-item-option>
48+
</ion-item-options>
49+
</ion-item-sliding>
50+
51+
<!-- Expandable on start side -->
52+
<ion-item-sliding id="expandable-start">
53+
<ion-item>
54+
<ion-label>Expandable Start (Swipe Right)</ion-label>
55+
</ion-item>
56+
<ion-item-options side="start">
57+
<ion-item-option expandable="true" color="success">Archive</ion-item-option>
58+
</ion-item-options>
59+
</ion-item-sliding>
60+
61+
<!-- Both sides with expandable -->
62+
<ion-item-sliding id="expandable-both">
63+
<ion-item>
64+
<ion-label>Expandable Both Sides</ion-label>
65+
</ion-item>
66+
<ion-item-options side="start">
67+
<ion-item-option expandable="true" color="success">Archive</ion-item-option>
68+
</ion-item-options>
69+
<ion-item-options side="end">
70+
<ion-item-option expandable="true" color="danger">Delete</ion-item-option>
71+
</ion-item-options>
72+
</ion-item-sliding>
73+
</ion-list>
74+
75+
<div class="ion-padding-start" style="padding-top: 30px">
76+
<h2>Non-Expandable Options (No Full Swipe)</h2>
77+
</div>
78+
<ion-list>
79+
<!-- Non-expandable option -->
80+
<ion-item-sliding id="non-expandable">
81+
<ion-item>
82+
<ion-label>Non-Expandable (Should Show Options)</ion-label>
83+
</ion-item>
84+
<ion-item-options side="end">
85+
<ion-item-option color="primary">Edit</ion-item-option>
86+
</ion-item-options>
87+
</ion-item-sliding>
88+
89+
<!-- Multiple non-expandable options -->
90+
<ion-item-sliding id="non-expandable-multiple">
91+
<ion-item>
92+
<ion-label>Multiple Non-Expandable Options</ion-label>
93+
</ion-item>
94+
<ion-item-options side="end">
95+
<ion-item-option color="primary">Edit</ion-item-option>
96+
<ion-item-option color="secondary">Share</ion-item-option>
97+
<ion-item-option color="danger">Delete</ion-item-option>
98+
</ion-item-options>
99+
</ion-item-sliding>
100+
</ion-list>
101+
102+
<div class="ion-padding-start" style="padding-top: 30px">
103+
<h2>Mixed Scenarios</h2>
104+
</div>
105+
<ion-list>
106+
<!-- Expandable with multiple options -->
107+
<ion-item-sliding id="expandable-with-others">
108+
<ion-item>
109+
<ion-label>Expandable + Other Options</ion-label>
110+
</ion-item>
111+
<ion-item-options side="end">
112+
<ion-item-option color="primary">Edit</ion-item-option>
113+
<ion-item-option expandable="true" color="danger">Delete</ion-item-option>
114+
</ion-item-options>
115+
</ion-item-sliding>
116+
</ion-list>
117+
</ion-content>
118+
</ion-app>
119+
<script>
120+
// Log swipe events for debugging
121+
document.querySelectorAll('ion-item-sliding').forEach((item) => {
122+
const id = item.getAttribute('id');
123+
item.querySelectorAll('ion-item-options').forEach((options) => {
124+
options.addEventListener('ionSwipe', () => {
125+
console.log(`[${id}] ionSwipe fired on ${options.getAttribute('side')} side`);
126+
});
127+
});
128+
});
129+
</script>
130+
</body>
131+
</html>

0 commit comments

Comments
 (0)