Skip to content

Commit 07e3448

Browse files
feat(popover): create ios26 popover animation (#59)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 1416c56 commit 07e3448

File tree

15 files changed

+1343
-8
lines changed

15 files changed

+1343
-8
lines changed

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,44 @@ To achieve higher fidelity to iOS26 design, you can implement additional design
9494
https://ionic-theme-ios26.netlify.app/main/docs
9595

9696

97-
### Experimental: Using Gesture Animation with `ion-tab-button` / `ion-segment-button`
97+
## Experimental: Animation
9898

9999
__This feature is experimental. The library can be used without this feature.__
100100

101+
### Enter and Leave animation with `ion-popover`
102+
103+
iOS26デザインした、IonicConfigで設定できるアニメーションを用意しています。
104+
105+
[![Image from Gyazo](https://i.gyazo.com/f3609bdc38f936149caa1e8617f54857.gif)](https://gyazo.com/f3609bdc38f936149caa1e8617f54857)
106+
107+
```js
108+
import { popoverEnterAnimation, popoverLeaveAnimation } from '@rdlabo/ionic-theme-ios26';
109+
110+
// Angular
111+
provideIonicAngular({
112+
...
113+
popoverEnter: popoverEnterAnimation,
114+
popoverLeave: popoverLeaveAnimation,
115+
});
116+
117+
// React
118+
setupIonicReact({
119+
...
120+
popoverEnter: popoverEnterAnimation,
121+
popoverLeave: popoverLeaveAnimation,
122+
});
123+
124+
// Vue
125+
createApp(App)
126+
.use(IonicVue, {
127+
...
128+
popoverEnter: popoverEnterAnimation,
129+
popoverLeave: popoverLeaveAnimation,
130+
})
131+
```
132+
133+
### Using Sheet of Glass with `ion-tab-button` / `ion-segment-button`
134+
101135
By registering `ion-tab-bar` / `ion-segment`, you can display animation effects on `ion-tab-button` / `ion-segment-button`
102136

103137
[![Image from Gyazo](https://i.gyazo.com/a57e8808156e5ce74463e6fa8c05ed61.gif)](https://gyazo.com/a57e8808156e5ce74463e6fa8c05ed61)
13.8 KB
Loading
15.2 KB
Loading

demo/src/app/app.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as allIcons from 'ionicons/icons';
55
import { routes } from './app.routes';
66
import { provideIonicAngular } from '@ionic/angular/standalone';
77
import { addIcons } from 'ionicons';
8+
import { popoverEnterAnimation, popoverLeaveAnimation } from '../../../src';
89

910
addIcons(allIcons);
1011

@@ -20,6 +21,8 @@ export const appConfig: ApplicationConfig = {
2021
mode: 'ios',
2122
backButtonText: '',
2223
animated: !isE2ETesting,
24+
popoverEnter: popoverEnterAnimation,
25+
popoverLeave: popoverLeaveAnimation,
2326
}),
2427
],
2528
};

demo/src/app/index/pages/popover/popover.page.html

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,73 @@ <h2>popover</h2>
2121
</ion-item-group>
2222
</ion-list>
2323

24-
<section class="section-example">
25-
<ion-button id="click-trigger">Click Me</ion-button>
26-
<ion-popover trigger="click-trigger" triggerAction="click">
24+
<ion-toolbar>
25+
<ion-buttons id="click-trigger-right-buttons" slot="end">
26+
<ion-button><ion-icon name="close-outline" slot="icon-only"></ion-icon></ion-button>
27+
</ion-buttons>
28+
</ion-toolbar>
29+
<ion-list [inset]="true">
30+
<ion-list-header><ion-label>ion-button/buttons</ion-label></ion-list-header>
31+
<ion-note>ion-button/ion-buttons has animation to replace ion-popover.</ion-note>
32+
<ion-item-group>
33+
<ion-item
34+
><ion-button id="click-trigger-left" slot="start">Click Me</ion-button
35+
><ion-button id="click-trigger-right" slot="end">Click Me</ion-button></ion-item
36+
>
37+
<ion-item>
38+
<ion-buttons id="click-trigger-left-buttons" slot="start"><ion-button>Click Buttons</ion-button></ion-buttons>
39+
</ion-item>
40+
</ion-item-group>
41+
<ion-popover trigger="click-trigger-left" triggerAction="click">
42+
<ng-template>
43+
<div class="ion-padding">
44+
<ion-text>Hello World!</ion-text>
45+
</div>
46+
</ng-template> </ion-popover
47+
><ion-popover trigger="click-trigger-right" triggerAction="click">
48+
<ng-template>
49+
<div class="ion-padding">
50+
<ion-text>Hello World!</ion-text>
51+
</div>
52+
</ng-template>
53+
</ion-popover>
54+
<ion-popover trigger="click-trigger-left-buttons" triggerAction="click">
55+
<ng-template>
56+
<div class="ion-padding">
57+
<ion-text>Hello World!</ion-text>
58+
</div>
59+
</ng-template> </ion-popover
60+
><ion-popover trigger="click-trigger-right-buttons" triggerAction="click">
2761
<ng-template>
2862
<div class="ion-padding">
2963
<ion-text>Hello World!</ion-text>
3064
</div>
3165
</ng-template>
3266
</ion-popover>
33-
</section>
67+
</ion-list>
68+
69+
<ion-list [inset]="true">
70+
<ion-list-header><ion-label>others</ion-label></ion-list-header>
71+
<ion-item-group>
72+
<ion-item id="click-trigger-item" [button]="true">
73+
<ion-label>Click ion-item self</ion-label>
74+
</ion-item>
75+
<ion-item>
76+
<button id="click-trigger-button" slot="end">Native HTML Button</button>
77+
</ion-item>
78+
</ion-item-group>
79+
<ion-popover trigger="click-trigger-item" triggerAction="click">
80+
<ng-template>
81+
<div class="ion-padding">
82+
<ion-text>Hello World!</ion-text>
83+
</div>
84+
</ng-template> </ion-popover
85+
><ion-popover trigger="click-trigger-button" triggerAction="click">
86+
<ng-template>
87+
<div class="ion-padding">
88+
<ion-text>Hello World!</ion-text>
89+
</div>
90+
</ng-template>
91+
</ion-popover>
92+
</ion-list>
3493
</ion-content>

demo/src/app/index/pages/popover/popover.page.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@ import { FormsModule } from '@angular/forms';
44
import {
55
IonBackButton,
66
IonButton,
7+
IonButtons,
78
IonContent,
89
IonHeader,
910
IonIcon,
1011
IonItem,
1112
IonItemGroup,
1213
IonLabel,
1314
IonList,
15+
IonListHeader,
16+
IonNote,
1417
IonPopover,
1518
IonText,
1619
IonTitle,
@@ -38,6 +41,9 @@ import {
3841
IonText,
3942
IonButton,
4043
IonPopover,
44+
IonButtons,
45+
IonListHeader,
46+
IonNote,
4147
],
4248
})
4349
export class PopoverPage implements OnInit {

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { registeredEffect } from './sheets-of-glass/interfaces';
22
import { registerEffect } from './sheets-of-glass';
33
export * from './sheets-of-glass/interfaces';
4+
export { iosEnterAnimation as popoverEnterAnimation } from './popover/animations/ios.enter';
5+
export { iosLeaveAnimation as popoverLeaveAnimation } from './popover/animations/ios.leave';
46

57
export const registerTabBarEffect = (targetElement: HTMLElement): registeredEffect | undefined => {
68
return registerEffect(targetElement, 'ion-tab-button', 'tab-selected', {
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { createAnimation, isPlatform } from '@ionic/core';
2+
import type { Animation } from '@ionic/core/dist/types/utils/animation/animation-interface';
3+
import { getElementRoot } from '../../utils';
4+
import { calculateWindowAdjustment, getArrowDimensions, getPopoverDimensions, getPopoverPosition, shouldShowArrow } from '../utils';
5+
6+
const POPOVER_IOS_BODY_PADDING = 5;
7+
export const POPOVER_IOS_BODY_MARGIN = 8;
8+
9+
/**
10+
* iOS Popover Enter Animation
11+
*/
12+
// TODO(FW-2832): types
13+
export const iosEnterAnimation = (baseEl: HTMLElement, opts?: any): Animation => {
14+
const { event: ev, size, trigger, reference, side, align } = opts;
15+
const doc = baseEl.ownerDocument as any;
16+
const isRTL = doc.dir === 'rtl';
17+
const bodyWidth = doc.defaultView.innerWidth;
18+
const bodyHeight = doc.defaultView.innerHeight;
19+
20+
const root = getElementRoot(baseEl);
21+
const contentEl = root.querySelector('.popover-content') as HTMLElement;
22+
const arrowEl = root.querySelector('.popover-arrow') as HTMLElement | null;
23+
24+
const referenceSizeEl = trigger || ev?.detail?.ionShadowTarget || ev?.target;
25+
const { contentWidth, contentHeight } = getPopoverDimensions(size, contentEl, referenceSizeEl);
26+
const { arrowWidth, arrowHeight } = getArrowDimensions(arrowEl);
27+
28+
const isReplace = ((): boolean => {
29+
if (!['ion-button', 'ion-buttons'].includes(referenceSizeEl.localName)) {
30+
return false;
31+
}
32+
if (referenceSizeEl.classList.contains('ios26-disabled')) {
33+
return false;
34+
}
35+
return true;
36+
})();
37+
38+
const defaultPosition = {
39+
top: bodyHeight / 2 - contentHeight / 2,
40+
left: bodyWidth / 2 - contentWidth / 2,
41+
originX: isRTL ? 'right' : 'left',
42+
originY: 'top',
43+
};
44+
45+
const results = getPopoverPosition(
46+
isRTL,
47+
contentWidth,
48+
contentHeight,
49+
arrowWidth,
50+
arrowHeight,
51+
reference,
52+
side,
53+
align,
54+
defaultPosition,
55+
trigger,
56+
ev,
57+
);
58+
59+
const padding = size === 'cover' ? 0 : POPOVER_IOS_BODY_PADDING;
60+
const margin = size === 'cover' ? 0 : 25;
61+
62+
const { originX, originY, top, left, bottom, checkSafeAreaLeft, checkSafeAreaRight, arrowTop, arrowLeft, addPopoverBottomClass } =
63+
calculateWindowAdjustment(
64+
side,
65+
results.top,
66+
results.left,
67+
padding,
68+
bodyWidth,
69+
bodyHeight,
70+
contentWidth,
71+
contentHeight,
72+
margin,
73+
results.originX,
74+
results.originY,
75+
results.referenceCoordinates,
76+
results.arrowTop,
77+
results.arrowLeft,
78+
arrowHeight,
79+
referenceSizeEl.getBoundingClientRect(),
80+
isReplace,
81+
);
82+
83+
const baseAnimation = createAnimation();
84+
const backdropAnimation = createAnimation();
85+
const contentAnimation = createAnimation();
86+
const targetAnimation = createAnimation();
87+
88+
backdropAnimation
89+
.delay(100)
90+
.duration(300)
91+
.addElement(root.querySelector('ion-backdrop')!)
92+
.fromTo('opacity', 0.01, 'var(--backdrop-opacity)')
93+
.beforeStyles({
94+
'pointer-events': 'none',
95+
})
96+
.afterClearStyles(['pointer-events']);
97+
98+
// In Chromium, if the wrapper animates, the backdrop filter doesn't work.
99+
// The Chromium team stated that this behavior is expected and not a bug. The element animating opacity creates a backdrop root for the backdrop-filter.
100+
// To get around this, instead of animating the wrapper, animate both the arrow and content.
101+
// https://bugs.chromium.org/p/chromium/issues/detail?id=1148826
102+
contentAnimation
103+
.easing('cubic-bezier(0, 1, 0.22, 1)')
104+
.delay(100)
105+
.duration(400)
106+
.addElement(root.querySelector('.popover-arrow')!)
107+
.addElement(root.querySelector('.popover-content')!)
108+
.beforeStyles({ 'transform-origin': `${originY} ${originX}` })
109+
.beforeAddWrite(() => {
110+
/**
111+
* 'transformOrigin' use for leave animation.
112+
*/
113+
root.querySelector<HTMLElement>('.popover-content')!.dataset['transformOrigin'] = `${originY} ${originX}`;
114+
})
115+
.fromTo('transform', 'scale(0)', 'scale(1)')
116+
.fromTo('opacity', 0.01, 1);
117+
// TODO(FW-4376) Ensure that arrow also blurs when translucent
118+
119+
if (isReplace) {
120+
targetAnimation
121+
.delay(0)
122+
.duration(200)
123+
.addElement(referenceSizeEl)
124+
.beforeStyles({ 'transform-origin': `${originY} ${originX}` })
125+
.beforeAddClass('ios26-replace-element')
126+
.fromTo('transform', 'scale(1)', 'scale(1.05)')
127+
.fromTo('opacity', 1, 0);
128+
}
129+
130+
return baseAnimation
131+
.easing('ease')
132+
.delay(100)
133+
.duration(100)
134+
.beforeAddWrite(() => {
135+
if (size === 'cover') {
136+
baseEl.style.setProperty('--width', `${contentWidth}px`);
137+
}
138+
139+
if (addPopoverBottomClass) {
140+
baseEl.classList.add('popover-bottom');
141+
}
142+
143+
if (bottom !== undefined) {
144+
contentEl.style.setProperty('bottom', `${bottom}px`);
145+
}
146+
147+
const safeAreaLeft = ' + var(--ion-safe-area-left, 0)';
148+
const safeAreaRight = ' - var(--ion-safe-area-right, 0)';
149+
150+
let leftValue = `${left}px`;
151+
152+
if (checkSafeAreaLeft) {
153+
leftValue = `${left}px${safeAreaLeft}`;
154+
}
155+
if (checkSafeAreaRight) {
156+
leftValue = `${left}px${safeAreaRight}`;
157+
}
158+
159+
contentEl.style.setProperty('top', `calc(${top}px + var(--offset-y, 0))`);
160+
contentEl.style.setProperty('left', `calc(${leftValue} + var(--offset-x, 0))`);
161+
contentEl.style.setProperty('transform-origin', `${originY} ${originX}`);
162+
163+
if (arrowEl !== null) {
164+
const didAdjustBounds = results.top !== top || results.left !== left;
165+
const showArrow = shouldShowArrow(side, didAdjustBounds, ev, trigger);
166+
167+
if (showArrow) {
168+
arrowEl.style.setProperty('top', `calc(${arrowTop}px + var(--offset-y, 0))`);
169+
arrowEl.style.setProperty('left', `calc(${arrowLeft}px + var(--offset-x, 0))`);
170+
} else {
171+
arrowEl.style.setProperty('display', 'none');
172+
}
173+
}
174+
})
175+
.addAnimation([backdropAnimation, contentAnimation, targetAnimation]);
176+
};

0 commit comments

Comments
 (0)