diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 555132d45..6da789ef6 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -21,7 +21,7 @@ import { HttpClient, HttpClientModule } from '@angular/common/http'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { TranslateHttpLoader } from '@ngx-translate/http-loader'; -import { IonicModule, IonicRouteStrategy, iosTransitionAnimation } from '@ionic/angular'; +import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { CoreModule } from '@/core/core.module'; import { AddonsModule } from '@/addons/addons.module'; @@ -31,6 +31,7 @@ import { AppRoutingModule } from './app-routing.module'; import { JitCompilerFactory } from '@angular/platform-browser-dynamic'; import { CoreCronDelegate } from '@services/cron'; import { CoreSiteInfoCronHandler } from '@services/handlers/site-info-cron'; +import { moodleTransitionAnimation } from '@classes/page-transition'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { @@ -44,7 +45,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { BrowserAnimationsModule, IonicModule.forRoot( { - navAnimation: iosTransitionAnimation, + navAnimation: moodleTransitionAnimation, }, ), HttpClientModule, // HttpClient is used to make JSON requests. It fails for HEAD requests because there is no content. diff --git a/src/core/classes/page-transition.ts b/src/core/classes/page-transition.ts new file mode 100644 index 000000000..7443f6c55 --- /dev/null +++ b/src/core/classes/page-transition.ts @@ -0,0 +1,366 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { createAnimation } from '@ionic/angular'; +import { Animation, NavOptions } from '@ionic/core'; + +/** + * Adaptation from Ionic 5 iOs transition. + * + * https://github.com/ionic-team/ionic-framework/blob/5.8.x/core/src/utils/transition/ios.transition.ts + * Removed large title options, translucent header, buttons-collapse and header-collapse-condense-inactive. + * Changed large title check by collapsible header. + */ + +const DURATION = 540; + +const getIonPageElement = (element: HTMLElement): HTMLElement | null => { + if (element.classList.contains('ion-page')) { + return element; + } + + const ionPage: HTMLElement | null = element.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs'); + if (ionPage) { + return ionPage; + } + + // idk, return the original element so at least something animates and we don't have a null pointer + return element; +}; + +export interface TransitionOptions extends NavOptions { + progressCallback?: ((ani: Animation | undefined) => void); + baseEl: HTMLElement; + enteringEl: HTMLElement; + leavingEl: HTMLElement | undefined; +}; + +export const shadow = (el: T): ShadowRoot | T => el.shadowRoot || el; + +export const moodleTransitionAnimation = (navEl: HTMLElement, opts: TransitionOptions): Animation => { + const EASING = 'cubic-bezier(0.32,0.72,0,1)'; + const OPACITY = 'opacity'; + const TRANSFORM = 'transform'; + const CENTER = '0%'; + const OFF_OPACITY = 0.8; + + const isRTL = navEl.ownerDocument.dir === 'rtl'; + const OFF_RIGHT = isRTL ? '-99.5%' : '99.5%'; + const OFF_LEFT = isRTL ? '33%' : '-33%'; + + const enteringEl = opts.enteringEl; + const leavingEl = opts.leavingEl; + + const backDirection = (opts.direction === 'back'); + const contentEl = enteringEl.querySelector(':scope > ion-content'); + const headerEls = enteringEl.querySelectorAll(':scope > ion-header > *:not(ion-toolbar), :scope > ion-footer > *'); + const enteringToolBarEls = enteringEl.querySelectorAll(':scope > ion-header > ion-toolbar'); + + const rootAnimation = createAnimation(); + const enteringContentAnimation = createAnimation(); + + rootAnimation + .addElement(enteringEl) + .duration(opts.duration || DURATION) + .easing(opts.easing || EASING) + .fill('both') + .beforeRemoveClass('ion-page-invisible'); + + if (leavingEl && navEl) { + const navDecorAnimation = createAnimation(); + navDecorAnimation.addElement(navEl); + rootAnimation.addAnimation(navDecorAnimation); + } + + if (!contentEl && enteringToolBarEls.length === 0 && headerEls.length === 0) { + enteringContentAnimation.addElement( + enteringEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs') || [], + ); + } else { + enteringContentAnimation.addElement(contentEl || []); + enteringContentAnimation.addElement(headerEls); + } + + rootAnimation.addAnimation(enteringContentAnimation); + + if (backDirection) { + enteringContentAnimation + .beforeClearStyles([OPACITY]) + .fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`) + .fromTo(OPACITY, OFF_OPACITY, 1); + } else { + // entering content, forward direction + enteringContentAnimation + .beforeClearStyles([OPACITY]) + .fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`); + } + + if (contentEl) { + const enteringTransitionEffectEl: HTMLElement | null = shadow(contentEl).querySelector('.transition-effect'); + if (enteringTransitionEffectEl) { + const enteringTransitionCoverEl: HTMLElement | null = enteringTransitionEffectEl.querySelector('.transition-cover'); + const enteringTransitionShadowEl: HTMLElement | null = enteringTransitionEffectEl.querySelector('.transition-shadow'); + + if (!enteringTransitionCoverEl || !enteringTransitionShadowEl) { + return rootAnimation; + } + + const enteringTransitionEffect = createAnimation(); + const enteringTransitionCover = createAnimation(); + const enteringTransitionShadow = createAnimation(); + + enteringTransitionEffect + .addElement(enteringTransitionEffectEl) + .beforeStyles({ opacity: '1', display: 'block' }) + .afterStyles({ opacity: '', display: '' }); + + enteringTransitionCover + .addElement(enteringTransitionCoverEl) + .beforeClearStyles([OPACITY]) + .fromTo(OPACITY, 0, 0.1); + + enteringTransitionShadow + .addElement(enteringTransitionShadowEl) + .beforeClearStyles([OPACITY]) + .fromTo(OPACITY, 0.03, 0.70); + + enteringTransitionEffect.addAnimation([enteringTransitionCover, enteringTransitionShadow]); + enteringContentAnimation.addAnimation([enteringTransitionEffect]); + } + } + + const enteringContentHasCollapsibleTitle = enteringEl.querySelector('ion-header[collapsible]'); + enteringToolBarEls.forEach(enteringToolBarEl => { + const enteringToolBar = createAnimation(); + enteringToolBar.addElement(enteringToolBarEl); + rootAnimation.addAnimation(enteringToolBar); + + const enteringTitle = createAnimation(); + enteringTitle.addElement(enteringToolBarEl.querySelector('ion-title') || []); + + const enteringToolBarButtons = createAnimation(); + const buttons: HTMLIonButtonsElement[] = Array.from(enteringToolBarEl.querySelectorAll('ion-buttons,[menuToggle]')); + + enteringToolBarButtons.addElement(buttons); + + const enteringToolBarItems = createAnimation(); + enteringToolBarItems.addElement( + enteringToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])'), + ); + + const enteringToolBarBg = createAnimation(); + enteringToolBarBg.addElement(shadow(enteringToolBarEl).querySelector('.toolbar-background') || []); + + const enteringBackButton = createAnimation(); + const backButtonEl = enteringToolBarEl.querySelector('ion-back-button'); + + if (backButtonEl) { + enteringBackButton.addElement(backButtonEl); + } + + enteringToolBar.addAnimation( + [enteringTitle, enteringToolBarButtons, enteringToolBarItems, enteringToolBarBg, enteringBackButton], + ); + enteringToolBarButtons.fromTo(OPACITY, 0.01, 1); + enteringToolBarItems.fromTo(OPACITY, 0.01, 1); + + if (backDirection) { + enteringTitle + .fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`) + .fromTo(OPACITY, 0.01, 1); + + enteringToolBarItems.fromTo('transform', `translateX(${OFF_LEFT})`, `translateX(${CENTER})`); + + // back direction, entering page has a back button + enteringBackButton.fromTo(OPACITY, 0.01, 1); + } else { + // entering toolbar, forward direction + if (!enteringContentHasCollapsibleTitle) { + enteringTitle + .fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`) + .fromTo(OPACITY, 0.01, 1); + } + + enteringToolBarItems.fromTo('transform', `translateX(${OFF_RIGHT})`, `translateX(${CENTER})`); + enteringToolBarBg.beforeClearStyles([OPACITY, 'transform']); + + enteringToolBarBg.fromTo(OPACITY, 0.01, 'var(--opacity)'); + + // forward direction, entering page has a back button + enteringBackButton.fromTo(OPACITY, 0.01, 1); + + if (backButtonEl) { + const enteringBackBtnText = createAnimation(); + enteringBackBtnText + .addElement(shadow(backButtonEl).querySelector('.button-text') || []) + .fromTo('transform', (isRTL ? 'translateX(-100px)' : 'translateX(100px)'), 'translateX(0px)'); + + enteringToolBar.addAnimation(enteringBackBtnText); + } + } + }); + + // setup leaving view + if (leavingEl) { + const leavingContent = createAnimation(); + const leavingContentEl = leavingEl.querySelector(':scope > ion-content'); + 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) { + leavingContent.addElement( + leavingEl.querySelector(':scope > .ion-page, :scope > ion-nav, :scope > ion-tabs') || [], + ); + } else { + leavingContent.addElement(leavingContentEl || []); + leavingContent.addElement(leavingHeaderEls); + } + + rootAnimation.addAnimation(leavingContent); + + if (backDirection) { + // leaving content, back direction + leavingContent + .beforeClearStyles([OPACITY]) + .fromTo('transform', `translateX(${CENTER})`, (isRTL ? 'translateX(-100%)' : 'translateX(100%)')); + + const leavingPage = getIonPageElement(leavingEl) as HTMLElement; + rootAnimation.afterAddWrite(() => { + if (rootAnimation.getDirection() === 'normal') { + leavingPage.style.setProperty('display', 'none'); + } + }); + + } else { + // leaving content, forward direction + leavingContent + .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) + .fromTo(OPACITY, 1, OFF_OPACITY); + } + + if (leavingContentEl) { + const leavingTransitionEffectEl = shadow(leavingContentEl).querySelector('.transition-effect'); + + if (leavingTransitionEffectEl) { + const leavingTransitionCoverEl = leavingTransitionEffectEl.querySelector('.transition-cover'); + const leavingTransitionShadowEl = leavingTransitionEffectEl.querySelector('.transition-shadow'); + + const leavingTransitionEffect = createAnimation(); + const leavingTransitionCover = createAnimation(); + const leavingTransitionShadow = createAnimation(); + + leavingTransitionEffect + .addElement(leavingTransitionEffectEl) + .beforeStyles({ opacity: '1', display: 'block' }) + .afterStyles({ opacity: '', display: '' }); + + leavingTransitionCover + .addElement(leavingTransitionCoverEl || []) + .beforeClearStyles([OPACITY]) + .fromTo(OPACITY, 0.1, 0); + + leavingTransitionShadow + .addElement(leavingTransitionShadowEl || []) + .beforeClearStyles([OPACITY]) + .fromTo(OPACITY, 0.70, 0.03); + + leavingTransitionEffect.addAnimation([leavingTransitionCover, leavingTransitionShadow]); + leavingContent.addAnimation([leavingTransitionEffect]); + } + } + + leavingToolBarEls.forEach(leavingToolBarEl => { + const leavingToolBar = createAnimation(); + leavingToolBar.addElement(leavingToolBarEl); + + const leavingTitle = createAnimation(); + leavingTitle.addElement(leavingToolBarEl.querySelector('ion-title') || []); + + const leavingToolBarButtons = createAnimation(); + const buttons: HTMLIonButtonsElement[] = Array.from(leavingToolBarEl.querySelectorAll('ion-buttons,[menuToggle]')); + + leavingToolBarButtons.addElement(buttons); + + const leavingToolBarItems = createAnimation(); + const leavingToolBarItemEls = + leavingToolBarEl.querySelectorAll(':scope > *:not(ion-title):not(ion-buttons):not([menuToggle])'); + if (leavingToolBarItemEls.length > 0) { + leavingToolBarItems.addElement(leavingToolBarItemEls); + } + + const leavingToolBarBg = createAnimation(); + leavingToolBarBg.addElement(shadow(leavingToolBarEl).querySelector('.toolbar-background') || []); + + const leavingBackButton = createAnimation(); + const backButtonEl = leavingToolBarEl.querySelector('ion-back-button'); + if (backButtonEl) { + leavingBackButton.addElement(backButtonEl); + } + + leavingToolBar.addAnimation( + [leavingTitle, leavingToolBarButtons, leavingToolBarItems, leavingBackButton, leavingToolBarBg], + ); + rootAnimation.addAnimation(leavingToolBar); + + // fade out leaving toolbar items + leavingBackButton.fromTo(OPACITY, 0.99, 0); + + leavingToolBarButtons.fromTo(OPACITY, 0.99, 0); + leavingToolBarItems.fromTo(OPACITY, 0.99, 0); + + if (backDirection) { + // leaving toolbar, back direction + leavingTitle + .fromTo('transform', `translateX(${CENTER})`, (isRTL ? 'translateX(-100%)' : 'translateX(100%)')) + .fromTo(OPACITY, 0.99, 0); + + leavingToolBarItems.fromTo( + 'transform', + `translateX(${CENTER})`, + (isRTL ? 'translateX(-100%)' : 'translateX(100%)'), + ); + leavingToolBarBg.beforeClearStyles([OPACITY, 'transform']); + // leaving toolbar, back direction, and there's no entering toolbar + // should just slide out, no fading out + leavingToolBarBg.fromTo(OPACITY, 'var(--opacity)', 0); + + if (backButtonEl) { + const leavingBackBtnText = createAnimation(); + leavingBackBtnText + .addElement(shadow(backButtonEl).querySelector('.button-text') || []) + .fromTo('transform', `translateX(${CENTER})`, `translateX(${(isRTL ? -124 : 124) + 'px'})`); + leavingToolBar.addAnimation(leavingBackBtnText); + } + + } else { + // leaving toolbar, forward direction + leavingTitle + .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) + .fromTo(OPACITY, 0.99, 0) + .afterClearStyles([TRANSFORM, OPACITY]); + + leavingToolBarItems + .fromTo('transform', `translateX(${CENTER})`, `translateX(${OFF_LEFT})`) + .afterClearStyles([TRANSFORM, OPACITY]); + + leavingBackButton.afterClearStyles([OPACITY]); + leavingTitle.afterClearStyles([OPACITY]); + leavingToolBarButtons.afterClearStyles([OPACITY]); + } + }); + } + + return rootAnimation; + +};