-
Notifications
You must be signed in to change notification settings - Fork 13.4k
feat(item-sliding): add automatic full expand animation of items #31036
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from 31 commits
6017570
994b969
a6f271f
63274c0
bd8568f
87ddc89
e0736dc
258e9df
0e9abb2
ed9bee5
08388ee
9aaecc9
24861e5
9fc848a
60df9d6
bad2ac7
f76e46e
4cb32a1
51db026
7762854
55eec20
004e33c
f06c1ad
cdbcdb7
bfc4073
c66de41
9f5e28a
754be74
5a64eac
b63ddfc
1130b7e
9a58724
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ const enum SlidingState { | |
|
|
||
| SwipeEnd = 1 << 5, | ||
| SwipeStart = 1 << 6, | ||
| AnimatingFullSwipe = 1 << 7, | ||
| } | ||
|
|
||
| let openSlidingItem: HTMLIonItemSlidingElement | undefined; | ||
|
|
@@ -113,6 +114,20 @@ export class ItemSliding implements ComponentInterface { | |
| this.gesture = undefined; | ||
| } | ||
|
|
||
| if (this.tmr !== undefined) { | ||
| clearTimeout(this.tmr); | ||
| this.tmr = undefined; | ||
| } | ||
|
|
||
| // Cancel animation if in progress | ||
| if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) { | ||
| if (this.item) { | ||
| this.item.style.transition = ''; | ||
| this.item.style.transform = ''; | ||
| } | ||
| this.state = SlidingState.Disabled; | ||
| } | ||
|
|
||
| this.item = null; | ||
| this.leftOptions = this.rightOptions = undefined; | ||
|
|
||
|
|
@@ -248,6 +263,111 @@ export class ItemSliding implements ComponentInterface { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Check if the given item options element contains at least one expandable, non-disabled option. | ||
| */ | ||
| private hasExpandableOptions(options: HTMLIonItemOptionsElement | undefined): boolean { | ||
| if (!options) return false; | ||
|
|
||
| const optionElements = options.querySelectorAll('ion-item-option'); | ||
| return Array.from(optionElements).some((option: any) => { | ||
| return option.expandable === true && !option.disabled; | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Animate the item to a specific position using CSS transitions. | ||
| * Returns a Promise that resolves when the animation completes. | ||
| */ | ||
| private animateToPosition(position: number, duration: number): Promise<void> { | ||
| return new Promise((resolve) => { | ||
| if (!this.item) { | ||
| return resolve(); | ||
| } | ||
|
|
||
| this.item.style.transition = `transform ${duration}ms ease-out`; | ||
| this.item.style.transform = `translate3d(${-position}px, 0, 0)`; | ||
|
|
||
| // tmr is shared with setOpenAmount's close timer; this is safe because | ||
| // animateFullSwipe only runs while the gesture is disabled. | ||
| this.tmr = setTimeout(() => { | ||
| this.tmr = undefined; | ||
| resolve(); | ||
| }, duration); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Calculate the swipe threshold distance required to trigger a full swipe animation. | ||
| * Returns the maximum options width plus a margin to ensure it's achievable. | ||
| */ | ||
| private getSwipeThreshold(direction: 'start' | 'end'): number { | ||
| const maxWidth = direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide; | ||
| return maxWidth + 30; // Slightly larger than SWIPE_MARGIN to be achievable | ||
| } | ||
|
|
||
| /** | ||
| * Animate the item through a full swipe sequence: off-screen → trigger action → return. | ||
| * This is used when an expandable option is swiped beyond the threshold. | ||
| */ | ||
| private async animateFullSwipe(direction: 'start' | 'end') { | ||
| // Prevent interruption during animation | ||
| if (this.gesture) { | ||
| this.gesture.enable(false); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're disabling the gesture here to prevent interruption, but |
||
| } | ||
|
|
||
| try { | ||
| const options = direction === 'end' ? this.rightOptions : this.leftOptions; | ||
|
|
||
| // Trigger expandable state without moving the item | ||
| // Set state directly so expandable option fills its container, starting from | ||
| // the exact position where the user released, without any visual snap. | ||
| this.state = | ||
| direction === 'end' | ||
| ? SlidingState.End | SlidingState.SwipeEnd | SlidingState.AnimatingFullSwipe | ||
| : SlidingState.Start | SlidingState.SwipeStart | SlidingState.AnimatingFullSwipe; | ||
|
|
||
| await new Promise<void>((resolve) => { | ||
|
||
| this.tmr = setTimeout(() => { | ||
| this.tmr = undefined; | ||
| resolve(); | ||
| }, 100); | ||
| }); | ||
|
|
||
| // Animate off-screen while maintaining the expanded state | ||
| const offScreenDistance = direction === 'end' ? window.innerWidth : -window.innerWidth; | ||
| await this.animateToPosition(offScreenDistance, 250); | ||
|
|
||
| // Trigger action | ||
| if (options) { | ||
| options.fireSwipeEvent(); | ||
| } | ||
|
|
||
| // Small delay before returning | ||
| await new Promise<void>((resolve) => { | ||
| this.tmr = setTimeout(() => { | ||
| this.tmr = undefined; | ||
| resolve(); | ||
| }, 300); | ||
| }); | ||
|
|
||
| // Return to closed state | ||
| await this.animateToPosition(0, 250); | ||
| } finally { | ||
| // Reset state | ||
| if (this.item) { | ||
| this.item.style.transition = ''; | ||
| } | ||
| this.openAmount = 0; | ||
| this.state = SlidingState.Disabled; | ||
| openSlidingItem = undefined; | ||
|
||
|
|
||
| if (this.gesture) { | ||
| this.gesture.enable(!this.disabled); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private async updateOptions() { | ||
| const options = this.el.querySelectorAll('ion-item-options'); | ||
|
|
||
|
|
@@ -370,6 +490,23 @@ export class ItemSliding implements ComponentInterface { | |
| resetContentScrollY(contentEl, initialContentScrollY); | ||
| } | ||
|
|
||
| // Check for full swipe conditions with expandable options | ||
| const rawSwipeDistance = Math.abs(gesture.deltaX); | ||
| const direction = gesture.deltaX < 0 ? 'end' : 'start'; | ||
| const options = direction === 'end' ? this.rightOptions : this.leftOptions; | ||
| const hasExpandable = this.hasExpandableOptions(options); | ||
|
|
||
| const shouldTriggerFullSwipe = | ||
| hasExpandable && | ||
| (rawSwipeDistance > this.getSwipeThreshold(direction) || | ||
| (Math.abs(gesture.velocityX) > 0.5 && | ||
| rawSwipeDistance > (direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide) * 0.5)); | ||
|
|
||
| if (shouldTriggerFullSwipe) { | ||
| this.animateFullSwipe(direction); | ||
|
||
| return; | ||
| } | ||
|
|
||
| const velocity = gesture.velocityX; | ||
|
|
||
| let restingPoint = this.openAmount > 0 ? this.optsWidthRightSide : -this.optsWidthLeftSide; | ||
|
|
||
OS-pedrolourenco marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| <!DOCTYPE html> | ||
| <html lang="en" dir="ltr"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <title>Item Sliding - Full Swipe</title> | ||
| <meta | ||
| name="viewport" | ||
| content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" | ||
| /> | ||
| <link href="../../../../../css/ionic.bundle.css" rel="stylesheet" /> | ||
| <link href="../../../../../scripts/testing/styles.css" rel="stylesheet" /> | ||
| <script src="../../../../../scripts/testing/scripts.js"></script> | ||
| <script nomodule src="../../../../../dist/ionic/ionic.js"></script> | ||
| <script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script> | ||
| <style> | ||
| h2 { | ||
| font-size: 12px; | ||
| font-weight: normal; | ||
|
|
||
| color: #6f7378; | ||
|
|
||
| margin-top: 10px; | ||
| margin-left: 5px; | ||
| } | ||
| </style> | ||
| </head> | ||
|
|
||
| <body> | ||
| <ion-app> | ||
| <ion-header> | ||
| <ion-toolbar> | ||
| <ion-title>Item Sliding - Full Swipe</ion-title> | ||
| </ion-toolbar> | ||
| </ion-header> | ||
|
|
||
| <ion-content> | ||
| <div class="ion-padding-start" style="padding-top: 30px"> | ||
| <h2>Full Swipe - Expandable Options</h2> | ||
| </div> | ||
| <ion-list> | ||
| <!-- Expandable option on end side --> | ||
| <ion-item-sliding id="expandable-end"> | ||
| <ion-item> | ||
| <ion-label>Expandable End (Swipe Left)</ion-label> | ||
| </ion-item> | ||
| <ion-item-options side="end"> | ||
| <ion-item-option expandable="true" color="danger">Delete</ion-item-option> | ||
| </ion-item-options> | ||
| </ion-item-sliding> | ||
|
|
||
| <!-- Expandable on start side --> | ||
| <ion-item-sliding id="expandable-start"> | ||
| <ion-item> | ||
| <ion-label>Expandable Start (Swipe Right)</ion-label> | ||
| </ion-item> | ||
| <ion-item-options side="start"> | ||
| <ion-item-option expandable="true" color="success">Archive</ion-item-option> | ||
| </ion-item-options> | ||
| </ion-item-sliding> | ||
|
|
||
| <!-- Both sides with expandable --> | ||
| <ion-item-sliding id="expandable-both"> | ||
| <ion-item> | ||
| <ion-label>Expandable Both Sides</ion-label> | ||
| </ion-item> | ||
| <ion-item-options side="start"> | ||
| <ion-item-option expandable="true" color="success">Archive</ion-item-option> | ||
| </ion-item-options> | ||
| <ion-item-options side="end"> | ||
| <ion-item-option expandable="true" color="danger">Delete</ion-item-option> | ||
| </ion-item-options> | ||
| </ion-item-sliding> | ||
| </ion-list> | ||
|
|
||
| <div class="ion-padding-start" style="padding-top: 30px"> | ||
| <h2>Non-Expandable Options (No Full Swipe)</h2> | ||
| </div> | ||
| <ion-list> | ||
| <!-- Non-expandable option --> | ||
| <ion-item-sliding id="non-expandable"> | ||
| <ion-item> | ||
| <ion-label>Non-Expandable (Should Show Options)</ion-label> | ||
| </ion-item> | ||
| <ion-item-options side="end"> | ||
| <ion-item-option color="primary">Edit</ion-item-option> | ||
| </ion-item-options> | ||
| </ion-item-sliding> | ||
|
|
||
| <!-- Multiple non-expandable options --> | ||
| <ion-item-sliding id="non-expandable-multiple"> | ||
| <ion-item> | ||
| <ion-label>Multiple Non-Expandable Options</ion-label> | ||
| </ion-item> | ||
| <ion-item-options side="end"> | ||
| <ion-item-option color="primary">Edit</ion-item-option> | ||
| <ion-item-option color="secondary">Share</ion-item-option> | ||
| <ion-item-option color="danger">Delete</ion-item-option> | ||
| </ion-item-options> | ||
| </ion-item-sliding> | ||
| </ion-list> | ||
|
|
||
| <div class="ion-padding-start" style="padding-top: 30px"> | ||
| <h2>Mixed Scenarios</h2> | ||
| </div> | ||
| <ion-list> | ||
| <!-- Expandable with multiple options --> | ||
| <ion-item-sliding id="expandable-with-others"> | ||
| <ion-item> | ||
| <ion-label>Expandable + Other Options</ion-label> | ||
| </ion-item> | ||
| <ion-item-options side="end"> | ||
| <ion-item-option color="primary">Edit</ion-item-option> | ||
| <ion-item-option expandable="true" color="danger">Delete</ion-item-option> | ||
| </ion-item-options> | ||
| </ion-item-sliding> | ||
| </ion-list> | ||
| </ion-content> | ||
| </ion-app> | ||
| <script> | ||
| // Log swipe events for debugging | ||
| document.querySelectorAll('ion-item-sliding').forEach((item) => { | ||
| const id = item.getAttribute('id'); | ||
| item.querySelectorAll('ion-item-options').forEach((options) => { | ||
| options.addEventListener('ionSwipe', () => { | ||
| console.log(`[${id}] ionSwipe fired on ${options.getAttribute('side')} side`); | ||
| }); | ||
| }); | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment says 'slightly larger than SWIPE_MARGIN' but this is exactly
SWIPE_MARGIN(both 30). Should this just reference the constant?return maxWidth + SWIPE_MARGIN;That way they stay in sync if the margin ever changes.