diff --git a/demo/e2e/screenshot.spec.ts-snapshots/button-dark.png b/demo/e2e/screenshot.spec.ts-snapshots/button-dark.png index ec38bfc7..15f89813 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/button-dark.png and b/demo/e2e/screenshot.spec.ts-snapshots/button-dark.png differ diff --git a/demo/e2e/screenshot.spec.ts-snapshots/button.png b/demo/e2e/screenshot.spec.ts-snapshots/button.png index 994071a0..b4f8e5a8 100644 Binary files a/demo/e2e/screenshot.spec.ts-snapshots/button.png and b/demo/e2e/screenshot.spec.ts-snapshots/button.png differ diff --git a/demo/src/app/index/pages/button/button.page.html b/demo/src/app/index/pages/button/button.page.html index 7f00de66..c5d926ae 100644 --- a/demo/src/app/index/pages/button/button.page.html +++ b/demo/src/app/index/pages/button/button.page.html @@ -4,6 +4,9 @@ button + + Push + diff --git a/demo/src/app/index/pages/button/button.page.ts b/demo/src/app/index/pages/button/button.page.ts index d99fca41..097f3db2 100644 --- a/demo/src/app/index/pages/button/button.page.ts +++ b/demo/src/app/index/pages/button/button.page.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, inject, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { @@ -17,6 +17,7 @@ import { IonText, IonTitle, IonToolbar, + NavController, } from '@demo/ionic'; @Component({ @@ -44,7 +45,12 @@ import { ], }) export class ButtonPage implements OnInit { + readonly navCtrl = inject(NavController); constructor() {} ngOnInit() {} + + navigateTo() { + return this.navCtrl.navigateForward('/main/index/action-sheet'); + } } diff --git a/src/styles/utils/translucent.scss b/src/styles/utils/translucent.scss index b12c0bb2..e6830ed5 100644 --- a/src/styles/utils/translucent.scss +++ b/src/styles/utils/translucent.scss @@ -51,8 +51,8 @@ ion-header.ios:not(.ios26-disabled).header-collapse-condense-inactive:not(.heade transform: translateY(8px); } -ion-header.ios:not(.ios26-disabled):not(.header-transitioning).header-translucent::before, -ion-header.ios:not(.ios26-disabled):not(.header-transitioning).header-translucent::after, +ion-header.ios:not(.ios26-disabled).header-translucent::before, +ion-header.ios:not(.ios26-disabled).header-translucent::after, ion-content.ios:not(.ios26-disabled).content-fullscreen::part(background)::before { content: ''; position: absolute; diff --git a/src/transition/ios.transition.ts b/src/transition/ios.transition.ts index c99ab4a1..e5301941 100644 --- a/src/transition/ios.transition.ts +++ b/src/transition/ios.transition.ts @@ -15,6 +15,109 @@ export const shadow = (el: T): ShadowRoot | T => { return el.shadowRoot || el; }; +const animateFixedBackButton = ( + root: Animation, + navEl: HTMLElement, + page: HTMLElement, + entering: boolean, + interactive: boolean, + otherPage?: HTMLElement, +) => { + const button = page.querySelector( + ':scope > ion-header.header-translucent:not(.ios26-disabled) ion-back-button:not(.ios26-disabled)', + ); + if (!button || button.offsetWidth === 0) { + return; + } + + const otherButton = otherPage?.querySelector( + ':scope > ion-header.header-translucent:not(.ios26-disabled) ion-back-button:not(.ios26-disabled)', + ); + const persistent = !!otherButton && otherButton.offsetWidth > 0; + const buttons = [button, ...(persistent ? [otherButton!] : [])].map((element) => ({ + element, + visibility: element.style.visibility, + })); + const rect = button.getBoundingClientRect(); + const width = button.offsetWidth; + const height = button.offsetHeight; + const clone = getClonedElement('ion-back-button'); + if (!clone) { + return; + } + const cloneParent = clone.parentNode!; + const cloneNextSibling = clone.nextSibling; + const cloneStyle = clone.getAttribute('style'); + clone.icon = button.icon; + clone.text = button.text; + clone.mode = button.mode; + clone.color = button.color; + clone.disabled = button.disabled; + const icon = shadow(clone).querySelector('ion-icon'); + const animation = createAnimation().addElement(clone); + const fadeStart = !entering && interactive ? 0.8 : 0.4; + const fadeEnd = entering ? 0.96 : interactive ? 1 : 0.95; + if (persistent) { + animation.fromTo('transform', 'scale(1)', 'scale(1)').fromTo('opacity', 1, 1); + } else if (entering) { + animation.keyframes([ + { offset: 0, transform: 'scale(1.2)', opacity: 0 }, + { offset: fadeStart, transform: 'scale(1.2)', opacity: 0 }, + { offset: 0.65, transform: 'scale(1.12)', opacity: 0.15 }, + { offset: 0.9, transform: 'scale(1.02)', opacity: 0.75 }, + { offset: fadeEnd, transform: 'scale(1)', opacity: 1 }, + { offset: 1, transform: 'scale(1)', opacity: 1 }, + ]); + } else { + animation.keyframes([ + { offset: 0, transform: 'scale(1)', opacity: 1 }, + { offset: fadeStart, transform: 'scale(1)', opacity: 1 }, + { offset: fadeEnd, transform: 'scale(1.2)', opacity: 0 }, + { offset: 1, transform: 'scale(1.2)', opacity: 0 }, + ]); + } + if (icon && !persistent) { + const from = entering ? 'blur(4px)' : 'blur(0px)'; + const to = entering ? 'blur(0px)' : 'blur(4px)'; + animation.addAnimation( + createAnimation() + .addElement(icon) + .keyframes([ + { offset: 0, filter: from }, + { offset: fadeStart, filter: from }, + { offset: fadeEnd, filter: to }, + { offset: 1, filter: to }, + ]), + ); + } + root.beforeAddWrite(() => { + Object.assign(clone.style, { + position: 'fixed', + left: `${rect.left + (rect.width - width) / 2}px`, + top: `${rect.top + (rect.height - height) / 2}px`, + width: `${width}px`, + height: `${height}px`, + margin: '0', + pointerEvents: 'none', + visibility: 'visible', + display: getComputedStyle(button).display, + zIndex: '1000', + }); + navEl.appendChild(clone); + buttons.forEach(({ element }) => (element.style.visibility = 'hidden')); + }); + root.afterAddWrite(() => { + buttons.forEach(({ element, visibility }) => (element.style.visibility = visibility)); + cloneParent.insertBefore(clone, cloneNextSibling); + if (cloneStyle === null) { + clone.removeAttribute('style'); + } else { + clone.setAttribute('style', cloneStyle); + } + }); + root.addAnimation(animation); +}; + const getLargeTitle = (refEl: any) => { const tabs = refEl.tagName === 'ION-TABS' ? refEl : refEl.querySelector('ion-tabs'); const query = 'ion-content ion-header:not(.header-collapse-condense-inactive) ion-title.title-large'; @@ -519,6 +622,17 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio .fill('both') .beforeRemoveClass('ion-page-invisible'); + const topPage = backDirection ? leavingEl : enteringEl; + if (topPage?.querySelector(':scope > ion-header.header-translucent:not(.ios26-disabled)')) { + const shadow = topPage.style.boxShadow; + rootAnimation.beforeAddWrite(() => { + topPage.style.boxShadow = `${isRTL ? 4 : -4}px 0 24px rgba(0, 0, 0, 0.04)`; + }); + rootAnimation.afterAddWrite(() => { + topPage.style.boxShadow = shadow; + }); + } + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain if (leavingEl && navEl !== null && navEl !== undefined) { const navDecorAnimation = createAnimation(); @@ -526,7 +640,9 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio rootAnimation.addAnimation(navDecorAnimation); } - if (!contentEl && enteringToolBarEls.length === 0 && headerEls.length === 0) { + if (enteringEl.querySelector(':scope > ion-header.header-translucent:not(.ios26-disabled)')) { + enteringContentAnimation.addElement(enteringEl); + } else if (!contentEl && enteringToolBarEls.length === 0 && headerEls.length === 0) { enteringContentAnimation.addElement(enteringEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs')!); // REVIEW } else { enteringContentAnimation.addElement(contentEl!); // REVIEW @@ -545,7 +661,7 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio enteringContentAnimation.beforeClearStyles([OPACITY]).fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`); } - if (contentEl) { + if (contentEl && !enteringEl.querySelector(':scope > ion-header.header-translucent:not(.ios26-disabled)')) { const enteringTransitionEffectEl = shadow(contentEl).querySelector('.transition-effect'); if (enteringTransitionEffectEl) { const enteringTransitionCoverEl = enteringTransitionEffectEl.querySelector('.transition-cover'); @@ -575,10 +691,23 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio } } + if (topPage) { + animateFixedBackButton( + rootAnimation, + navEl, + topPage, + !backDirection, + opts.progressCallback !== undefined, + backDirection ? enteringEl : leavingEl, + ); + } + const enteringContentHasLargeTitle = enteringEl.querySelector('ion-header.header-collapse-condense'); - const { forward, backward } = createLargeTitleTransition(rootAnimation, isRTL, backDirection, enteringEl, leavingEl); enteringToolBarEls.forEach((enteringToolBarEl) => { + if (enteringToolBarEl.closest('ion-header')?.matches('.header-translucent:not(.ios26-disabled)')) { + return; + } const enteringToolBar = createAnimation(); enteringToolBar.addElement(enteringToolBarEl); rootAnimation.addAnimation(enteringToolBar); @@ -647,11 +776,9 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio } // forward direction, entering page has a back button - if (!forward) { - enteringBackButton.fromTo(OPACITY, 0.01, 1); - } + enteringBackButton.fromTo(OPACITY, 0.01, 1); - if (backButtonEl && !forward) { + if (backButtonEl) { const enteringBackBtnText = createAnimation(); enteringBackBtnText .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW @@ -669,7 +796,9 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio const leavingToolBarEls = leavingEl.querySelectorAll(':scope > ion-header > ion-toolbar'); const leavingHeaderEls = leavingEl.querySelectorAll(':scope > ion-header > *:not(ion-toolbar), :scope > ion-footer > *'); - if (!leavingContentEl && leavingToolBarEls.length === 0 && leavingHeaderEls.length === 0) { + if (leavingEl.querySelector(':scope > ion-header.header-translucent:not(.ios26-disabled)')) { + leavingContent.addElement(leavingEl); + } else if (!leavingContentEl && leavingToolBarEls.length === 0 && leavingHeaderEls.length === 0) { leavingContent.addElement(leavingEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs')!); // REVIEW } else { leavingContent.addElement(leavingContentEl!); // REVIEW @@ -695,7 +824,7 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio leavingContent.fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`).fromTo(OPACITY, 1, OFF_OPACITY); } - if (leavingContentEl) { + if (leavingContentEl && !leavingEl.querySelector(':scope > ion-header.header-translucent:not(.ios26-disabled)')) { const leavingTransitionEffectEl = shadow(leavingContentEl).querySelector('.transition-effect'); if (leavingTransitionEffectEl) { @@ -727,6 +856,9 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio } leavingToolBarEls.forEach((leavingToolBarEl) => { + if (leavingToolBarEl.closest('ion-header')?.matches('.header-translucent:not(.ios26-disabled)')) { + return; + } const leavingToolBar = createAnimation(); leavingToolBar.addElement(leavingToolBarEl); @@ -789,7 +921,7 @@ export const iosTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptio leavingToolBarBg.fromTo('transform', 'translateX(0px)', isRTL ? 'translateX(-100%)' : 'translateX(100%)'); } - if (backButtonEl && !backward) { + if (backButtonEl) { const leavingBackBtnText = createAnimation(); leavingBackBtnText .addElement(shadow(backButtonEl).querySelector('.button-text')!) // REVIEW