diff --git a/src/core/components/loading/loading.ts b/src/core/components/loading/loading.ts index a0254ee8b..dd06222c0 100644 --- a/src/core/components/loading/loading.ts +++ b/src/core/components/loading/loading.ts @@ -18,6 +18,8 @@ import { CoreEventLoadingChangedData, CoreEvents } from '@singletons/events'; import { CoreUtils } from '@services/utils/utils'; import { CoreAnimations } from '@components/animations'; import { Translate } from '@singletons'; +import { CoreComponentsRegistry } from '@singletons/components-registry'; +import { CorePromisedValue } from '@classes/promised-value'; /** * Component to show a loading spinner and message while data is being loaded. @@ -56,16 +58,18 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit { uniqueId: string; protected element: HTMLElement; // Current element. loaded = false; // Only comes true once. + protected firstLoadedPromise = new CorePromisedValue(); constructor(element: ElementRef) { this.element = element.nativeElement; + CoreComponentsRegistry.register(this.element, this); // Calculate the unique ID. this.uniqueId = 'core-loading-content-' + CoreUtils.getUniqueId('CoreLoadingComponent'); } /** - * Component being initialized. + * @inheritdoc */ ngOnInit(): void { if (!this.message) { @@ -77,50 +81,54 @@ export class CoreLoadingComponent implements OnInit, OnChanges, AfterViewInit { } /** - * View has been initialized. + * @inheritdoc */ ngAfterViewInit(): void { - // Add class if loaded on init. - if (this.hideUntil) { - this.element.classList.add('core-loading-loaded'); - } - this.loaded = !!this.hideUntil; - - this.content?.nativeElement.classList.toggle('core-loading-content', !!this.hideUntil); + this.changeState(!!this.hideUntil); } /** - * Component input changed. - * - * @param changes Changes. + * @inheritdoc */ ngOnChanges(changes: { [name: string]: SimpleChange }): void { if (changes.hideUntil) { - if (!this.loaded) { - this.loaded = !!this.hideUntil; // Only comes true once. - } - - if (this.hideUntil) { - setTimeout(() => { - // Content is loaded so, center the spinner on the content itself. - this.element.classList.add('core-loading-loaded'); - // Change CSS to force calculate height. - // Removed 500ms timeout to avoid reallocating html. - this.content?.nativeElement.classList.add('core-loading-content'); - }); - } else { - this.element.classList.remove('core-loading-loaded'); - this.content?.nativeElement.classList.remove('core-loading-content'); - } - - // Trigger the event after a timeout since the elements inside ngIf haven't been added to DOM yet. - setTimeout(() => { - CoreEvents.trigger(CoreEvents.CORE_LOADING_CHANGED, { - loaded: !!this.hideUntil, - uniqueId: this.uniqueId, - }); - }); + this.changeState(!!this.hideUntil); } } + /** + * Change loaded state. + * + * @param loaded True to load, false otherwise. + * @return Promise resolved when done. + */ + async changeState(loaded: boolean): Promise { + await CoreUtils.nextTick(); + + this.element.classList.toggle('core-loading-loaded', loaded); + this.content?.nativeElement.classList.add('core-loading-content', loaded); + + await CoreUtils.nextTick(); + + // Wait for next tick before triggering the event to make sure ngIf elements have been added to the DOM. + CoreEvents.trigger(CoreEvents.CORE_LOADING_CHANGED, { + loaded: loaded, + uniqueId: this.uniqueId, + }); + + if (!this.loaded && loaded) { + this.loaded = true; // Only comes true once. + this.firstLoadedPromise.resolve(this.uniqueId); + } + } + + /** + * Wait the loading to finish. + * + * @return Promise resolved with the uniqueId when done. + */ + async whenLoaded(): Promise { + return await this.firstLoadedPromise; + } + } diff --git a/src/core/directives/collapsible-header.ts b/src/core/directives/collapsible-header.ts index faf83ddbe..cceaca12e 100644 --- a/src/core/directives/collapsible-header.ts +++ b/src/core/directives/collapsible-header.ts @@ -13,6 +13,8 @@ // limitations under the License. import { Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, SimpleChange } from '@angular/core'; +import { CorePromisedValue } from '@classes/promised-value'; +import { CoreLoadingComponent } from '@components/loading/loading'; import { CoreTabsOutletComponent } from '@components/tabs-outlet/tabs-outlet'; import { ScrollDetail } from '@ionic/core'; import { CoreUtils } from '@services/utils/utils'; @@ -55,32 +57,36 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest @Input() collapsible = true; protected page?: HTMLElement; - protected collapsedHeader?: Element; + protected collapsedHeader: HTMLIonHeaderElement; protected collapsedFontStyles?: Partial; - protected expandedHeader?: Element; + protected expandedHeader?: HTMLIonItemElement; protected expandedHeaderHeight?: number; protected expandedFontStyles?: Partial; protected content?: HTMLIonContentElement; protected contentScrollListener?: EventListener; protected endContentScrollListener?: EventListener; - protected floatingTitle?: HTMLElement; + protected pageDidEnterListener?: EventListener; + protected floatingTitle?: HTMLHeadingElement; protected scrollingHeight?: number; protected subscriptions: Subscription[] = []; protected enabled = true; protected isWithinContent = false; + protected enteredPromise = new CorePromisedValue(); - constructor(protected el: ElementRef) {} + constructor(el: ElementRef) { + this.collapsedHeader = el.nativeElement; + } /** * @inheritdoc */ async ngOnInit(): Promise { - this.collapsedHeader = this.el.nativeElement; + this.initializePage(); await Promise.all([ - this.initializePage(), this.initializeCollapsedHeader(), this.initializeExpandedHeader(), + await this.enteredPromise, ]); this.initializeFloatingTitle(); @@ -111,30 +117,42 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest if (this.content && this.endContentScrollListener) { this.content.removeEventListener('ionScrollEnd', this.endContentScrollListener); } + if (this.page && this.pageDidEnterListener) { + this.page.removeEventListener('ionViewDidEnter', this.pageDidEnterListener); + } } /** * Search the page element, initialize it, and wait until it's ready for the transition to trigger on scroll. */ - protected async initializePage(): Promise { - if (!this.collapsedHeader?.parentElement) { + protected initializePage(): void { + if (!this.collapsedHeader.parentElement) { throw new Error('[collapsible-header] Couldn\'t get page'); } // Find element and prepare classes. this.page = this.collapsedHeader.parentElement; - this.page.classList.add('collapsible-header-page'); + + this.page.addEventListener( + 'ionViewDidEnter', + this.pageDidEnterListener = () => { + clearTimeout(timeout); + this.enteredPromise.resolve(); + }, + { once: true }, + ); + + // Timeout in case event is never fired. + const timeout = window.setTimeout(() => { + this.enteredPromise.reject(new Error('[collapsible-header] Waiting for ionViewDidEnter timeout reached')); + }, 5000); } /** * Search the collapsed header element, initialize it, and wait until it's ready for the transition to trigger on scroll. */ protected async initializeCollapsedHeader(): Promise { - if (!this.collapsedHeader) { - throw new Error('[collapsible-header] Couldn\'t initialize collapsed header'); - } - this.collapsedHeader.classList.add('collapsible-header-collapsed'); await this.waitFormatTextsRendered(this.collapsedHeader); @@ -144,23 +162,16 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest * Search the expanded header element, initialize it, and wait until it's ready for the transition to trigger on scroll. */ protected async initializeExpandedHeader(): Promise { - do { - await CoreUtils.wait(50); + await this.waitLoadingsDone(); - this.expandedHeader = this.page?.querySelector('ion-item[collapsible]') ?? undefined; - - if (!this.expandedHeader) { - continue; - } - - await this.waitFormatTextsRendered(this.expandedHeader); - } while ( - !this.expandedHeader || - this.expandedHeader.clientHeight === 0 || - this.expandedHeader.closest('ion-content.animating') - ); + this.expandedHeader = this.page?.querySelector('ion-item[collapsible]') ?? undefined; + if (!this.expandedHeader) { + throw new Error('[collapsible-header] Couldn\'t initialize expanded header'); + } this.expandedHeader.classList.add('collapsible-header-expanded'); + + await this.waitFormatTextsRendered(this.expandedHeader); } /** @@ -180,6 +191,8 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest this.subscriptions.push(outlet.activateEvents.subscribe(onOutletUpdated)); + onOutletUpdated(); + return; } @@ -197,15 +210,15 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest * Initialize a floating title to mimic transitioning the title from one state to the other. */ protected initializeFloatingTitle(): void { - if (!this.page || !this.collapsedHeader || !this.expandedHeader) { + if (!this.page || !this.expandedHeader) { throw new Error('[collapsible-header] Couldn\'t create floating title'); } // Add floating title and measure initial position. - const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLElement; - const originalTitle = this.expandedHeader.querySelector('h1') as HTMLElement; + const collapsedHeaderTitle = this.collapsedHeader.querySelector('h1') as HTMLHeadingElement; + const originalTitle = this.expandedHeader.querySelector('h1') as HTMLHeadingElement; const floatingTitleWrapper = originalTitle.parentElement as HTMLElement; - const floatingTitle = originalTitle.cloneNode(true) as HTMLElement; + const floatingTitle = originalTitle.cloneNode(true) as HTMLHeadingElement; originalTitle.classList.add('collapsible-header-original-title'); floatingTitle.classList.add('collapsible-header-floating-title'); @@ -265,17 +278,22 @@ export class CoreCollapsibleHeaderDirective implements OnInit, OnChanges, OnDest this.expandedHeaderHeight = expandedHeaderHeight; } + /** + * Wait until all children inside the page. + * + * @return Promise resolved when loadings are done. + */ + protected async waitLoadingsDone(): Promise { + await CoreComponentsRegistry.resolveAllElementsInside(this.page, 'core-loading', 'whenLoaded'); + } + /** * Wait until all children inside the element are done rendering. * * @param element Element. */ protected async waitFormatTextsRendered(element: Element): Promise { - const formatTexts = Array - .from(element.querySelectorAll('core-format-text')) - .map(element => CoreComponentsRegistry.resolve(element, CoreFormatTextDirective)); - - await Promise.all(formatTexts.map(formatText => formatText?.rendered())); + await CoreComponentsRegistry.resolveAllElementsInside(element, 'core-format-text', 'rendered'); } /** diff --git a/src/core/singletons/components-registry.ts b/src/core/singletons/components-registry.ts index a5c7b12fb..8087d5409 100644 --- a/src/core/singletons/components-registry.ts +++ b/src/core/singletons/components-registry.ts @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { Component } from '@angular/core'; +import { CoreUtils } from '@services/utils/utils'; + /** * Registry to keep track of component instances. */ @@ -36,7 +39,7 @@ export class CoreComponentsRegistry { * @param componentClass Component class. * @returns Component instance. */ - static resolve(element?: Element | null, componentClass?: ComponentConstructor): T | null { + static resolve(element?: Element | null, componentClass?: ComponentConstructor): T | null { const instance = (element && this.instances.get(element) as T) ?? null; return instance && (!componentClass || instance instanceof componentClass) @@ -44,6 +47,39 @@ export class CoreComponentsRegistry { : null; } + /** + * Waits all elements to be resolved. + * + * @param element Parent element where to search. + * @param selector Selector to search on parent. + * @param fnName Function that needs to get resolved. + * @param params Params of function that needs to get resolved. + * @return Promise resolved when done. + */ + static async resolveAllElementsInside( + element: Element | undefined | null, + selector: string, + fnName: string, + params?: unknown[], + ): Promise { + if (!element) { + return; + } + + const components = Array + .from(element.querySelectorAll(selector)) + .map(element => CoreComponentsRegistry.resolve(element)); + + await Promise.all(components.map(component => { + if (!component) { + return; + } + + return component[fnName].apply(component, params); + })); + await CoreUtils.nextTick(); + } + } /**