Skip to content

Commit a6ea277

Browse files
feat(item-sliding): added specific animations for ionic
1 parent 69e5ee1 commit a6ea277

2 files changed

Lines changed: 256 additions & 19 deletions

File tree

core/src/components/item-option/item-option.ionic.scss

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,3 @@
9999
background: globals.current-color(base);
100100
color: globals.current-color(contrast);
101101
}
102-
103-
// Item Expandable Animation
104-
// --------------------------------------------------
105-
106-
:host(.item-option-expandable) {
107-
transition-timing-function: globals.$ion-transition-curve-expressive;
108-
}

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

Lines changed: 256 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ import type { Side } from '../menu/menu-interface';
1111

1212
const SWIPE_MARGIN = 30;
1313
const ELASTIC_FACTOR = 0.55;
14+
const IONIC_SNAP_OPEN_RATIO = 0.4;
15+
const IONIC_EXPAND_TRIGGER = 80;
16+
const IONIC_VELOCITY_THRESHOLD = 0.4;
17+
const IONIC_ACTION_BASE_WIDTH = 64;
18+
const IONIC_OPEN_TRANSITION = '250ms cubic-bezier(0.25, 1, 0.5, 1)';
19+
const IONIC_SNAPBACK_TRANSITION = '300ms cubic-bezier(0.34, 1.4, 0.64, 1)';
20+
const IONIC_CONFIRM_EASE_IN = '150ms ease-in';
21+
const IONIC_CONFIRM_SNAPBACK = '480ms cubic-bezier(0.34, 1.4, 0.64, 1)';
22+
const IONIC_CONFIRM_PAUSE = 900;
23+
const IONIC_EXPAND_RESISTANCE_FACTOR = 0.95;
24+
25+
/** Expandable, non-disabled option (matches item-option expandable class). */
26+
const EXPANDABLE_OPTION_SELECTOR = 'ion-item-option.item-option-expandable:not(.item-option-disabled)';
1427

1528
const enum ItemSide {
1629
None = 0,
@@ -38,7 +51,11 @@ let openSlidingItem: HTMLIonItemSlidingElement | undefined;
3851
*/
3952
@Component({
4053
tag: 'ion-item-sliding',
41-
styleUrl: 'item-sliding.scss',
54+
styleUrls: {
55+
ios: 'item-sliding.scss',
56+
md: 'item-sliding.scss',
57+
ionic: 'item-sliding.scss',
58+
},
4259
})
4360
export class ItemSliding implements ComponentInterface {
4461
private item: HTMLIonItemElement | null = null;
@@ -56,6 +73,12 @@ export class ItemSliding implements ComponentInterface {
5673
private contentEl: HTMLElement | null = null;
5774
private initialContentScrollY = true;
5875
private mutationObserver?: MutationObserver;
76+
private leftExpandableBaseWidth = IONIC_ACTION_BASE_WIDTH;
77+
private rightExpandableBaseWidth = IONIC_ACTION_BASE_WIDTH;
78+
79+
private isIonicTheme(): boolean {
80+
return getIonTheme(this) === 'ionic';
81+
}
5982

6083
@Element() el!: HTMLIonItemSlidingElement;
6184

@@ -79,7 +102,6 @@ export class ItemSliding implements ComponentInterface {
79102

80103
async connectedCallback() {
81104
const { el } = this;
82-
83105
this.item = el.querySelector('ion-item');
84106
this.contentEl = findClosestIonContent(el);
85107

@@ -332,8 +354,8 @@ export class ItemSliding implements ComponentInterface {
332354
}
333355

334356
/**
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.
357+
* Native (ios/md) full swipe: off-screen → fire swipe → return.
358+
* Ionic theme uses `animateIonicFullSwipe` instead (see `onEndIonic`).
337359
*/
338360
private async animateFullSwipe(direction: 'start' | 'end') {
339361
const abortController = new AbortController();
@@ -395,6 +417,157 @@ export class ItemSliding implements ComponentInterface {
395417
}
396418
}
397419

420+
private queryExpandableOption(options?: HTMLIonItemOptionsElement): HTMLIonItemOptionElement | undefined {
421+
return options?.querySelector<HTMLIonItemOptionElement>(EXPANDABLE_OPTION_SELECTOR) ?? undefined;
422+
}
423+
424+
private getExpandableOption(direction: 'start' | 'end'): HTMLIonItemOptionElement | undefined {
425+
return this.queryExpandableOption(direction === 'end' ? this.rightOptions : this.leftOptions);
426+
}
427+
428+
private getOpenDirectionFromAmount(openAmount: number): 'start' | 'end' | undefined {
429+
if (openAmount > 0) {
430+
return 'end';
431+
}
432+
if (openAmount < 0) {
433+
return 'start';
434+
}
435+
return undefined;
436+
}
437+
438+
private getOptionsWidthForDirection(direction: 'start' | 'end'): number {
439+
return direction === 'end' ? this.optsWidthRightSide : this.optsWidthLeftSide;
440+
}
441+
442+
private getExpandableBaseWidth(direction: 'start' | 'end'): number {
443+
return direction === 'end' ? this.rightExpandableBaseWidth : this.leftExpandableBaseWidth;
444+
}
445+
446+
private setIonicExpandableWidth(direction: 'start' | 'end', width: number, animate: boolean, easing?: string) {
447+
const expandableOption = this.getExpandableOption(direction);
448+
if (!expandableOption) {
449+
return;
450+
}
451+
452+
const style = expandableOption.style;
453+
style.transition = animate ? `width ${easing ?? IONIC_OPEN_TRANSITION}` : 'none';
454+
const baseWidth = this.getExpandableBaseWidth(direction);
455+
style.width = `${Math.max(baseWidth, width)}px`;
456+
}
457+
458+
private resetIonicExpandableOptions() {
459+
[this.leftOptions, this.rightOptions].forEach((options) => {
460+
const expandableOption = this.queryExpandableOption(options);
461+
if (!expandableOption) {
462+
return;
463+
}
464+
expandableOption.style.transition = '';
465+
expandableOption.style.width = '';
466+
});
467+
}
468+
469+
private updateIonicExpandableFromOpenAmount(openAmount: number, isFinal: boolean, previousOpenAmount: number) {
470+
if ((this.state & SlidingState.AnimatingFullSwipe) !== 0) {
471+
return;
472+
}
473+
474+
const direction = this.getOpenDirectionFromAmount(openAmount);
475+
if (direction === undefined) {
476+
const previousDirection = this.getOpenDirectionFromAmount(previousOpenAmount);
477+
if (previousDirection === undefined) {
478+
this.resetIonicExpandableOptions();
479+
return;
480+
}
481+
482+
this.setIonicExpandableWidth(
483+
previousDirection,
484+
this.getExpandableBaseWidth(previousDirection),
485+
isFinal,
486+
IONIC_SNAPBACK_TRANSITION
487+
);
488+
return;
489+
}
490+
491+
const baseWidth = this.getExpandableBaseWidth(direction);
492+
const optionsWidth = this.getOptionsWidthForDirection(direction);
493+
const extraWidth = Math.max(0, Math.abs(openAmount) - optionsWidth);
494+
const resistedExtraWidth = isFinal ? extraWidth : extraWidth * IONIC_EXPAND_RESISTANCE_FACTOR;
495+
const targetWidth = baseWidth + resistedExtraWidth;
496+
const easing = openAmount === 0 ? IONIC_SNAPBACK_TRANSITION : IONIC_OPEN_TRANSITION;
497+
498+
this.setIonicExpandableWidth(direction, targetWidth, isFinal, easing);
499+
}
500+
501+
private async animateIonicFullSwipe(direction: 'start' | 'end') {
502+
const abortController = new AbortController();
503+
this.animationAbortController = abortController;
504+
const { signal } = abortController;
505+
const expandableOption = this.getExpandableOption(direction);
506+
const options = direction === 'end' ? this.rightOptions : this.leftOptions;
507+
508+
if (this.gesture) {
509+
this.gesture.enable(false);
510+
}
511+
512+
try {
513+
this.state =
514+
direction === 'end'
515+
? SlidingState.End | SlidingState.AnimatingFullSwipe
516+
: SlidingState.Start | SlidingState.AnimatingFullSwipe;
517+
518+
if (!this.item) {
519+
return;
520+
}
521+
522+
const itemWidth = this.el.offsetWidth || window.innerWidth;
523+
const baseWidth = this.getExpandableBaseWidth(direction);
524+
const expandableTargetWidth = Math.max(baseWidth, itemWidth - 16);
525+
const offScreenPosition = direction === 'end' ? itemWidth : -itemWidth;
526+
527+
if (expandableOption) {
528+
expandableOption.style.transition = `width ${IONIC_CONFIRM_EASE_IN}`;
529+
expandableOption.style.width = `${expandableTargetWidth}px`;
530+
531+
}
532+
533+
this.item.style.transition = `transform ${IONIC_CONFIRM_EASE_IN}`;
534+
this.item.style.transform = `translate3d(${-offScreenPosition}px, 0, 0)`;
535+
await this.delay(150, signal);
536+
537+
options?.fireSwipeEvent();
538+
await this.delay(IONIC_CONFIRM_PAUSE, signal);
539+
540+
if (expandableOption) {
541+
expandableOption.style.transition = `width ${IONIC_CONFIRM_SNAPBACK}`;
542+
expandableOption.style.width = `${baseWidth}px`;
543+
}
544+
545+
this.item.style.transition = `transform ${IONIC_CONFIRM_SNAPBACK}`;
546+
this.item.style.transform = 'translate3d(0, 0, 0)';
547+
await this.delay(480, signal);
548+
} catch {
549+
// Animation was aborted. finally handles cleanup.
550+
} finally {
551+
this.animationAbortController = undefined;
552+
553+
if (this.item) {
554+
this.item.style.transition = '';
555+
this.item.style.transform = '';
556+
}
557+
this.resetIonicExpandableOptions();
558+
this.openAmount = 0;
559+
this.state = SlidingState.Disabled;
560+
561+
if (openSlidingItem === this.el) {
562+
openSlidingItem = undefined;
563+
}
564+
565+
if (this.gesture) {
566+
this.gesture.enable(!this.disabled);
567+
}
568+
}
569+
}
570+
398571
private async updateOptions() {
399572
const options = this.el.querySelectorAll('ion-item-options');
400573

@@ -512,11 +685,22 @@ export class ItemSliding implements ComponentInterface {
512685
}
513686

514687
private onEnd(gesture: GestureDetail) {
688+
this.restoreContentScrollAfterSlide();
689+
if (this.isIonicTheme()) {
690+
this.onEndIonic(gesture);
691+
} else {
692+
this.onEndNative(gesture);
693+
}
694+
}
695+
696+
private restoreContentScrollAfterSlide() {
515697
const { contentEl, initialContentScrollY } = this;
516698
if (contentEl) {
517699
resetContentScrollY(contentEl, initialContentScrollY);
518700
}
701+
}
519702

703+
private onEndNative(gesture: GestureDetail) {
520704
// Check for full swipe conditions with expandable options
521705
const rawSwipeDistance = Math.abs(gesture.deltaX);
522706
const direction = gesture.deltaX < 0 ? 'end' : 'start';
@@ -561,18 +745,77 @@ export class ItemSliding implements ComponentInterface {
561745
}
562746
}
563747

748+
private onEndIonic(gesture: GestureDetail) {
749+
const velocity = gesture.velocityX;
750+
const velocityX = velocity * 1000;
751+
const activeDirection = this.getOpenDirectionFromAmount(this.openAmount);
752+
if (activeDirection === undefined) {
753+
this.setOpenAmount(0, true);
754+
return;
755+
}
756+
757+
const optionsWidth = this.getOptionsWidthForDirection(activeDirection);
758+
const extraWidth = Math.max(0, Math.abs(this.openAmount) - optionsWidth);
759+
const hasExpandable = this.hasExpandableOptions(activeDirection === 'end' ? this.rightOptions : this.leftOptions);
760+
const wasRevealed = Math.abs(this.initialOpenAmount) >= optionsWidth;
761+
762+
if (
763+
hasExpandable &&
764+
(extraWidth >= IONIC_EXPAND_TRIGGER || (wasRevealed && velocityX < Math.abs(IONIC_VELOCITY_THRESHOLD * 1000)))
765+
) {
766+
this.animateIonicFullSwipe(activeDirection).catch(() => {
767+
if (this.gesture) {
768+
this.gesture.enable(!this.disabled);
769+
}
770+
});
771+
return;
772+
}
773+
774+
const closeDirection =
775+
activeDirection === 'end'
776+
? velocityX > IONIC_VELOCITY_THRESHOLD * 1000
777+
: velocityX < -IONIC_VELOCITY_THRESHOLD * 1000;
778+
if (closeDirection) {
779+
this.setOpenAmount(0, true);
780+
return;
781+
}
782+
783+
const openThreshold = optionsWidth * IONIC_SNAP_OPEN_RATIO;
784+
const shouldSnapOpen = Math.abs(this.openAmount) > openThreshold;
785+
const restingPoint = shouldSnapOpen
786+
? activeDirection === 'end'
787+
? this.optsWidthRightSide
788+
: -this.optsWidthLeftSide
789+
: 0;
790+
791+
this.setOpenAmount(restingPoint, true);
792+
}
793+
564794
private calculateOptsWidth() {
565795
this.optsWidthRightSide = 0;
566796
if (this.rightOptions) {
567797
this.rightOptions.style.display = 'flex';
568798
this.optsWidthRightSide = this.rightOptions.offsetWidth;
799+
const rightExpandable = this.queryExpandableOption(this.rightOptions);
800+
if (rightExpandable) {
801+
rightExpandable.style.width = '';
802+
this.rightExpandableBaseWidth = Math.max(
803+
IONIC_ACTION_BASE_WIDTH,
804+
rightExpandable.getBoundingClientRect().width
805+
);
806+
}
569807
this.rightOptions.style.display = '';
570808
}
571809

572810
this.optsWidthLeftSide = 0;
573811
if (this.leftOptions) {
574812
this.leftOptions.style.display = 'flex';
575813
this.optsWidthLeftSide = this.leftOptions.offsetWidth;
814+
const leftExpandable = this.queryExpandableOption(this.leftOptions);
815+
if (leftExpandable) {
816+
leftExpandable.style.width = '';
817+
this.leftExpandableBaseWidth = Math.max(IONIC_ACTION_BASE_WIDTH, leftExpandable.getBoundingClientRect().width);
818+
}
576819
this.leftOptions.style.display = '';
577820
}
578821

@@ -591,22 +834,23 @@ export class ItemSliding implements ComponentInterface {
591834
const { el } = this;
592835

593836
const style = this.item.style;
837+
const previousOpenAmount = this.openAmount;
594838
this.openAmount = openAmount;
595839

840+
if (this.isIonicTheme()) {
841+
this.updateIonicExpandableFromOpenAmount(openAmount, isFinal, previousOpenAmount);
842+
}
843+
596844
if (isFinal) {
597845
style.transition = '';
598846
}
599847

600848
if (openAmount > 0) {
601-
this.state =
602-
openAmount >= this.optsWidthRightSide + SWIPE_MARGIN
603-
? SlidingState.End | SlidingState.SwipeEnd
604-
: SlidingState.End;
849+
const fullSwipe = !this.isIonicTheme() && openAmount >= this.optsWidthRightSide + SWIPE_MARGIN;
850+
this.state = fullSwipe ? SlidingState.End | SlidingState.SwipeEnd : SlidingState.End;
605851
} else if (openAmount < 0) {
606-
this.state =
607-
openAmount <= -this.optsWidthLeftSide - SWIPE_MARGIN
608-
? SlidingState.Start | SlidingState.SwipeStart
609-
: SlidingState.Start;
852+
const fullSwipe = !this.isIonicTheme() && openAmount <= -this.optsWidthLeftSide - SWIPE_MARGIN;
853+
this.state = fullSwipe ? SlidingState.Start | SlidingState.SwipeStart : SlidingState.Start;
610854
} else {
611855
/**
612856
* The sliding options should not be

0 commit comments

Comments
 (0)